How do I build command line applications in Rust?

Walkthrough

Clap (Command Line Argument Parser) is the most popular library for building command-line interfaces in Rust. It provides both derive and builder APIs, automatic help generation, shell completion scripts, subcommand support, and extensive validation. Clap handles argument parsing, type conversion, and error messages with minimal boilerplate.

Key concepts:

  1. Derive API — use attributes on structs and enums for declarative parsing
  2. Builder API — construct parsers programmatically with method chains
  3. Arguments — positional args, flags (boolean), and options (with values)
  4. Subcommands — nested command structures like git commit, cargo build
  5. Validation — automatic type parsing, value constraints, and required arguments

Clap generates professional CLI apps with help, version, and usage strings automatically.

Code Example

# Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
use clap::Parser;
 
/// A simple calculator program
#[derive(Parser, Debug)]
#[command(name = "calc")]
#[command(author, version, about)]
struct Args {
    /// First number
    #[arg(short, long)]
    a: i32,
    
    /// Second number
    #[arg(short, long)]
    b: i32,
    
    /// Operation to perform
    #[arg(short, long, default_value = "add")]
    op: String,
}
 
fn main() {
    let args = Args::parse();
    
    let result = match args.op.as_str() {
        "add" => args.a + args.b,
        "sub" => args.a - args.b,
        "mul" => args.a * args.b,
        "div" => args.a / args.b,
        _ => panic!("Unknown operation: {}", args.op),
    };
    
    println!("Result: {}", result);
}

Positional Arguments

use clap::Parser;
 
/// File processing tool
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
    /// Input file path
    input: String,
    
    /// Output file path
    output: String,
    
    /// Number of lines to process
    #[arg(short, long, default_value = "10")]
    lines: usize,
}
 
fn main() {
    let args = Args::parse();
    
    println!("Input: {}", args.input);
    println!("Output: {}", args.output);
    println!("Lines: {}", args.lines);
}
 
// Usage:
// ./program input.txt output.txt --lines 20
// ./program input.txt output.txt -l 20
// ./program input.txt output.txt

Flags and Options

use clap::Parser;
 
/// Search tool with various options
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
    /// Search pattern
    #[arg(short, long)]
    pattern: String,
    
    /// Search path (can be specified multiple times)
    #[arg(short = 'p', long = "path")]
    paths: Vec<String>,
    
    /// Case insensitive search
    #[arg(short, long)]
    ignore_case: bool,
    
    /// Verbose output
    #[arg(short, long)]
    verbose: bool,
    
    /// Number of context lines
    #[arg(short = 'C', long = "context", default_value = "2")]
    context_lines: usize,
    
    /// Output format
    #[arg(short, long, value_enum, default_value = "text")]
    format: OutputFormat,
}
 
#[derive(clap::ValueEnum, Clone, Debug, Default)]
enum OutputFormat {
    Text,
    Json,
    Csv,
    Html,
}
 
fn main() {
    let args = Args::parse();
    
    println!("Pattern: {}", args.pattern);
    println!("Paths: {:?}", args.paths);
    println!("Ignore case: {}", args.ignore_case);
    println!("Verbose: {}", args.verbose);
    println!("Context: {}", args.context_lines);
    println!("Format: {:?}", args.format);
}
 
// Usage:
// ./search -p "error" --path /var/log --path ./logs -iv
// ./search --pattern "todo" --format json

Subcommands

use clap::{Parser, Subcommand};
 
/// Version control system
#[derive(Parser, Debug)]
#[command(name = "mygit")]
#[command(author, version, about)]
struct Cli {
    /// Turn debugging information on
    #[arg(short, long, global = true)]
    verbose: bool,
    
    #[command(subcommand)]
    command: Commands,
}
 
#[derive(Subcommand, Debug)]
enum Commands {
    /// Add file contents to the index
    Add {
        /// Files to add
        #[arg(required = true)]
        files: Vec<String>,
        
        /// Add all files
        #[arg(short, long)]
        all: bool,
    },
    
    /// Commit changes to the repository
    Commit {
        /// Commit message
        #[arg(short, long)]
        message: String,
        
        /// Author name override
        #[arg(long)]
        author: Option<String>,
    },
    
    /// Clone a repository
    Clone {
        /// Repository URL
        url: String,
        
        /// Target directory
        #[arg(short, long)]
        directory: Option<String>,
        
        /// Clone depth
        #[arg(long, default_value = "0")]
        depth: usize,
    },
    
