2 min read
On this page

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 — a Vec<String> positional argument accepts zero values by default. Add required = true if at least one is needed.
  • Conflicting short flags-c can only map to one argument. clap panics at runtime if two arguments share a short flag.
  • Not using ValueEnum for fixed options — accepting a String and matching manually loses help text, tab completion, and validation.
  • Ignoring exit codesCli::parse() calls std::process::exit(2) on parse errors. Use Cli::try_parse() for custom error handling.
  • Hardcoding version strings — use #[command(version)] to pull from Cargo.toml automatically.
  • 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 ValueEnum for fixed choice sets, value_parser for range validation, and env for 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.