← Table of contents

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 -- list does not work: -- is consumed by make and never reaches the program. Arguments are passed through the ARGS variable:

# Makefile - CHANGED
run:
	@cargo run -- $(ARGS)

After this: make run ARGS="list", make run ARGS="add 'Buy coffee'".

The complete tq code for this chapter is in 5-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.