    /// Push changes to remote
    Push {
        /// Remote name
        #[arg(default_value = "origin")]
        remote: String,
        
        /// Branch name
        #[arg(default_value = "main")]
        branch: String,
        
        /// Force push
        #[arg(short, long)]
        force: bool,
    },
    
    /// Show working tree status
    Status {
        /// Show short format
        #[arg(short, long)]
        short: bool,
        
        /// Show branch info
        #[arg(short, long)]
        branch: bool,
    },
}
 
fn main() {
    let cli = Cli::parse();
    
    if cli.verbose {
        println!("Debug mode enabled");
    }
    
    match cli.command {
        Commands::Add { files, all } => {
            if all {
                println!("Adding all files");
            } else {
                println!("Adding files: {:?}", files);
            }
        }
        Commands::Commit { message, author } => {
            println!("Commit: {}", message);
            if let Some(a) = author {
                println!("Author: {}", a);
            }
        }
        Commands::Clone { url, directory, depth } => {
            println!("Cloning {} (depth: {})", url, depth);
            if let Some(dir) = directory {
                println!("Target: {}", dir);
            }
        }
        Commands::Push { remote, branch, force } => {
            if force {
                println!("Force pushing to {}/{}", remote, branch);
            } else {
                println!("Pushing to {}/{}", remote, branch);
            }
        }
        Commands::Status { short, branch } => {
            if short {
                println!("Short status");
            }
            if branch {
                println!("On branch: main");
            }
        }
    }
}
 
// Usage:
// ./mygit add file1.txt file2.txt --all
// ./mygit commit -m "Initial commit" --author "Alice"
// ./mygit clone https://github.com/user/repo -d myrepo --depth 1
// ./mygit push origin main --force
// ./mygit status -sb

Argument Groups and Mutually Exclusive Options

use clap::{Parser, ArgGroup};
 
/// Database backup tool
#[derive(Parser, Debug)]
#[command(author, version, about)]
#[command(group(
    ArgGroup::new("source")
        .args(["file", "database"])
        .required(true)
))]
#[command(group(
    ArgGroup::new("output")
        .args(["output_file", "stdout"])
))]
struct Args {
    /// Input file path
    #[arg(short, long)]
    file: Option<String>,
    
    /// Database connection string
    #[arg(short, long)]
    database: Option<String>,
    
    /// Output file path
    #[arg(short, long)]
    output_file: Option<String>,
    
    /// Output to stdout
    #[arg(long)]
    stdout: bool,
    
    /// Compress output
    #[arg(short, long)]
    compress: bool,
}
 
fn main() {
    let args = Args::parse();
    
    let source = args.file
        .as_ref()
        .map(|f| format!("file: {}", f))
        .or(args.database.as_ref().map(|d| format!("database: {}", d)));
    
    println!("Source: {}", source.unwrap());
    println!("Compress: {}", args.compress);
}
 
// Usage:
// ./backup --file data.sql --output-file backup.sql
// ./backup --database postgres://localhost/db --stdout
// ./backup --file data.sql  # Error: missing output option

Validation and Custom Parsing

use clap::Parser;
use std::path::PathBuf;
 
/// File processor with validation
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
    /// Input file (must exist)
    #[arg(short, long, value_parser = clap::value_parser!(PathBuf))]
    input: PathBuf,
    
    /// Output file
    #[arg(short, long)]
    output: PathBuf,
    
    /// Port number (1-65535)
    #[arg(short, long, value_parser = parse_port)]
    port: u16,
    
    /// IP address
    #[arg(long, value_parser = parse_ip)]
    ip: String,
    
    /// Compression level (1-9)
    #[arg(short, long, value_parser = 1..=9)]
    level: u8,
    
    /// Timeout in seconds
    #[arg(short, long, value_parser = parse_timeout)]
    timeout: u64,
}
 
fn parse_port(s: &str) -> Result<u16, String> {
    let port: u16 = s.parse().map_err(|_| "Invalid port number".to_string())?;
    if port == 0 {
        Err("Port cannot be 0".to_string())
    } else {
        Ok(port)
    }
}
 
