Step 6 of 6
Putting it together: Config, run() and clap
Every well-built Rust CLI ends up with the same shape:
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let command = match parse_args(&args) { // 1. arguments -> a typed Config/Command
Ok(c) => c,
Err(msg) => {
eprintln!("{msg}\n{USAGE}");
return ExitCode::from(2); // usage errors: exit 2
}
};
match run(command) { // 2. do the work, returning Result
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}"); // 3. report on stderr
ExitCode::FAILURE // failures: exit 1
}
}
}
- Parsing turns strings into a typed value (a
Configstruct, or an enum with one variant per subcommand). After this point, nothing looks at raw arguments. rundoes the work and returnsResult, so it can use?everywhere (with anyhow and context from the last module, in a real project).mainis the only place that knows about exit codes and the usage text.- The decisions inside
runlive in small functions over&strand plain values, which unit tests can call directly.
clap: what teams use for real
Hand-written parsing is fine for small tools. For anything bigger, teams use the clap crate. You describe the arguments as a struct, and #[derive(Parser)] generates the parser, --help, --version, clear error messages (including suggestions for misspelled flags), and exit code 2 for usage errors. clap isn't available on this site, so here's what it looks like (not compiled):
[dependencies]
clap = { version = "4", features = ["derive"] }
use clap::{Parser, Subcommand};
/// A tiny todo list
#[derive(Parser)]
#[command(version)]
struct Cli {
/// File that stores the tasks
#[arg(short, long, default_value = "todo.txt")]
file: String,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Show all tasks
List,
/// Add a task
Add { text: Vec<String> },
/// Mark task N as done
Done { n: usize },
}
fn main() {
let cli = Cli::parse(); // on bad arguments: prints the error and exits with 2
match cli.command {
Command::List => { /* ... */ }
Command::Add { text } => { /* ... */ }
Command::Done { n } => { /* ... */ }
}
}
The /// doc comments become the help text:
$ todo --help
A tiny todo list
Usage: todo [OPTIONS] <COMMAND>
Commands:
list Show all tasks
add Add a task
done Mark task N as done
help Print this message or the help of the given subcommand(s)
Options:
-f, --file <FILE> File that stores the tasks [default: todo.txt]
-h, --help Print help
-V, --version Print version
Notice it's the same design as this module's: a typed struct, an enum of subcommands, match in main. The Pro Track builds a complete CLI with clap, anyhow and integration tests; everything you practiced here carries over directly.
Common mistakes
- Business logic in
main. It can't be unit-tested and mixes concerns. Move it to functions. process::exitdeep in helper functions: the code becomes impossible to test and skips cleanup. Return errors; letmaindecide.- A usage message that doesn't say what's wrong. Print the specific problem first (
unknown command "lst"), then the usage.
Your task
Build todo, a todo list stored in todo.txt, one task per line written as [ ] text or [x] text (done). Commands:
list: print each task as1. [ ] buy milk; if there are none (or the file doesn't exist yet), printno tasks.add <words>...: append[ ] <words joined by spaces>to the file and printadded 3: buy milk(3 is the new task's number).done <n>: mark task n as done, write the file back, and printdone 2: feed the crab. If there's no task n (that includes0), printerror: no task 7to stderr and exit 1.- No command, an unknown command,
addwithout words ordonewithout a valid number: printusage: todo list | add <text> | done <n>to stderr and exit 2.
Challenge 2
Test the hard part without files: write fn mark_done(contents: &str, n: usize) -> Result<String, String> that takes the whole todo.txt text and returns the new text with task n (1-based) marked [x], each line ending in \n. A task that's already done stays done. A missing task gives Err("no task 7").
Challenge 3
Model the arguments the way clap does: write fn parse_command(args: &[String]) -> Result<Command, String> for a key-value store CLI with subcommands get <key>, set <key> <value>, del <key>... (one or more keys) and list [--limit N]. Errors: no subcommand gives missing command; an unknown one gives unknown command "x"; anything else wrong with a subcommand's arguments gives usage: <subcommand> ... with that subcommand's usage from the list below.
usage: get <key>usage: set <key> <value>usage: del <key>...usage: list [--limit N]