How do I parse command line arguments with clap in Rust?

Walkthrough

The clap crate (Command Line Argument Parser) is the most popular library for parsing command line arguments in Rust. It provides a derive-based API for declarative argument definitions and a builder API for more control. clap automatically generates help messages, handles errors, supports subcommands, and provides shell completion scripts. It's perfect for building professional CLI tools with minimal boilerplate.

Key concepts:

  1. Derive API — use #[derive(Parser)] to define arguments as struct fields
  2. Builder API — construct the parser programmatically with methods
  3. Subcommands — group related functionality under commands (like git add, git commit)
  4. Arguments — positional arguments that must be provided in order
  5. Options — named flags with values (--name value or --name=value)
  6. Flags — boolean switches (--verbose, -v)

Code Example

# Cargo.toml
[dependencies]
clap = { version = "4.0", features = ["derive"] }
use clap::Parser;
 
#[derive(Parser)]
#[command(name = "myapp")]
#[command(about = "A simple CLI application", long_about = None)]
struct Cli {
    /// Name of the person to greet
    #[arg(short, long)]
    name: String,
    
    /// Number of times to greet
    #[arg(short, long, default_value_t = 1)]
    count: u8,
}
 
fn main() {
    let cli = Cli::parse();
    
    for _ in 0..cli.count {
        println!("Hello, {}!", cli.name);
    }
}

Basic Argument Parsing

use clap::Parser;
 
/// Simple program to greet a person
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
    /// Name of the person to greet
    #[arg(short, long)]
    name: String,
    
    /// Number of times to greet
    #[arg(short, long, default_value_t = 1)]
    count: u8,
}
 
fn main() {
    let args = Args::parse();
    
    for _ in 0..args.count {
        println!("Hello {}!", args.name);
    }
}
 
// Run with:
// cargo run -- --name Alice --count 3
// cargo run -- -n Bob -c 2
// cargo run -- --help

Positional Arguments

use clap::Parser;
 
#[derive(Parser, Debug)]
#[command(about = "File operations")]
struct Args {
    /// Input file path
    input: String,
    
    /// Output file path
    output: String,
    
    /// Optional verbose flag
    #[arg(short, long)]
    verbose: bool,
}
 
fn main() {
    let args = Args::parse();
    
    println!("Input: {}", args.input);
    println!("Output: {}", args.output);
    if args.verbose {
        println!("Verbose mode enabled");
    }
}
 
// Run with:
// cargo run -- input.txt output.txt
// cargo run -- input.txt output.txt --verbose

Optional Arguments

use clap::Parser;
 
#[derive(Parser, Debug)]
struct Args {
    /// Required name
    #[arg(short, long)]
    name: String,
    
    /// Optional age (can be omitted)
    #[arg(short, long)]
    age: Option<u32>,
    
    /// Optional with default value
    #[arg(short, long, default_value = "guest")]
    role: String,
    
    /// Optional with default value using function
    #[arg(long, default_value_t = chrono::Local::now().format("%Y-%m-%d").to_string())]
    date: String,
}
 
fn main() {
    let args = Args::parse();
    
    println!("Name: {}", args.name);
    println!("Age: {:?}", args.age);
    println!("Role: {}", args.role);
    println!("Date: {}", args.date);
}
 
// Run with:
// cargo run -- --name Alice
// cargo run -- --name Bob --age 30
// cargo run -- --name Charlie --age 25 --role admin

Boolean Flags

use clap::Parser;
 
#[derive(Parser, Debug)]
struct Args {
    /// Enable verbose output
    #[arg(short, long)]
    verbose: bool,
    
    /// Enable debug mode
    #[arg(short, long)]
    debug: bool,
    
    /// Quiet mode (suppress output)
    #[arg(short, long)]
    quiet: bool,
    
    /// Force operation without confirmation
    #[arg(short, long)]
    force: bool,
}
 
fn main() {
    let args = Args::parse();
    
    if args.verbose {
        println!("Verbose mode");
    }
    
    if args.debug {
        println!("Debug mode");
    }
    
    if args.quiet {
        println!("Quiet mode");
    }
    
    if args.force {
        println!("Force mode");
    }
}
 