fn parse_ip(s: &str) -> Result<String, String> {
    let parts: Vec<&str> = s.split('.').collect();
    if parts.len() != 4 {
        return Err("Invalid IP address format".to_string());
    }
    for part in parts {
        let n: u8 = part.parse().map_err(|_| "Invalid IP octet".to_string())?;
        if n > 255 {
            return Err("IP octet must be 0-255".to_string());
        }
    }
    Ok(s.to_string())
}
 
fn parse_timeout(s: &str) -> Result<u64, String> {
    let timeout: u64 = s.parse().map_err(|_| "Invalid timeout".to_string())?;
    if timeout < 1 || timeout > 3600 {
        Err("Timeout must be between 1 and 3600 seconds".to_string())
    } else {
        Ok(timeout)
    }
}
 
fn main() {
    let args = Args::parse();
    
    println!("Input: {:?}", args.input);
    println!("Output: {:?}", args.output);
    println!("Port: {}", args.port);
    println!("IP: {}", args.ip);
    println!("Level: {}", args.level);
    println!("Timeout: {}s", args.timeout);
}

Environment Variables and Config Files

use clap::Parser;
 
/// Configuration tool with environment variable support
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
    /// API key (from --api-key or MYAPP_API_KEY env var)
    #[arg(long, env = "MYAPP_API_KEY")]
    api_key: String,
    
    /// Server host
    #[arg(long, env = "MYAPP_HOST", default_value = "localhost")]
    host: String,
    
    /// Server port
    #[arg(long, env = "MYAPP_PORT", default_value = "8080")]
    port: u16,
    
    /// Log level
    #[arg(long, env = "MYAPP_LOG_LEVEL", default_value = "info")]
    log_level: String,
    
    /// Database URL (optional env var)
    #[arg(long, env = "DATABASE_URL")]
    database_url: Option<String>,
}
 
fn main() {
    let args = Args::parse();
    
    println!("API Key: {}", args.api_key);
    println!("Host: {}", args.host);
    println!("Port: {}", args.port);
    println!("Log Level: {}", args.log_level);
    if let Some(url) = args.database_url {
        println!("Database: {}", url);
    }
}
 
// Usage:
// MYAPP_API_KEY=secret ./app
// ./app --api-key secret --port 3000
// DATABASE_URL=postgres://localhost/db ./app --api-key secret

Colored Output and Help Customization

use clap::Parser;
 
/// A modern file search tool
#[derive(Parser, Debug)]
#[command(
    name = "fsearch",
    author,
    version,
    about,
    long_about = "A fast, modern file search tool with regex support.",
    next_line_help = true,
    disable_help_flag = false,
    disable_version_flag = false,
)]
struct Args {
    /// Search pattern (supports regex)
    #[arg(short, long, help = "Pattern to search for in file contents")]
    pattern: String,
    
    /// Root directory to search
    #[arg(short, long, default_value = ".", help = "Starting directory")]
    path: String,
    
    /// File extension filter
    #[arg(short = 'e', long, help = "Only search files with this extension")]
    extension: Option<String>,
    
    /// Case insensitive search
    #[arg(short, long, help_heading = "Search Options")]
    ignore_case: bool,
    
    /// Show line numbers
    #[arg(short = 'n', long, help_heading = "Output Options")]
    line_numbers: bool,
    
    /// Show file names only
    #[arg(short, long, help_heading = "Output Options")]
    files_with_matches: bool,
    
    /// Recursive search depth
    #[arg(long, default_value = "10", help_heading = "Search Options")]
    max_depth: usize,
    
    /// Exclude patterns
    #[arg(short, long, help_heading = "Filter Options")]
    exclude: Vec<String>,
}
 
fn main() {
    let args = Args::parse();
    
    println!("Pattern: {}", args.pattern);
    println!("Path: {}", args.path);
    if let Some(ext) = args.extension {
        println!("Extension: {}", ext);
    }
    println!("Max depth: {}", args.max_depth);
    if !args.exclude.is_empty() {
        println!("Exclude: {:?}", args.exclude);
    }
}

Nested Subcommands

use clap::{Parser, Subcommand};
 
/// Docker-like CLI with nested commands
#[derive(Parser, Debug)]
#[command(name = "container")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}
 
#[derive(Subcommand, Debug)]
enum Commands {
    /// Manage containers
    Container {
        #[command(subcommand)]
        command: ContainerCommands,
    },
    /// Manage images
    Image {
        #[command(subcommand)]
        command: ImageCommands,
    },
    /// Manage networks
    Network {
        #[command(subcommand)]
        command: NetworkCommands,
    },
}
 
