Rust CLIs with clap
clap is the de facto standard for parsing command-line
arguments in Rust. You declare your interface as a struct with attributes, and clap derives the
parser, validation, --help text, --version, and error messages from it. This guide covers the
derive API, which is the recommended starting point; the lower-level builder API exists for
cases needing runtime-determined arguments. It assumes you know the language basics from the
Rust fundamentals guide — structs, enums, Option, and Result all
feature heavily here, because a clap CLI is a struct.
The canonical reference is the derive tutorial on docs.rs; sections below link into it. Current version at the time of writing: clap 4.6.
Setup
Add clap with the derive feature flag (the derive macros are opt-in):
cargo add clap --features deriveA first CLI
Derive Parser on a struct; each field becomes an argument. Doc comments (///) become the help
text. #[command(version, about)] fills --version and the description from Cargo.toml, so
they never drift from your package metadata.
use clap::Parser;
/// Greet someone from the command line
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// Name of the person to greet
name: String,
}
fn main() {
let cli = Cli::parse();
println!("Hello, {}!", cli.name);
}That's a complete program: cargo run -- Ada prints the greeting, cargo run -- --help prints
generated usage, and a missing NAME produces a clear error and nonzero exit code — all for free.
(The -- separates cargo's arguments from your program's.)
Configuring the command
Any Command builder method can be used
as a #[command(...)] attribute — the attribute list is not limited to a fixed set. A realistic
top-level configuration:
#[derive(Parser)]
#[command(
name = "rtemis",
version,
about = "The rtemis command-line interface.",
before_help = logo(),
arg_required_else_help = true
)]
struct Cli {
// ...
}Taking these in turn: name sets the program name shown in usage and version output (default: the
package name from Cargo.toml); bare version and about pull from Cargo.toml, though about
here overrides with an explicit string; before_help prints text above the help output — and
because attribute values are arbitrary expressions, it can call a function like logo() that
returns a banner String; arg_required_else_help = true prints help instead of an error when
the program is run with no arguments — the right default for a subcommand-style tool.
Other commonly reached-for methods include after_help (text below the help, e.g. examples),
long_version (extended --version output with build info), and propagate_version (make
--version work inside every subcommand).
Positionals, options, and flags
By default a field is a positional argument
(tutorial: adding arguments).
Add #[arg(short, long)] to make it a named option (-n / --name, inferred from the field
name). A bool field with short/long becomes a flag that is false unless passed.
Field types drive everything else: Option<T> makes an argument optional, Vec<T> accepts
repeated values, and default_value_t supplies a default.
use std::path::PathBuf;
use clap::Parser;
/// Search files for a pattern
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// Pattern to search for
pattern: String,
/// Files to search (one or more)
paths: Vec<PathBuf>,
/// Maximum number of matches to print
#[arg(short = 'm', long, default_value_t = 10)]
max_count: usize,
/// Write output to a file instead of stdout
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
/// Ignore case when matching
#[arg(short, long)]
ignore_case: bool,
}
fn main() {
let cli = Cli::parse();
println!("searching for {:?} in {} file(s)", cli.pattern, cli.paths.len());
}This parses invocations like grepish TODO src/main.rs src/lib.rs -i -m 3 --output hits.txt.
Note PathBuf rather than String for paths — clap parses into any type that implements
FromStr, and the help shows <FILE> thanks to value_name.
A repeatable flag like -v -v -v for verbosity uses a u8 field with
#[arg(short, long, action = clap::ArgAction::Count)] — the field holds how many times it
appeared.
Validated and enumerated values
Because clap parses into typed fields, much validation is automatic: passing abc to a usize
field fails with a clear error before your code runs. Beyond that, constrain values in the
attribute (tutorial: validation).
For a fixed set of choices, derive ValueEnum — invalid input gets an error listing the valid
values, and --help documents them:
use clap::{Parser, ValueEnum};
#[derive(Copy, Clone, ValueEnum)]
enum Format {
Plain,
Json,
Csv,
}
#[derive(Parser)]
struct Cli {
/// Output format
#[arg(long, value_enum, default_value_t = Format::Plain)]
format: Format,
}For numeric ranges, use value_parser; for anything else, any function
fn(&str) -> Result<T, String> works as a custom parser:
#[derive(clap::Parser)]
struct Cli {
/// Network port to use
#[arg(long, value_parser = clap::value_parser!(u16).range(1..))]
port: u16,
}Passing --port 0 exits with error: invalid value '0' for '--port <PORT>': 0 is not in 1..=65535.
Subcommands
Tools like git and cargo split functionality into subcommands (git commit, cargo build).
Model them as an enum deriving Subcommand, where each variant carries its own arguments
(tutorial: subcommands).
Dispatch is an exhaustive match, so adding a variant forces you to handle it.
use clap::{Parser, Subcommand};
/// Manage a todo list
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Add a new task
Add {
/// Task description
description: String,
/// Mark as high priority
#[arg(short, long)]
priority: bool,
},
/// List all tasks
List,
/// Mark a task as done
Done {
/// Task number from `list`
id: usize,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Add { description, priority } => {
println!("adding{}: {description}", if priority { " (high)" } else { "" });
}
Commands::List => println!("listing tasks"),
Commands::Done { id } => println!("completing task {id}"),
}
}Running with no subcommand prints help; todo add --priority "ship guide" and todo done 3 both
parse into their variant. For larger CLIs, give each variant a dedicated #[derive(Args)] struct
(Add(AddArgs)) and keep each subcommand's arguments in its own module.
Errors and exit codes
Cli::parse() handles bad input for you: it prints the error and exits with code 2. Your own
runtime failures should flow through Result, exactly as in the
error handling section of the fundamentals guide — return
Result from main and let ? propagate:
use clap::Parser;
#[derive(Parser)]
struct Cli {
path: std::path::PathBuf,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let content = std::fs::read_to_string(&cli.path)?; // I/O error propagates
println!("{} lines", content.lines().count());
Ok(())
}Many real CLIs use the anyhow crate here — its anyhow::Result and
.context("while reading config")? produce friendlier error chains with no boilerplate.
Testing your interface
clap reports most definition mistakes (conflicting flags, invalid defaults) as debug assertions at parse time. One unit test catches them all without running every subcommand:
#[test]
fn verify_cli() {
use clap::CommandFactory;
Cli::command().debug_assert();
}Resources
The derive tutorial is the canonical walkthrough, and the derive reference documents every attribute. The cookbook has complete application-style examples, including a git clone and a pacman clone.
Beyond parsing, the Rust CLI book covers the wider ecosystem: anyhow for errors, indicatif for progress bars, assert_cmd for end-to-end testing, and clap_complete for generating shell completions from the same struct you already wrote.