// Run with:
// cargo run -- --verbose --debug
// cargo run -- -v -d -f
// cargo run -- -vdf  // Combined short flags

Subcommands

use clap::{Parser, Subcommand};
 
#[derive(Parser)]
#[command(name = "myapp")]
#[command(about = "A fictional versioning CLI", long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}
 
#[derive(Subcommand)]
enum Commands {
    /// Adds files to myapp
    Add {
        /// Set the path to add
        #[arg(short, long)]
        path: Option<String>,
    },
    /// Removes files from myapp
    Remove {
        /// Force removal
        #[arg(short, long)]
        force: bool,
        
        /// Path to remove
        path: String,
    },
    /// List files
    List {
        /// Show all files
        #[arg(short, long)]
        all: bool,
    },
}
 
fn main() {
    let cli = Cli::parse();
    
    match &cli.command {
        Commands::Add { path } => {
            println!("'myapp add' was used with path: {:?}", path);
        }
        Commands::Remove { force, path } => {
            println!("'myapp remove' was used with force: {}, path: {}", force, path);
        }
        Commands::List { all } => {
            println!("'myapp list' was used with all: {}", all);
        }
    }
}
 
// Run with:
// cargo run -- add --path ./src
// cargo run -- remove --force ./target
// cargo run -- list --all

Nested Subcommands

use clap::{Parser, Subcommand};
 
#[derive(Parser)]
#[command(about = "Container management CLI")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}
 
#[derive(Subcommand)]
enum Commands {
    /// Container operations
    Container {
        #[command(subcommand)]
        command: ContainerCommands,
    },
    /// Image operations
    Image {
        #[command(subcommand)]
        command: ImageCommands,
    },
}
 
#[derive(Subcommand)]
enum ContainerCommands {
    /// List containers
    List {
        /// Show all containers (including stopped)
        #[arg(short, long)]
        all: bool,
    },
    /// Start a container
    Start {
        /// Container ID or name
        container: String,
    },
    /// Stop a container
    Stop {
        /// Container ID or name
        container: String,
        /// Force stop
        #[arg(short, long)]
        force: bool,
    },
}
 
#[derive(Subcommand)]
enum ImageCommands {
    /// List images
    List {
        /// Show all images
        #[arg(short, long)]
        all: bool,
    },
    /// Pull an image
    Pull {
        /// Image name
        name: String,
        /// Specific tag
        #[arg(short, long)]
        tag: Option<String>,
    },
}
 
fn main() {
    let cli = Cli::parse();
    
    match &cli.command {
        Commands::Container { command } => match command {
            ContainerCommands::List { all } => {
                println!("Listing containers (all: {})", all);
            }
            ContainerCommands::Start { container } => {
                println!("Starting container: {}", container);
            }
            ContainerCommands::Stop { container, force } => {
                println!("Stopping container: {} (force: {})", container, force);
            }
        },
        Commands::Image { command } => match command {
            ImageCommands::List { all } => {
                println!("Listing images (all: {})", all);
            }
            ImageCommands::Pull { name, tag } => {
                println!("Pulling image: {} (tag: {:?})", name, tag);
            }
        },
    }
}
 
// Run with:
// cargo run -- container list --all
// cargo run -- container start my-container
// cargo run -- image pull alpine --tag latest

Argument Groups and Mutually Exclusive Arguments

use clap::{Parser, ArgGroup};
 
#[derive(Parser, Debug)]
#[command(about = "File converter")]
#[command(group(
    ArgGroup::new("format")
        .args(["json", "yaml", "toml"])
        .required(true)
))]
struct Args {
    /// Input file
    #[arg(short, long)]
    input: String,
    
    /// Output file
    #[arg(short, long)]
    output: String,
    
    /// Convert to JSON format
    #[arg(long)]
    json: bool,
    
    /// Convert to YAML format
    #[arg(long)]
    yaml: bool,
    
    /// Convert to TOML format
    #[arg(long)]
    toml: bool,
    