#[derive(Subcommand, Debug)]
enum ContainerCommands {
    /// List containers
    Ls {
        /// Show all containers (including stopped)
        #[arg(short, long)]
        all: bool,
        
        /// Filter by name
        #[arg(short, long)]
        filter: Option<String>,
    },
    
    /// Run a container
    Run {
        /// Image name
        image: String,
        
        /// Container name
        #[arg(short, long)]
        name: Option<String>,
        
        /// Port mapping (e.g., 8080:80)
        #[arg(short, long)]
        publish: Vec<String>,
        
        /// Environment variables
        #[arg(short, long)]
        env: Vec<String>,
        
        /// Run in background
        #[arg(short, long)]
        detach: bool,
    },
    
    /// Stop a container
    Stop {
        /// Container ID or name
        containers: Vec<String>,
        
        /// Time to wait before killing
        #[arg(short, long, default_value = "10")]
        time: u32,
    },
    
    /// Remove a container
    Rm {
        /// Container ID or name
        containers: Vec<String>,
        
        /// Force removal
        #[arg(short, long)]
        force: bool,
    },
}
 
#[derive(Subcommand, Debug)]
enum ImageCommands {
    /// List images
    Ls,
    
    /// Pull an image
    Pull {
        /// Image name
        name: String,
        
        /// Tag
        #[arg(short, long, default_value = "latest")]
        tag: String,
    },
    
    /// Remove an image
    Rm {
        /// Image ID or name
        images: Vec<String>,
    },
}
 
#[derive(Subcommand, Debug)]
enum NetworkCommands {
    /// List networks
    Ls,
    
    /// Create a network
    Create {
        /// Network name
        name: String,
        
        /// Network driver
        #[arg(long, default_value = "bridge")]
        driver: String,
    },
}
 
fn main() {
    let cli = Cli::parse();
    
    match cli.command {
        Commands::Container { command } => match command {
            ContainerCommands::Ls { all, filter } => {
                println!("Listing containers (all: {})", all);
                if let Some(f) = filter {
                    println!("Filter: {}", f);
                }
            }
            ContainerCommands::Run { image, name, publish, env, detach } => {
                println!("Running image: {}", image);
                if let Some(n) = name {
                    println!("Name: {}", n);
                }
                println!("Ports: {:?}", publish);
                println!("Env: {:?}", env);
                if detach {
                    println!("Running in background");
                }
            }
            ContainerCommands::Stop { containers, time } => {
                println!("Stopping {:?} (timeout: {}s)", containers, time);
            }
            ContainerCommands::Rm { containers, force } => {
                if force {
                    println!("Force removing {:?}", containers);
                } else {
                    println!("Removing {:?}", containers);
                }
            }
        },
        Commands::Image { command } => match command {
            ImageCommands::Ls => println!("Listing images"),
            ImageCommands::Pull { name, tag } => {
                println!("Pulling {}: {}", name, tag);
            }
            ImageCommands::Rm { images } => {
                println!("Removing images: {:?}", images);
            }
        },
        Commands::Network { command } => match command {
            NetworkCommands::Ls => println!("Listing networks"),
            NetworkCommands::Create { name, driver } => {
                println!("Creating network '{}' with driver '{}'", name, driver);
            }
        },
    }
}
 
// Usage:
// ./container container ls --all
// ./container container run nginx -d -p 8080:80 --name web
// ./container image pull alpine --tag 3.18
// ./container network create mynet --driver bridge

Summary

  • Use #[derive(Parser)] on a struct for the derive API (recommended)
  • #[arg(short, long)] creates -s and --short flags/options
  • Positional arguments don't need attributes
  • Use Option<T> for optional arguments, Vec<T> for multiple values
  • #[arg(default_value = "...")] sets a default
  • #[command(subcommand)] with #[derive(Subcommand)] enum creates subcommands
  • #[arg(value_parser = ...)] for custom validation functions
  • #[arg(env = "VAR")] reads from environment variables
  • #[arg(global = true)] makes flags available to all subcommands
  • Use ArgGroup with #[command(group(...))] for mutually exclusive arguments
  • #[arg(help = "...")] customizes help text
  • #[command(help_heading = "...")] groups options in help output
  • #[derive(ValueEnum)] creates enumerated values for arguments
  • clap::value_parser!(PathBuf) for type-safe path parsing
  • Built-in --help and --version flags are generated automatically