When one command isn't enough
You built a Rust script that does one thing. It works. Now you need it to do three things. You add a flag for each action. The main function swells into a wall of if statements checking string slices against hardcoded values. You realize parsing arguments by hand is fragile. A typo in a flag name crashes the program. A missing argument panics. You need a way to define commands upfront and let the compiler enforce the structure.
The clap crate is the standard solution. It uses derive macros to turn Rust structs and enums into argument parsers. You define the shape of your CLI in code. clap generates the parsing logic, the help text, and the error messages. You get type safety for free. If a user types a command that does not exist, clap catches it before your code runs.
The derive pattern
The derive pattern maps your CLI structure directly to Rust types. A struct represents the top-level command. An enum represents subcommands. Fields inside the struct or enum variants represent arguments. Think of it like a blueprint for a house. You draw the rooms and doors. The construction crew handles the plumbing and wiring. You just hand them the blueprint and tell them where to build.
Add clap = { version = "4", features = ["derive"] } to your Cargo.toml. The derive feature enables the macros. Without it, you get the builder API, which requires chaining method calls for every single flag. The macro approach keeps your code declarative and readable.
Here is the smallest working case: a top-level struct, a subcommand enum, and two commands with typed arguments.
use clap::{Parser, Subcommand};
/// My awesome CLI tool
#[derive(Parser)]
#[command(name = "my-cli")]
struct Cli {
/// The subcommand to run
#[command(subcommand)]
command: Commands,
}
/// Available commands
#[derive(Subcommand)]
enum Commands {
/// Add a new item
Add {
/// Name of the item
name: String,
},
/// Remove an item by ID
Remove {
/// ID of the item
id: u32,
},
}
fn main() {
// Reads std::env::args() and matches tokens to the Cli struct
let cli = Cli::parse();
// Routes execution based on the parsed variant
match cli.command {
Commands::Add { name } => println!("Adding: {}", name),
Commands::Remove { id } => println!("Removing ID: {}", id),
}
}
How the parsing works
Cli::parse() reads the raw strings from std::env::args() and matches them against your type definition. The process happens in three distinct phases. First, clap tokenizes the input. It splits the argument vector into flags, options, and positional values. Second, it validates the tokens against your struct definition. It checks for required fields, verifies types, and enforces mutually exclusive groups. Third, it constructs the typed struct and hands it to your main function.
The first token after the program name determines the subcommand. If the user runs my-cli add, clap looks for the Add variant. It then expects a string argument for name. If the user runs my-cli add without providing a name, clap prints an error and exits. Your code never sees the incomplete input.
If the user runs my-cli remove abc, clap tries to parse abc as a u32. The conversion fails. clap prints a helpful error message and exits. The type system does the heavy lifting. You do not write validation loops.
The doc comments on the struct, enum, and fields become the help text. Run my-cli --help to see the generated output. clap formats it automatically. You do not write the help text separately. Change the struct, and the help text updates. The documentation stays in sync with the code because it is the code.
Treat your struct definition as the single source of truth. Do not maintain separate markdown files for CLI usage.
Realistic CLI structure
Real tools have global flags, default values, and restricted inputs. Global flags apply to all subcommands. Default values fill in missing arguments. Restricted inputs limit values to a known set.
Here is a production-ready skeleton with a global verbose flag, a default output format, and an enum that restricts user input.
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
/// A tool for managing data files
#[derive(Parser)]
#[command(version, about = "Manage data files", long_about = None)]
struct Cli {
/// Enable verbose output for all commands
#[arg(short, long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Process a data file
Process {
/// Path to the input file
#[arg(short, long)]
input: PathBuf,
/// Output format
#[arg(short, long, default_value = "json")]
format: OutputFormat,
},
/// List all available files
List,
}
#[derive(Debug, Clone, ValueEnum)]
enum OutputFormat {
Json,
Csv,
}
fn main() {
let cli = Cli::parse();
// Global flags are checked before routing to subcommands
if cli.verbose {
println!("Verbose mode enabled");
}
match cli.command {
Commands::Process { input, format } => {
println!("Processing {} as {:?}", input.display(), format);
}
Commands::List => {
println!("Listing files...");
}
}
}
The global = true attribute on verbose makes it available before the subcommand. Users can run my-cli -v process ... or my-cli process -v .... The default_value attribute sets format to json if the user omits it. The ValueEnum derive restricts format to json or csv. If the user types xml, clap rejects it with a list of valid options.
Convention aside: clap version 4 renamed its attributes. Old tutorials use #[clap(...)]. The current standard is #[command(...)] for structs and enums, and #[arg(...)] for fields. The compiler accepts the old name in some contexts, but the community has moved on. Stick to the new names to avoid confusion when reading modern examples.
Convention aside: PathBuf is the standard type for file arguments. It handles OS-specific path separators automatically. Never use String for paths unless you have a specific reason to strip the filesystem abstraction.
Pitfalls and compiler errors
Forgetting the derive feature in Cargo.toml causes a compilation error. The compiler rejects you with cannot find derive macro Parser. Add features = ["derive"] to the dependency. The macro lives in a separate crate to keep the base clap dependency small.
Using a custom type for an argument without implementing FromStr breaks parsing. If you define a field as my_type: MyCustomType, the compiler rejects you with E0277 (trait bound not satisfied). clap needs to know how to convert a string to your type. Implement FromStr for MyCustomType, or use #[arg(value_parser = parse_my_type)] to provide a custom parser function. The trait tells clap how to handle conversion failures gracefully.
Positional arguments match by order. If you have two positional strings, the first token goes to the first field. This confuses users who expect named flags. Use named arguments with #[arg(short, long)] for anything beyond a single positional input. Named arguments are self-documenting and survive reordering.
Help text generation relies entirely on doc comments. If you omit doc comments, clap generates minimal help. Add /// comments to structs, enums, and fields. Run cargo run -- --help frequently to verify the output. The help text is part of your API. Treat it like a public interface.
Trust the derive macros. They handle edge cases you would miss in manual parsing.
Decision matrix
Use clap with the derive feature when you want type-safe arguments and automatic help generation with minimal boilerplate. Use clap builder when you need dynamic command structures that change at runtime, such as a plugin system where commands are loaded from files. Use manual argument parsing only for throwaway scripts where adding a dependency feels like overkill; even then, std::env::args() gets messy fast.