    /// Pretty print output
    #[arg(short, long)]
    pretty: bool,
}
 
fn main() {
    let args = Args::parse();
    
    println!("Input: {}", args.input);
    println!("Output: {}", args.output);
    
    let format = if args.json { "JSON" }
    else if args.yaml { "YAML" }
    else { "TOML" };
    
    println!("Format: {}", format);
    println!("Pretty: {}", args.pretty);
}
 
// Run with:
// cargo run -- -i input.txt -o output.txt --json
// cargo run -- -i input.txt -o output.txt --yaml --pretty
// This will error: cargo run -- -i input.txt -o output.txt --json --yaml

Custom Type Validation

use clap::Parser;
use std::path::PathBuf;
 
#[derive(Parser, Debug)]
struct Args {
    /// Input file (must exist)
    #[arg(short, long, value_parser = clap::value_parser!(PathBuf))]
    input: PathBuf,
    
    /// Port number (1-65535)
    #[arg(short, long, value_parser = clap::value_parser!(u16).range(1..))]
    port: u16,
    
    /// Email address
    #[arg(long, value_parser = validate_email)]
    email: String,
}
 
fn validate_email(s: &str) -> Result<String, String> {
    if s.contains('@') && s.contains('.') {
        Ok(s.to_string())
    } else {
        Err(format!("'{}' is not a valid email address", s))
    }
}
 
fn main() {
    let args = Args::parse();
    
    println!("Input: {:?}", args.input);
    println!("Port: {}", args.port);
    println!("Email: {}", args.email);
}
 
// Run with:
// cargo run -- --input ./src/main.rs --port 8080 --email user@example.com

Multiple Values and Delimiters

use clap::Parser;
 
#[derive(Parser, Debug)]
struct Args {
    /// List of files to process
    #[arg(short, long, value_name = "FILE")]
    files: Vec<String>,
    
    /// Tags (can be specified multiple times)
    #[arg(short, long)]
    tags: Vec<String>,
    
    /// Comma-separated list of items
    #[arg(long, value_delimiter = ',')]
    items: Vec<String>,
    
    /// Numbers (must be between 1-10)
    #[arg(long, value_parser = clap::value_parser!(u8).range(1..=10))]
    numbers: Vec<u8>,
}
 
fn main() {
    let args = Args::parse();
    
    println!("Files: {:?}", args.files);
    println!("Tags: {:?}", args.tags);
    println!("Items: {:?}", args.items);
    println!("Numbers: {:?}", args.numbers);
}
 
// Run with:
// cargo run -- --files a.txt --files b.txt --files c.txt
// cargo run -- --tags tag1 --tags tag2
// cargo run -- --items=one,two,three
// cargo run -- --numbers 1 --numbers 5 --numbers 10

Environment Variables

use clap::Parser;
 
#[derive(Parser, Debug)]
struct Args {
    /// Server host
    #[arg(short = 'H', long, env = "SERVER_HOST", default_value = "localhost")]
    host: String,
    
    /// Server port
    #[arg(short, long, env = "SERVER_PORT", default_value = "8080")]
    port: u16,
    
    /// API key
    #[arg(short, long, env = "API_KEY")]
    api_key: Option<String>,
    
    /// Database URL
    #[arg(long, env = "DATABASE_URL")]
    database: Option<String>,
}
 
fn main() {
    let args = Args::parse();
    
    println!("Host: {}", args.host);
    println!("Port: {}", args.port);
    println!("API Key: {:?}", args.api_key);
    println!("Database: {:?}", args.database);
}
 
// Run with:
// SERVER_HOST=0.0.0.0 API_KEY=secret cargo run
// cargo run -- --host 127.0.0.1 --port 3000

Configuration Files

use clap::Parser;
use serde::Deserialize;
use std::path::PathBuf;
 
#[derive(Parser, Debug)]
struct Args {
    /// Configuration file path
    #[arg(short, long, value_name = "FILE")]
    config: Option<PathBuf>,
    
    /// Override: server host
    #[arg(short = 'H', long)]
    host: Option<String>,
    
