5.1 The Voice at the Door
The sign above a workshop entrance is not decoration. It tells the visitor where they have arrived before they knock. Nowhere is it written that someone must be on the other side - that is too obvious to spend ink on. A workshop without its owner is just a room with a sign.
What You Need
The main from chapter 4.5 runs the same sequence on every invocation: load the config,
add three tasks, print the list, complete one. The command line has no effect on any of it.
Writing tq list or tq add "Buy coffee" is the same as talking to a wall.
A command-line argument parser is needed. clap is the standard choice in the Rust
ecosystem: its derive interface lets you describe a CLI as a struct, and --help is
generated from it automatically.
The Build
Two changes to Cargo.toml: [[bin]] added by hand, clap via
cargo add clap --features derive:
# Cargo.toml - CHANGED
[[bin]]
name = "tq"
path = "src/main.rs"
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
[[bin]] in Cargo.toml names the binary tq - that name will appear in Usage: in
--help and in target/debug/. Without it, the binary takes its name from the name
field in [package] - correct if the package is already named tq. In the chapter’s
example code under tq/ the package is named after its chapter, so [[bin]] is
necessary there.
The CLI types and logic live in a separate src/cli/mod.rs. src/main.rs stays the entry
point: parse arguments, load the config, hand over control.
In src/cli/mod.rs:
// src/cli/mod.rs - NEW
use crate::config::Config;
use crate::error::TqResult;
use crate::persistence;
use crate::store::{Store, TaskStore};
use crate::task::Task;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(about = "Task queue manager", version)]
pub struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Add a new task
Add {
/// Task title
title: String,
},
/// List all tasks
List,
/// Show a task
Get {
/// Task id
id: u64,
},
/// Mark a task done
Done {
/// Task id
id: u64,
},
}
pub fn run(cli: Cli, config: Config) -> TqResult<()> {
let path = config.data_dir.join("tasks.json");
let tasks = persistence::load(&path)?;
let mut store = TaskStore::new(tasks);
match cli.command {
Commands::Add { title } => {
let task = Task::new(0, &title)?;
store.add(task)?;
persistence::save(store.all(), &path)?;
println!("added");
}
Commands::List => {
for task in store.all() {
let marker = if task.is_done() { "✓" } else { "·" };
println!("{marker} {task}");
}
}
Commands::Get { id } => {
println!("{}", store.get(id)?);
}
Commands::Done { id } => {
store.get_mut(id)?.complete();
persistence::save(store.all(), &path)?;
println!("done");
}
}
Ok(())
}
#[derive(Parser)] asks clap to generate a parser from Cli. #[command(...)] describes
the tool: about is the description string; version without an argument - clap takes the
version from Cargo.toml itself. #[command(subcommand)] says that the command field is
a dispatcher: clap looks for the first positional argument among the Commands variants.
run receives an already-parsed Cli and a loaded Config - reading the environment
stays on main’s side. Add creates a task through Task::new - an empty title will
return an error before anything is written; 0 as the id is a placeholder, the store will
overwrite it with its next value. List marks completed tasks with ✓, pending ones
with ·.
src/main.rs adds mod cli and reduces fn main to three lines:
// src/main.rs - CHANGED
mod cli;
mod config;
mod error;
mod persistence;
mod store;
mod task;
use clap::Parser;
use cli::Cli;
use config::Config;
use error::TqResult;
use std::path::Path;
fn main() -> TqResult<()> {
let cli = Cli::parse();
let config = Config::load(Path::new("config.toml"))?;
cli::run(cli, config)
}
parse_from checks argument parsing without reading env::args(). In src/cli/tests.rs:
// src/cli/tests.rs - NEW
use super::*;
#[test]
fn cli_parse_add() {
let cli = Cli::parse_from(["tq", "add", "Buy coffee"]);
let Commands::Add { title } = cli.command else { panic!("expected Add") };
assert_eq!(title, "Buy coffee");
}
#[test]
fn cli_parse_list() {
let cli = Cli::parse_from(["tq", "list"]);
assert!(matches!(cli.command, Commands::List));
}
#[test]
fn cli_parse_done() {
let cli = Cli::parse_from(["tq", "done", "42"]);
assert!(matches!(cli.command, Commands::Done { id: 42 }));
}
The Result
$ cargo run -- --help
Task queue manager
Usage: tq <COMMAND>
Commands:
add Add a new task
list List all tasks
get Show a task
done Mark a task done
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
-- separates Cargo’s arguments from the program’s own - without it, Cargo would try to
parse add as one of its own commands.
$ cargo run -- add --help
Add a new task
Usage: tq add <TITLE>
Arguments:
<TITLE> Task title
Options:
-h, --help Print help
cargo run -- add "Buy coffee" adds a task. cargo run -- list prints the list.
cargo run -- done 1 completes it.
make ci passes.
Note.
make run -- listdoes not work:--is consumed by make and never reaches the program. Arguments are passed through theARGSvariable:
# Makefile - CHANGED
run:
@cargo run -- $(ARGS)
After this:
make run ARGS="list",make run ARGS="add 'Buy coffee'".
The complete
tqcode for this chapter is in5-interface/01-the-voice-at-the-door/.
Lore: mod Declares, use Finds
In main.rs there are lines that fn main does not use directly:
mod persistence;
mod store;
mod task;
mod foo; is not an import. It is a declaration: “the file src/foo.rs exists and is
compiled with the program.” All such declarations live in main.rs - the entry point where
the compiler begins the build. src/cli/mod.rs reaches them through crate::persistence,
crate::store, crate::task - but only because main.rs declared their existence first.
Remove mod persistence; and crate::persistence in src/cli/mod.rs ceases to exist,
even though the file on disk goes nowhere.
Lore: How clap Reads Documentation
The lines /// Add a new task are Rust doc comments. The compiler stores them as a
#[doc = "..."] attribute on the type or field. clap reads exactly that attribute - not
the string literals directly.
The practical consequence: remove the comment and the variant remains, but --help shows
a blank. Not an error. Just help without explanation - a state that occurs more often than
one would like.