Parsing arguments with clap
In this article: hit learns to accept get and post subcommands, headers, a request body, and
a global --verbose flag — and gets --help output for free. No HTTP yet; that is the next article.
By the end you will have a real command-line interface, and you will have written almost no parsing
code. That is the point of clap’s derive API: you describe your arguments as a struct, and the
crate generates the parser, the help text, the error messages, and the shell completions.
Add the dependency
Section titled “Add the dependency”cargo add clap --features deriveThat writes this into Cargo.toml:
[dependencies]clap = { version = "4", features = ["derive"] }The derive feature is what enables the #[derive(Parser)] macro. Without it you get the builder
API, which is fine but far more verbose for anything beyond two flags.
Describe the command line as a struct
Section titled “Describe the command line as a struct”Replace the contents of src/main.rs:
use clap::{Parser, Subcommand};
#[derive(Parser)]#[command(name = "hit", version, about = "A tiny command-line HTTP client")]struct Cli { /// Print what we are about to do #[arg(short, long, global = true)] verbose: bool,
#[command(subcommand)] command: Command,}
#[derive(Subcommand)]enum Command { /// Send a GET request Get { /// The URL to request url: String,
/// Send this header, for example -H "Accept: application/json" #[arg(short = 'H', long = "header")] headers: Vec<String>, },
/// Send a POST request with a body Post { /// The URL to request url: String,
/// Request body #[arg(short, long)] body: Option<String>, },}
fn main() { let cli = Cli::parse();
match cli.command { Command::Get { url, headers } => { if cli.verbose { eprintln!("GET {url}"); for header in &headers { eprintln!(" header: {header}"); } } println!("would GET {url}"); } Command::Post { url, body } => { if cli.verbose { eprintln!("POST {url}"); } println!("would POST {url} with body {body:?}"); } }}Try it
Section titled “Try it”cargo run -- get https://example.comwould GET https://example.comNow ask it for help — nobody wrote this text:
cargo run -- --helpA tiny command-line HTTP client
Usage: hit <COMMAND>
Commands: get Send a GET request post Send a POST request with a body help Print this message or the help of the given subcommand(s)
Options: -v, --verbose Print what we are about to do -h, --help Print help -V, --version Print versionRepeatable flags work as you would hope:
cargo run -- get https://example.com -H "Accept: application/json" -H "X-Trace: 1" -vGET https://example.com header: Accept: application/json header: X-Trace: 1would GET https://example.comAnd mistakes are handled for you:
cargo run -- gte https://example.comerror: unrecognized subcommand 'gte'
Usage: hit <COMMAND>
For more information, try '--help'.How it works
Section titled “How it works”Doc comments become help text. /// Send a GET request on the enum variant becomes the
description in Commands:; the same comment on a field becomes the argument description. If you
change the comment, the help changes — they cannot drift apart.
The enum is the parser. Command is an ordinary Rust enum, so match cli.command is exhaustive:
add a third subcommand and the compiler will refuse to build until you handle it. This is the single
biggest reason to model a CLI with enums instead of a pile of strings.
Cli::parse() does the boring parts. It reads std::env::args, matches your struct, prints the
help or the error and exits with the right status code. For --help and --version the exit code is
0; for a usage error it is 2, which is the convention command-line tools are expected to follow so
that shell scripts can tell the difference between “you used it wrong” and “it ran and failed”.
Vec<String> means repeatable. Any field whose type is a Vec collects every occurrence of the
flag. Option<T> means the flag is optional and may be absent; a bare T means required.
global = true pushes a flag into subcommands. Without it, --verbose would have to be written
before the subcommand. With it, both of these work:
cargo run -- -v get https://example.comcargo run -- get https://example.com -vThree things that trip people up
Section titled “Three things that trip people up”1. -h is taken. The obvious short flag for headers is -h, and clap will refuse to build,
because -h is already --help. That is why the field above uses short = 'H'. Always check for
collisions with -h and -V before choosing a short flag.
2. The long flag comes from the field name. headers: Vec<String> with long produces
--headers, not --header. Writing it out — #[arg(short = 'H', long = "header")] — is how you get
the singular, which reads better in a command line people type by hand.
3. A field with no #[arg(...)] is positional. Leave the attribute off and the argument becomes
a required positional, which is usually what you want for url and never what you want for a flag.
If you get “unexpected argument” errors, check for a missing attribute rather than a missing parser
feature.
Exercises
Section titled “Exercises”- Add a
deletesubcommand that takes a URL and an optional--forceflag. - Make
--timeoutaccept seconds with a default of10, using#[arg(long, default_value_t = 10)]. - Give
posta--jsonflag that switches the body between raw text and a JSON payload.
Next we stop printing would GET and send an actual request with reqwest — including how to handle
timeouts, and why the naive unwrap() version will eventually ruin your afternoon.