    /// Override: server port
    #[arg(short, long)]
    port: Option<u16>,
}
 
#[derive(Deserialize, Debug)]
struct Config {
    host: String,
    port: u16,
    database_url: String,
}
 
impl Config {
    fn from_file(path: &PathBuf) -> Self {
        let content = std::fs::read_to_string(path).expect("Failed to read config");
        toml::from_str(&content).expect("Failed to parse config")
    }
}
 
fn main() {
    let args = Args::parse();
    
    // Load config from file if provided
    let config = args.config.as_ref().map(|p| Config::from_file(p));
    
    // Merge config with command line args (CLI takes precedence)
    let host = args.host
        .or(config.as_ref().map(|c| c.host.clone()))
        .unwrap_or_else(|| "localhost".to_string());
    
    let port = args.port
        .or(config.as_ref().map(|c| c.port))
        .unwrap_or(8080);
    
    println!("Host: {}", host);
    println!("Port: {}", port);
    if let Some(cfg) = config {
        println!("Database URL: {}", cfg.database_url);
    }
}
 
// Example config.toml:
// host = "0.0.0.0"
// port = 3000
// database_url = "postgres://localhost/mydb"

Arguments with Aliases

use clap::Parser;
 
#[derive(Parser, Debug)]
struct Args {
    /// Server hostname
    #[arg(short = 'H', long, visible_alias = "server")]
    host: String,
    
    /// Verbosity level
    #[arg(short, long, visible_aliases = ["verbosity", "v"])]
    verbose: bool,
    
    /// Output format
    #[arg(short, long, visible_short_aliases = ['F'])]
    format: Option<String>,
}
 
fn main() {
    let args = Args::parse();
    
    println!("Host: {}", args.host);
    println!("Verbose: {}", args.verbose);
    println!("Format: {:?}", args.format);
}
 
// All these work:
// cargo run -- --host localhost
// cargo run -- --server localhost
// cargo run -- -H localhost

Argument Relationships

use clap::{Parser, ArgGroup};
 
#[derive(Parser, Debug)]
#[command(group(
    ArgGroup::new("input")
        .args(["file", "stdin"])
        .required(true)
))]
struct Args {
    /// Read from file
    #[arg(short, long, conflicts_with = "stdin")]
    file: Option<String>,
    
    /// Read from stdin
    #[arg(long, conflicts_with = "file")]
    stdin: bool,
    
    /// Output file (required if using --file)
    #[arg(short, long, requires = "file")]
    output: Option<String>,
}
 
fn main() {
    let args = Args::parse();
    
    if let Some(file) = args.file {
        println!("Reading from file: {}", file);
        if let Some(output) = args.output {
            println!("Writing to: {}", output);
        }
    } else if args.stdin {
        println!("Reading from stdin");
    }
}
 
// Run with:
// cargo run -- --file input.txt --output output.txt
// cargo run -- --stdin
// This errors: cargo run -- --file input.txt --stdin
// This errors: cargo run -- --output output.txt (requires --file)

Colorized Help and Errors

use clap::Parser;
 
#[derive(Parser, Debug)]
#[command(name = "colorful")]
#[command(about = "A colorful CLI app", long_about = None)]
#[command(color = clap::ColorChoice::Always)]
struct Args {
    /// Name to greet
    #[arg(short, long)]
    name: String,
    
    /// Enable verbose mode
    #[arg(short, long)]
    verbose: bool,
}
 
fn main() {
    let args = Args::parse();
    
    println!("Hello, {}!", args.name);
    if args.verbose {
        println!("Verbose mode enabled");
    }
}
 
// Colors can be controlled:
// - ColorChoice::Always: Always use colors
// - ColorChoice::Auto: Use colors if output is a terminal
// - ColorChoice::Never: Never use colors

Builder API Alternative

use clap::{Arg, Command};
 
