Building with clap
clap is the standard argument parsing library for Rust CLI tools. Its derive API lets you define your entire CLI interface as a struct, with argument validation, help text, and shell completions generated automatically. You can go from zero to a polished CLI tool in under 50 lines.
The Derive API
Define your CLI with #[derive(Parser)]:
use clap::Parser;
/// A tool for counting words in files
#[derive(Parser, Debug)]
#[command(name = "wc-rs", version, about)]
struct Cli {
/// Files to process
#[arg(required = true)]
files: Vec<String>,
/// Count lines instead of words
#[arg(short, long)]
lines: bool,
/// Count characters instead of words
#[arg(short, long)]
chars: bool,
}
fn main() {
let cli = Cli::parse();
println!("{:?}", cli);
}
$ wc-rs --help
A tool for counting words in files
Usage: wc-rs [OPTIONS] <FILES>...
Arguments:
<FILES>... Files to process
Options:
-l, --lines Count lines instead of words
-c, --chars Count characters instead of words
-h, --help Print help
-V, --version Print version
The doc comment on the struct becomes the help description. Doc comments on fields become argument descriptions. The #[arg] attribute controls behavior.
Arguments, Flags & Options
clap distinguishes between three kinds of CLI inputs:
use clap::Parser;
#[derive(Parser, Debug)]
struct Cli {
/// Positional argument (required by default)
input: String,
/// Optional positional argument
output: Option<String>,
/// Flag: present or absent (bool)
#[arg(short, long)]
verbose: bool,
/// Option: takes a value
#[arg(short, long, default_value = "80")]
width: u32,
/// Option that can be repeated: -q -q -q or -qqq
#[arg(short, long, action = clap::ArgAction::Count)]
quiet: u8,
}
fn main() {
let cli = Cli::parse();
println!("{:?}", cli);
}
$ tool input.txt -v -w 120 -qqq
Cli { input: "input.txt", output: None, verbose: true, width: 120, quiet: 3 }
Subcommands
Most real CLI tools have subcommands. Use an enum:
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "tasks", version, about = "A task management CLI")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Add a new task
Add {
/// Task description
description: String,
/// Priority level (1-5)
#[arg(short, long, default_value = "3")]
priority: u8,
},
/// List all tasks
List {
/// Show only completed tasks
#[arg(long)]
done: bool,
},
/// Mark a task as complete
Done {
/// Task ID to mark complete
id: u32,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Add { description, priority } => {
println!("Adding task: {} (priority {})", description, priority);
}
Commands::List { done } => println!("Listing tasks (done={})", done),
Commands::Done { id } => println!("Marking task {} as done", id),
}
}
$ tasks add "Write documentation" -p 1
Adding task: Write documentation (priority 1)
$ tasks done 42
Marking task 42 as done
Argument Validation
clap validates arguments at parse time. Use value_parser for custom validation:
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
struct Cli {
/// Port to listen on (1024-65535)
#[arg(short, long, value_parser = clap::value_parser!(u16).range(1024..=65535))]
port: u16,
/// Config file path (must exist)
#[arg(short, long)]
config: PathBuf,
/// Output format
#[arg(short, long, value_parser = ["json", "csv", "table"])]
format: String,
}
fn main() {
let cli = Cli::parse();
println!("Port: {}, Format: {}", cli.port, cli.format);
}
$ tool --port 80 --config app.toml --format json
error: invalid value '80' for '--port <PORT>': 80 is not in 1024..=65535
$ tool --port 8080 --config app.toml --format xml
error: invalid value 'xml' for '--format <FORMAT>'
[possible values: json, csv, table]
For a fixed set of choices, use an enum with #[derive(ValueEnum)] instead of a string. This gives you compile-time safety, auto-generated help text, and tab completion.
Environment Variable Fallbacks
Arguments can fall back to environment variables:
use clap::Parser;
#[derive(Parser, Debug)]
struct Cli {
/// API key for authentication
#[arg(long, env = "API_KEY")]
api_key: String,
/// Database connection string
#[arg(long, env = "DATABASE_URL", default_value = "postgres://localhost/mydb")]
database_url: String,
}
fn main() {
let cli = Cli::parse();
println!("Connecting to: {}", cli.database_url);
}
$ export API_KEY=secret123
$ tool
Connecting to: postgres://localhost/mydb
$ tool --database-url postgres://prod/mydb
Connecting to: postgres://prod/mydb
Command-line arguments override environment variables, which override defaults. This is the standard precedence for CLI tools.
Auto-Generated Shell Completions
The clap_complete crate generates shell completion scripts. Add a completions subcommand that calls clap_complete::generate with the target shell and your Command. Users pipe the output to their shell's completion directory:
$ my-tool completions bash > ~/.local/share/bash-completion/completions/my-tool
$ my-tool completions zsh > ~/.zfunc/_my-tool
This gives you tab completion for subcommands, flags, and arguments with no manual script writing.
A Complete CLI Tool in 50 Lines
use clap::Parser;
use std::fs;
use std::path::PathBuf;
/// Count lines, words, or characters in files
#[derive(Parser)]
#[command(name = "counter", version)]
struct Cli {
/// Files to process
files: Vec<PathBuf>,
/// Count lines
#[arg(short, long)]
lines: bool,
/// Count characters
#[arg(short, long)]
chars: bool,
}
fn main() {
let cli = Cli::parse();
for path in &cli.files {
match fs::read_to_string(path) {
Ok(content) => {
let (count, label) = if cli.lines {
(content.lines().count(), "lines")
} else if cli.chars {
(content.chars().count(), "chars")
} else {
(content.split_whitespace().count(), "words")
};
println!("{}: {} {}", path.display(), count, label);
}
Err(e) => eprintln!("Error reading {}: {}", path.display(), e),
}
}
}
Common Pitfalls
- Forgetting
#[arg(required = true)]on Vec — aVec<String>positional argument accepts zero values by default. Addrequired = trueif at least one is needed. - Conflicting short flags —
-ccan only map to one argument. clap panics at runtime if two arguments share a short flag. - Not using ValueEnum for fixed options — accepting a
Stringand matching manually loses help text, tab completion, and validation. - Ignoring exit codes —
Cli::parse()callsstd::process::exit(2)on parse errors. UseCli::try_parse()for custom error handling. - Hardcoding version strings — use
#[command(version)]to pull fromCargo.tomlautomatically. - Missing help text — doc comments on struct fields become help descriptions for free. Omitting them produces a CLI with no documentation.
Key Takeaways
- clap's derive API defines CLI interfaces as structs and enums. Arguments, flags, options, and subcommands are all declarative.
- Doc comments on fields become help text automatically.
- Use
ValueEnumfor fixed choice sets,value_parserfor range validation, andenvfor environment variable fallbacks. - Shell completions come free with
clap_complete. - A polished CLI tool with validation, help, version, and error handling fits in under 50 lines with clap.