Skip to content
Select theme

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.

Terminal window
cargo add clap --features derive

That writes this into Cargo.toml:

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.

Replace the contents of src/main.rs:

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:?}");
}
}
}
Terminal window
cargo run -- get https://example.com
would GET https://example.com

Now ask it for help — nobody wrote this text:

Terminal window
cargo run -- --help
A 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 version

Repeatable flags work as you would hope:

Terminal window
cargo run -- get https://example.com -H "Accept: application/json" -H "X-Trace: 1" -v
GET https://example.com
header: Accept: application/json
header: X-Trace: 1
would GET https://example.com

And mistakes are handled for you:

Terminal window
cargo run -- gte https://example.com
error: unrecognized subcommand 'gte'
Usage: hit <COMMAND>
For more information, try '--help'.

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:

Terminal window
cargo run -- -v get https://example.com
cargo run -- get https://example.com -v

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.

  1. Add a delete subcommand that takes a URL and an optional --force flag.
  2. Make --timeout accept seconds with a default of 10, using #[arg(long, default_value_t = 10)].
  3. Give post a --json flag 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.

← Back to the track overview