fn main() {
    let matches = Command::new("myapp")
        .version("1.0")
        .about("Does awesome things")
        .arg(
            Arg::new("name")
                .short('n')
                .long("name")
                .help("Name of the person")
                .required(true),
        )
        .arg(
            Arg::new("count")
                .short('c')
                .long("count")
                .help("Number of times to greet")
                .value_parser(clap::value_parser!(u8))
                .default_value("1"),
        )
        .arg(
            Arg::new("verbose")
                .short('v')
                .long("verbose")
                .help("Enable verbose output")
                .action(clap::ArgAction::SetTrue),
        )
        .get_matches();
    
    let name = matches.get_one::<String>("name").unwrap();
    let count = matches.get_one::<u8>("count").unwrap();
    let verbose = matches.get_flag("verbose");
    
    for i in 0..*count {
        if verbose {
            println!("Greeting {} of {}: Hello, {}!", i + 1, count, name);
        } else {
            println!("Hello, {}!", name);
        }
    }
}
 
// Run with:
// cargo run -- --name Alice --count 3 --verbose

Real-World CLI: File Search Tool

use clap::Parser;
use std::path::PathBuf;
 
#[derive(Parser, Debug)]
#[command(name = "findr")]
#[command(version, about = "A fast file search tool", long_about = None)]
struct Args {
    /// Pattern to search for
    pattern: String,
    
    /// Directory to search in
    #[arg(short, long, default_value = ".")]
    dir: PathBuf,
    
    /// Search recursively
    #[arg(short, long)]
    recursive: bool,
    
    /// Case insensitive search
    #[arg(short, long)]
    ignore_case: bool,
    
    /// Include hidden files
    #[arg(long)]
    hidden: bool,
    
    /// Maximum depth for recursive search
    #[arg(short = 'd', long)]
    max_depth: Option<usize>,
    
    /// File extension filter
    #[arg(short = 'e', long)]
    extension: Option<String>,
    
    /// Output format
    #[arg(short = 'f', long, value_enum, default_value = "path")]
    format: OutputFormat,
}
 
#[derive(Debug, Clone, clap::ValueEnum)]
enum OutputFormat {
    Path,
    Line,
    Json,
}
 
fn main() {
    let args = Args::parse();
    
    println!("Searching for: {}", args.pattern);
    println!("Directory: {:?}", args.dir);
    println!("Recursive: {}", args.recursive);
    println!("Case insensitive: {}", args.ignore_case);
    println!("Hidden files: {}", args.hidden);
    
    if let Some(depth) = args.max_depth {
        println!("Max depth: {}", depth);
    }
    
    if let Some(ext) = &args.extension {
        println!("Extension: {}", ext);
    }
    
    println!("Format: {:?}", args.format);
}
 
// Run with:
// cargo run -- "TODO" --dir ./src --recursive --extension rs
// cargo run -- "pattern" -ri --format json

Real-World CLI: Task Manager

use clap::{Parser, Subcommand};
use std::path::PathBuf;
 
#[derive(Parser)]
#[command(name = "task")]
#[command(about = "A simple task manager", long_about = None)]
struct Cli {
    /// Database file path
    #[arg(short, long, env = "TASK_DB", default_value = "tasks.json")]
    db: PathBuf,
    
    #[command(subcommand)]
    command: Commands,
}
 
#[derive(Subcommand)]
enum Commands {
    /// Add a new task
    Add {
        /// Task title
        title: String,
        
        /// Task description
        #[arg(short, long)]
        description: Option<String>,
        
        /// Due date (YYYY-MM-DD)
        #[arg(short, long)]
        due: Option<String>,
        
        /// Priority (1-5)
        #[arg(short, long, value_parser = clap::value_parser!(u8).range(1..=5))]
        priority: Option<u8>,
        
        /// Tags
        #[arg(short, long)]
        tags: Vec<String>,
    },
    
    /// List all tasks
    List {
        /// Show completed tasks
        #[arg(short, long)]
        all: bool,
        
        /// Filter by tag
        #[arg(short, long)]
        tag: Option<String>,
        
        /// Sort by field
        #[arg(long, value_enum, default_value = "created")]
        sort: SortField,
    },
    
    /// Complete a task
    Done {
        /// Task ID
        id: u64,
    },
    
