03 · Building CLIs with clap¶
Every language eventually needs a story for command-line tools, and Rust's
is the clap crate. It parses std::env::args() into typed structs, derives
--help and --version for free, and turns "user typed something wrong"
into a clean error message instead of a panic. This module builds a small
task-tracker CLI with subcommands, flags, and defaults.
Setup¶
The derive feature is what lets you describe a CLI as a struct with
attributes, instead of hand-building a parser. It costs a bit of compile
time; it saves a lot of boilerplate.
A struct is a CLI¶
use clap::{Parser, Subcommand};
/// A tiny task tracker
#[derive(Parser, Debug)]
#[command(name = "tasks", version, about)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Add a new task
Add {
text: String,
#[arg(short, long, default_value_t = 3)]
priority: u8,
},
/// List tasks
List {
#[arg(short, long)]
verbose: bool,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Add { text, priority } => {
println!("added \"{text}\" with priority {priority}");
}
Commands::List { verbose } => {
if verbose {
println!("listing tasks (verbose)");
} else {
println!("listing tasks");
}
}
}
}
#[derive(Parser)] on the top-level struct wires up Cli::parse().
#[command(subcommand)] says "this field's variants become subcommands."
Each enum variant's fields become that subcommand's own arguments — Add
gets a positional text and an optional --priority, List gets a boolean
--verbose flag.
Running it:
$ cargo run -q -- add "write docs" --priority 1
added "write docs" with priority 1
$ cargo run -q -- list -v
listing tasks (verbose)
$ cargo run -q -- --help
A tiny task tracker
Usage: tasks <COMMAND>
Commands:
add Add a new task
list List tasks
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
That --help text was not written by hand — every line came from the doc
comments (///) and attributes on the struct and enum. Keep them accurate
and the help output stays accurate.
Missing required arguments fail loudly, not silently¶
$ cargo run -q -- add
error: the following required arguments were not provided:
<TEXT>
Usage: tasks add <TEXT>
For more information, try '--help'.
Exit code is 2. This is the behavior you want from a CLI: a clear message
to stderr, a nonzero exit code, no panic, no partial execution. You get it
without writing any validation code — text: String with no Option
wrapper and no default means clap treats it as required and enforces that
before your main body ever runs.
Rust-specific traps¶
Forgetting derive features. cargo add clap alone gets you the
builder API, not the derive macros. If #[derive(Parser)] fails to compile
with "cannot find derive macro," check Cargo.toml for
features = ["derive"].
String vs &str in struct fields. Derived CLI structs almost always
want owned String fields, not &str. Clap parses each argument into a new
owned value with no argv buffer to borrow from — trying to store &'a str
means fighting a lifetime clap doesn't hand you.
Enum variant field shadowing. Each Commands variant's fields are
scoped to that variant. It's tempting to hoist a shared flag like
--verbose onto the top-level Cli struct instead of duplicating it per
subcommand — usually the right call, but it changes where in the argv list
the flag has to appear (before vs. after the subcommand name), which trips
people testing by hand.
default_value_t needs Default-free literal types. default_value_t
= 3 requires priority: u8 to implement Display and be parseable from
a string via FromStr — works for numbers and String out of the box, but
a custom enum needs #[derive(ValueEnum)] or its own FromStr impl before
it can be a typed argument with a default.
How It Actually Works¶
#[derive(Parser)] is a procedural macro that runs at compile time,
inspects your struct's field names, types, and attributes as a syntax tree,
and generates a whole hand-written-looking clap::Parser implementation —
argument definitions, a help-text renderer, and a parse() method that
builds your struct field by field. None of this is reflection: by the time
your binary runs, there's no metadata table clap consults to know your
struct's shape, because the parsing code was already fully generated and
type-checked against your exact struct at compile time, and the compiler
would reject a mismatch (say, a required String field with no CLI value)
as a normal type error inside the generated code, not a runtime surprise.
The String vs &str trap traces straight back to ownership: clap builds
your typed struct from std::env::args(), which (per Module 10's project)
already handed you owned Strings decoded from argv — there is no
longer-lived buffer for a &str field to legally borrow from once the
parsing function returns, so the borrow checker rejects &'a str fields
exactly the way it would reject returning a reference to a local variable.
Exit code 2 on a validation failure isn't magic either: it's clap's
generated try_parse() returning Err, with your generated main (or the
derive's own main-wrapping logic) printing the error to stderr and calling
std::process::exit(2) before your actual program logic ever begins — the
same Result-and-early-return pattern from Module 5, just wired up for you
by the macro.
Cheat sheet¶
| Attribute | Effect |
|---|---|
#[derive(Parser)] |
Generates parse() / try_parse() on the struct |
#[command(subcommand)] |
Field's enum becomes the subcommand dispatch |
#[arg(short, long)] |
Enables both -x and --xyz forms |
#[arg(default_value_t = v)] |
Optional argument with a typed default |
Option<T> field |
Argument is optional, None if absent |
Vec<T> field |
Argument may be repeated / collects multiple values |
#[command(version, about)] |
Pulls --version/--help text from Cargo.toml / doc comments |
Exercise¶
Extend the tasks CLI with a third subcommand, Remove { id: u32 }, and a
top-level --json flag (on Cli, not per-subcommand) that, when set,
changes every command's output to a JSON line instead of plain text — e.g.
{"action":"add","text":"write docs","priority":1}. Verify with
cargo run -- --json add "ship it" that the flag is readable from the
Commands::Add match arm even though it lives on the outer struct.