← Table of contents

5.2 The Four Commands

A craftsman who explains refusal as clearly as they describe success is rarer than one would like. The reason is understandable: refusals happen less often than successes, and they receive less thought. This reasoning always seems sound - right up until something goes wrong.

What You Need

tq responds to erroneous commands in debug format: the way the data looks inside the program, not what the workshop took the trouble to phrase for people. The process exit code is never set explicitly. The add command confirms the result with a single word, without naming what that result is.

The Build

Specifically: cargo run -- add "" currently exits like this:

Error: EmptyTitle

fn main() -> TqResult<()> relies on the Termination trait - it prints the error with {:?}, the Debug format. That is why EmptyTitle appears instead of title cannot be empty, which TqError is perfectly capable of producing. The Display implementation is there; it just is not being used yet.

A few edits to src/main.rs to take control:

// src/main.rs - CHANGED
fn main() {
    if let Err(e) = run() {
        eprintln!("tq: {e}");
        std::process::exit(1);
    }
}

fn run() -> TqResult<()> {
    let cli = Cli::parse();
    let config = Config::load(Path::new("config.toml"))?;
    cli::run(cli, config)
}

eprintln! writes to stderr - the diagnostic stream. println! writes to stdout, the output stream. The distinction matters: cargo run -- list | grep coffee reads stdout; if something goes wrong, the error in that pipeline should pass through separately, on stderr.

{e} calls Display - the one that returns title cannot be empty.

process::exit(1) terminates the process with exit code 1. Code 0 means success; any non-zero value means failure. This is the convention on which shell pipelines are built.

run() is a helper function where the familiar ? lives. main now only checks the result.


The add command currently prints a single word. Time to give the user something more useful.

First, teach Store::add to return the ID of the added task. In src/store/mod.rs:

// src/store/mod.rs - CHANGED
pub trait Store<T: HasId> {
    fn add(&mut self, item: impl Into<T>) -> TqResult<u64>;
    // ...
}

impl Store<Task> for TaskStore {
    fn add(&mut self, item: impl Into<Task>) -> TqResult<u64> {
        let mut task = item.into();
        let id = self.next_id;
        task.id = id;
        self.next_id += 1;
        self.items.push(task);
        Ok(id)
    }
    // ...
}

Now in src/cli/mod.rs:

// src/cli/mod.rs - CHANGED
Commands::Add { title } => {
    let task = Task::new(0, &title)?;
    let id = store.add(task)?;
    persistence::save(store.all(), &path)?;
    println!("{}", store.get(id)?);
}

store.add() returns the ID of the added task - passed directly to store.get().

The done command still prints that single word. Try fixing it yourself - store.get(id)? is already familiar. The complete solution is in the full code for this chapter under tq/.

The Result

$ cargo run -- add "Buy coffee" && echo "success"
#1: Buy coffee [Todo] (2026-06-03)
success

&& continues the chain only on exit code 0.

$ cargo run -- add "" && echo "success"
tq: title cannot be empty

Errors go to stderr, in human-readable text. Success goes to stdout, with details. make ci passes.

The complete tq code for this chapter is in 5-interface/02-the-four-commands/.


Lore: Why fn main() -> Result Prints Debug

Rust allows writing fn main() -> Result<(), E> - convenient, ? works directly in main. But when main returns Err(e), the standard library takes over the formatting: it prints Error: {e:?} - Debug, not Display. Hence EmptyTitle instead of title cannot be empty.

Display was written by hand. The standard library did not ask for it.

Explicit handling in run() restores control: now we decide what to write and where.