    /// Remove a task
    Remove {
        /// Task ID
        id: u64,
        
        /// Force removal without confirmation
        #[arg(short, long)]
        force: bool,
    },
    
    /// Edit a task
    Edit {
        /// Task ID
        id: u64,
        
        /// New title
        #[arg(short, long)]
        title: Option<String>,
        
        /// New description
        #[arg(short, long)]
        description: Option<String>,
        
        /// New due date
        #[arg(short, long)]
        due: Option<String>,
    },
}
 
#[derive(Debug, Clone, clap::ValueEnum)]
enum SortField {
    Created,
    Priority,
    Due,
}
 
fn main() {
    let cli = Cli::parse();
    
    println!("Database: {:?}", cli.db);
    
    match &cli.command {
        Commands::Add { title, description, due, priority, tags } => {
            println!("Adding task: {}", title);
            if let Some(desc) = description {
                println!("  Description: {}", desc);
            }
            if let Some(d) = due {
                println!("  Due: {}", d);
            }
            if let Some(p) = priority {
                println!("  Priority: {}", p);
            }
            if !tags.is_empty() {
                println!("  Tags: {:?}", tags);
            }
        }
        Commands::List { all, tag, sort } => {
            println!("Listing tasks (all: {}, sort: {:?})", all, sort);
            if let Some(t) = tag {
                println!("  Filtered by tag: {}", t);
            }
        }
        Commands::Done { id } => {
            println!("Completing task {}", id);
        }
        Commands::Remove { id, force } => {
            println!("Removing task {} (force: {})", id, force);
        }
        Commands::Edit { id, title, description, due } => {
            println!("Editing task {}", id);
            if let Some(t) = title {
                println!("  New title: {}", t);
            }
            if let Some(d) = description {
                println!("  New description: {}", d);
            }
            if let Some(d) = due {
                println!("  New due date: {}", d);
            }
        }
    }
}
 
// Run with:
// cargo run -- add "Buy groceries" -d "Milk, eggs, bread" --priority 3
// cargo run -- list --all --sort priority
// cargo run -- done 1
// cargo run -- remove 2 --force

Testing CLI Applications

use clap::Parser;
 
#[derive(Parser, Debug)]
struct Args {
    #[arg(short, long)]
    name: String,
    
    #[arg(short, long, default_value_t = 1)]
    count: u8,
}
 
fn run(args: Args) -> String {
    let mut result = String::new();
    for _ in 0..args.count {
        result.push_str(&format!("Hello, {}!\n", args.name));
    }
    result
}
 
fn main() {
    let args = Args::parse();
    print!("{}", run(args));
}
 
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_greet_once() {
        let args = Args::try_parse_from(["test", "--name", "Alice"]).unwrap();
        let output = run(args);
        assert_eq!(output, "Hello, Alice!\n");
    }
    
    #[test]
    fn test_greet_multiple() {
        let args = Args::try_parse_from(["test", "--name", "Bob", "--count", "3"]).unwrap();
        let output = run(args);
        assert_eq!(output, "Hello, Bob!\nHello, Bob!\nHello, Bob!\n");
    }
    
    #[test]
    fn test_missing_name() {
        let result = Args::try_parse_from(["test"]);
        assert!(result.is_err());
    }
}

Summary

  • Use #[derive(Parser)] for declarative argument definitions
  • #[arg(short, long)] creates both -s and --long-form options
  • Positional arguments are defined without #[arg] attributes
  • Option<T> for optional arguments, direct types for required
  • Vec<T> for arguments that can be repeated
  • bool flags are set with #[arg(action = ArgAction::SetTrue)] (or just #[arg(short, long)])
  • Subcommands use #[derive(Subcommand)] with an enum
  • Use conflicts_with and requires for argument relationships
  • value_parser enables custom validation and type conversion
  • env = "VAR_NAME" reads from environment variables
  • value_delimiter = ',' splits comma-separated values
  • Use default_value or default_value_t for defaults
  • The Command builder API is available for dynamic argument construction
  • Test with try_parse_from for unit tests
  • Perfect for: CLI tools, scripts, devops tools, configuration utilities