Building CLIs with clap
Parsing std::env::args() by hand works for a one-flag script and falls apart the moment you need subcommands, validation, --help, or sensible error messages. clap is the standard crate for command-line interfaces in Rust, and its derive API turns a plain struct into a fully-featured argument parser: the struct is the interface specification. This tutorial covers the derive approach, the three kinds of arguments, subcommands, validation, and the small touches that make a CLI feel polished.
The Derive API: A Struct Is the Interface
Without clap, you read std::env::args() and branch by hand, and every new flag adds more positional bookkeeping, manual validation, and a hand-written --help:
// by hand: brittle, and you still owe the user --help and decent error messages
let args: Vec<String> = std::env::args().collect();
let name = args.get(1).expect("usage: greet <name> [--count N]");
let count: u8 = match args.iter().position(|a| a == "--count") {
Some(i) => args[i + 1].parse().expect("count must be a number"),
None => 1,
};
With clap's derive feature, you annotate a struct with #[derive(Parser)], and each field becomes an argument. Parser::parse() reads the process arguments, validates them, and either fills in your struct or exits with a helpful error and the right exit code, all generated from the type.
use clap::Parser;
#[derive(Parser)]
#[command(name = "greet", version, about = "Greets a person")]
struct Cli {
/// Name of the person to greet
name: String,
/// Number of times to repeat the greeting
#[arg(short, long, default_value_t = 1)]
count: u8,
}
fn main() {
let cli = Cli::parse();
for _ in 0..cli.count {
println!("Hello, {}!", cli.name);
}
}
This tiny struct already gives you greet Alice, greet Alice --count 3, greet Alice -c 3, plus auto-generated --help and --version. The doc comment (///) on each field becomes that argument's help text, the same comment that documents the code documents the CLI, so they can't drift apart. #[command(version)] pulls the version straight from your Cargo.toml.
Gotcha:
#[derive(Parser)]only exists when clap'sderivefeature is on. Depend on it asclap = { version = "4", features = ["derive"] }inCargo.toml, the defaultclapdependency omits the derive macros, and the#[derive(Parser)]line won't resolve.
The Three Kinds of Arguments
Every CLI argument is one of three shapes, and the field declaration determines which:
- Positional: identified by order, not a flag name. A bare field (
name: Stringabove) is positional, the user just types the value. - Option: a named value,
--count 3or-c 3. Adding#[arg(short, long)]makes a field an option;shortenables-c(first letter),longenables--count(the field name). - Flag: a boolean switch with no value, present or absent. A
boolfield with#[arg(short, long)]becomes a flag.
#[derive(Parser)]
struct Cli {
input: String, // positional: required value
#[arg(short, long)]
output: Option<String>, // option: --output FILE, optional
#[arg(short, long)]
verbose: bool, // flag: --verbose / -v, true if present
#[arg(long, value_delimiter = ',')]
tags: Vec<String>, // repeatable: --tags a,b,c (or --tags a --tags b)
}
The field type drives the requirement rules automatically, you express the contract by choosing the type, with no separate "required" configuration to keep in sync:
| Field type | Behavior | Required? |
|---|---|---|
String (bare) | positional value | yes — clap errors if missing |
Option<T> | optional value | no — None when absent |
bool | flag (--verbose) | n/a — false when absent |
Vec<T> | collects multiple values | no — empty when absent |
Subcommands with Enums
Tools like git and cargo have subcommands (git commit, cargo build), each with its own arguments. In clap, you model this with an enum where each variant is a subcommand, deriving Subcommand, and embed it in the top-level struct.
use clap::{Parser, Subcommand};
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Add a new item
Add { name: String },
/// Remove an item by id
Remove {
id: u64,
#[arg(short, long)]
force: bool,
},
/// List all items
List,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Add { name } => println!("adding {name}"),
Commands::Remove { id, force } => println!("removing {id} (force={force})"),
Commands::List => println!("listing"),
}
}
Each variant's fields are that subcommand's arguments, following the same positional/option/flag rules as a top-level struct. Dispatching is just a match on the enum, the exhaustiveness checking from Pattern Matching Deep Dive guarantees you handle every subcommand, and adding a new variant won't compile until you handle it. The enum modeling subcommands is a clean fit for Rust's type system: invalid combinations (a flag that only exists on one subcommand) simply can't be represented.
Validation: Let the Type System and clap Do the Work
Much validation is free: declaring a field count: u8 means clap parses and range-checks the input, passing --count 300 produces a clear error without a line of validation code, because 300 doesn't fit a u8. This extends to any type implementing FromStr, including enums via ValueEnum:
use clap::{Parser, ValueEnum};
#[derive(Clone, ValueEnum)]
enum LogLevel {
Debug,
Info,
Warn,
Error,
}
#[derive(Parser)]
struct Cli {
#[arg(long, value_enum, default_value_t = LogLevel::Info)]
log_level: LogLevel,
}
Now --log-level debug parses into the enum, and --log-level shout is rejected with a message listing the valid choices. For custom rules clap can't infer, #[arg(value_parser = my_fn)] runs your own function that returns Result<T, E>, letting you reject, say, a port outside 1024..=65535 with a tailored message. The principle: push validation into types and parsers so an invalid argument never reaches your logic, the same "make illegal states unrepresentable" idea from Newtype Pattern and From/Into Conversions, applied at the CLI boundary.
Returning Results from main
A CLI's main can return Result, so the ? operator and the error libraries from Error Libraries — thiserror and anyhow work end to end: clap handles argument errors (printing usage and exiting), while anyhow handles runtime errors (a missing file, a failed request) with context.
use anyhow::{Context, Result};
use clap::Parser;
fn main() -> Result<()> {
let cli = Cli::parse(); // clap exits here on bad arguments, before main's body
let data = std::fs::read_to_string(&cli.input)
.with_context(|| format!("reading {}", cli.input))?;
process(&data)?;
Ok(())
}
This is the idiomatic shape: Cli::parse() deals with everything about arguments (validation, --help, exit codes) and returns a clean struct, then the rest of main is ordinary fallible Rust returning anyhow::Result. The two error domains stay cleanly separated, argument errors never reach your logic, and runtime errors never get tangled in argument parsing.
Quick Reference
| You want | Reach for |
|---|---|
| A required value by position | bare field: path: String |
| A named optional value | #[arg(short, long)] out: Option<String> |
| An on/off switch | #[arg(short, long)] verbose: bool |
| A repeatable value | tags: Vec<String> |
| Subcommands | #[command(subcommand)] + a #[derive(Subcommand)] enum |
| Restrict to fixed choices | #[derive(ValueEnum)] + #[arg(value_enum)] |
| Custom validation | #[arg(value_parser = my_fn)] |
Fallible runtime work in main | return anyhow::Result<()> |
Key Takeaways
- clap's derive API makes a
#[derive(Parser)]struct the single source of truth for the CLI: fields become arguments, doc comments become help text, andCli::parse()validates and fills it in or exits with a helpful message. - Field type determines behavior with no extra config: plain
Stringis a required positional,Option<T>is optional,boolis a flag,Vec<T>collects multiple values;#[arg(short, long)]turns a field into a named option/flag. - Model subcommands as a
#[derive(Subcommand)]enum embedded in the top-level struct; dispatch with an exhaustivematch, so adding a subcommand won't compile until it's handled. - Lean on types for validation, numeric fields range-check for free,
ValueEnumrestricts to valid choices, andvalue_parserruns custom rules, so invalid arguments are rejected before reaching your logic. - Return
anyhow::Resultfrommain: clap owns argument errors (usage + exit codes),anyhowowns runtime errors (with.context()), keeping the two error domains cleanly separated.