In the previous tutorial, we built a database-backed API with SQLx. Now we switch gears and learn Clap — the most popular library for building command-line tools in Rust.

CLI tools are one of Rust’s sweet spots. Fast startup, small binaries, no runtime needed. Tools like ripgrep, bat, fd, and exa are all written in Rust. Clap handles the argument parsing so you can focus on the logic.

By the end of this tutorial, you will build a complete CLI tool with subcommands, flags, and validated arguments.

Why Clap?

You could parse std::env::args() manually. But that means writing your own help messages, handling missing arguments, validating types, and dealing with edge cases. Clap does all of this for free:

  • Automatic help messages--help works without writing anything
  • Type-safe parsing — arguments are parsed into Rust types
  • Validation — reject invalid values before your code runs
  • Subcommands — like git commit or cargo build
  • Shell completions — generate autocomplete scripts

Setting Up

Add Clap to your Cargo.toml:

[dependencies]
clap = { version = "4", features = ["derive"] }

The "derive" feature enables #[derive(Parser)]. This is the recommended way to use Clap. You define your CLI as a struct, and Clap generates the parser.

Your First CLI

A greeter that takes a name:

use clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "greeter")]
#[command(about = "A simple greeter")]
struct Cli {
    /// Name of the person to greet
    name: String,

    /// Number of times to greet
    #[arg(short, long, default_value = "1")]
    count: u32,

    /// Use uppercase greeting
    #[arg(short, long)]
    uppercase: bool,
}

fn main() {
    let cli = Cli::parse();

    for i in 1..=cli.count {
        let msg = format!("Hello, {}! (#{}/{})", cli.name, i, cli.count);
        if cli.uppercase {
            println!("{}", msg.to_uppercase());
        } else {
            println!("{}", msg);
        }
    }
}

Let’s break this down:

  • #[derive(Parser)] — Clap generates the argument parser from the struct
  • #[command(name = "greeter")] — sets the program name in help output
  • name: String — a positional argument (required by default)
  • #[arg(short, long)] — creates both -c and --count flags
  • #[arg(default_value = "1")] — makes the argument optional with a default
  • bool fields become flags — present means true, absent means false

The doc comments (///) become help text automatically. Run greeter --help:

A simple greeter

Usage: greeter [OPTIONS] <NAME>

Arguments:
  <NAME>  Name of the person to greet

Options:
  -c, --count <COUNT>  Number of times to greet [default: 1]
  -u, --uppercase      Use uppercase greeting
  -h, --help           Print help

Positional vs Named Arguments

Positional arguments are fields without #[arg(short)] or #[arg(long)]:

use clap::Parser;

#[derive(Parser)]
struct FileCopy {
    /// Source file
    source: String,

    /// Destination file
    destination: String,
}

Usage: filecopy input.txt output.txt

Named flags use #[arg(short, long)]:

use clap::Parser;

#[derive(Parser)]
struct FileCopy {
    /// Source file
    #[arg(short, long)]
    source: String,

    /// Destination file
    #[arg(short, long)]
    destination: String,
}

Usage: filecopy -s input.txt -d output.txt

Optional arguments use Option<T>:

use clap::Parser;

#[derive(Parser)]
struct Cli {
    /// Optional config file
    #[arg(short, long)]
    config: Option<String>,
}

The field is None if the user does not provide it.

ValueEnum — Choosing from a Set

When the user must pick from a list, use ValueEnum:

use clap::{Parser, ValueEnum};
use std::path::PathBuf;

#[derive(ValueEnum, Clone, Debug)]
enum OutputFormat {
    Json,
    Csv,
    Table,
}

#[derive(Parser, Debug)]
struct Cli {
    /// Input file
    path: PathBuf,

    /// Output format
    #[arg(short, long, default_value = "table")]
    format: OutputFormat,
}

Clap converts between lowercase strings and enum variants. The user types --format json and gets OutputFormat::Json.

If the user types an invalid value:

error: invalid value 'xml' for '--format <FORMAT>'
  [possible values: json, csv, table]

No manual validation needed.

Subcommands

Real CLI tools have subcommands. Think git commit, cargo build. Clap makes this easy with enums:

use clap::{Parser, Subcommand, Args};
use std::path::PathBuf;

#[derive(Parser, Debug)]
#[command(name = "notetool")]
#[command(version = "1.0")]
#[command(about = "A note management tool")]
struct Cli {
    #[command(subcommand)]
    command: Commands,

    /// Enable verbose output
    #[arg(short, long, global = true)]
    verbose: bool,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Add a new note
    Add(AddArgs),

    /// List all notes
    List(ListArgs),

    /// Search notes
    Search {
        /// Search query
        query: String,
    },
}

#[derive(Args, Debug)]
struct AddArgs {
    /// Note title
    title: String,

    /// Note content
    #[arg(short, long)]
    content: Option<String>,

    /// Tags for the note
    #[arg(short, long)]
    tags: Vec<String>,
}

#[derive(Args, Debug)]
struct ListArgs {
    /// Filter by tag
    #[arg(short, long)]
    tag: Option<String>,

    /// Maximum number of notes to show
    #[arg(short, long, default_value = "10")]
    max: usize,
}

Usage:

notetool add "Shopping" -c "Milk, eggs, bread" -t groceries
notetool list --tag groceries --max 5
notetool search "milk"
notetool -v list                # verbose + list

The #[arg(global = true)] on verbose means it works with any subcommand.

Processing Subcommands

Use match to handle each subcommand:

fn main() {
    let cli = Cli::parse();

    match &cli.command {
        Commands::Add(args) => {
            println!("Adding note: {}", args.title);
            if let Some(content) = &args.content {
                println!("Content: {}", content);
            }
            if !args.tags.is_empty() {
                println!("Tags: {}", args.tags.join(", "));
            }
        }
        Commands::List(args) => {
            println!("Listing notes (max: {})", args.max);
            if let Some(tag) = &args.tag {
                println!("Filtered by tag: {}", tag);
            }
        }
        Commands::Search { query } => {
            println!("Searching for: {}", query);
        }
    }

    if cli.verbose {
        println!("Done!");
    }
}

Each subcommand has its own logic. The struct fields give you typed, validated data ready to use.

Argument Validation

Clap can validate arguments at parse time. Use value_parser for numeric ranges:

use clap::Parser;

#[derive(Parser, Debug)]
struct ServerConfig {
    /// Port number (1024-65535)
    #[arg(short, long, value_parser = clap::value_parser!(u16).range(1024..=65535))]
    port: u16,

    /// Number of workers (1-32)
    #[arg(short, long, value_parser = clap::value_parser!(u8).range(1..=32))]
    workers: u8,

    /// Host address
    #[arg(long, default_value = "127.0.0.1")]
    host: String,
}

If the user passes an invalid port:

error: invalid value '80' for '--port <PORT>': 80 is not in 1024..=65535

You can also validate with custom functions:

fn validate_name(name: &str) -> Result<String, String> {
    if name.len() < 2 {
        Err("Name must be at least 2 characters".to_string())
    } else if name.len() > 50 {
        Err("Name must be at most 50 characters".to_string())
    } else {
        Ok(name.to_string())
    }
}

use clap::Parser;

#[derive(Parser)]
struct Cli {
    /// Your name (2-50 characters)
    #[arg(value_parser = validate_name)]
    name: String,
}

Clap runs your validation during parsing. If it returns Err, Clap shows the error and exits.

Common Patterns

Multiple Values

Accept multiple values for an argument:

use clap::Parser;
use std::path::PathBuf;

#[derive(Parser)]
struct Cli {
    /// Files to process
    #[arg(short, long, num_args = 1..)]
    files: Vec<PathBuf>,
}

Usage: mytool -f a.txt -f b.txt -f c.txt

Environment Variables

Fall back to environment variables:

use clap::Parser;

#[derive(Parser)]
struct Cli {
    /// API key (or set API_KEY env var)
    #[arg(long, env = "API_KEY")]
    api_key: String,
}

If --api-key is not provided, Clap checks the API_KEY environment variable.

Version from Cargo.toml

Use #[command(version)] without a value. Clap reads the version from your Cargo.toml:

use clap::Parser;

#[derive(Parser)]
#[command(version)]
struct Cli {
    // ...
}

Now mytool --version prints mytool 0.1.0 (or whatever version is in Cargo.toml).

Testing Your CLI

Test Clap parsing without running the binary. Use try_parse_from:

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;

    #[test]
    fn test_add_subcommand() {
        let cli = Cli::try_parse_from([
            "notetool", "add", "Shopping", "-c", "Buy milk",
        ]).unwrap();

        match cli.command {
            Commands::Add(args) => {
                assert_eq!(args.title, "Shopping");
                assert_eq!(args.content, Some("Buy milk".to_string()));
            }
            _ => panic!("Expected Add command"),
        }
    }

    #[test]
    fn test_list_defaults() {
        let cli = Cli::try_parse_from(["notetool", "list"]).unwrap();
        match cli.command {
            Commands::List(args) => {
                assert_eq!(args.max, 10);
                assert_eq!(args.tag, None);
            }
            _ => panic!("Expected List command"),
        }
    }

    #[test]
    fn test_verbose_flag() {
        let cli = Cli::try_parse_from(["notetool", "-v", "list"]).unwrap();
        assert!(cli.verbose);
    }

    #[test]
    fn test_invalid_args() {
        let result = Cli::try_parse_from(["notetool"]);
        assert!(result.is_err());
    }
}

try_parse_from returns a Result instead of exiting on error. This is perfect for tests.

A Complete Example: File Stats Tool

Let’s combine everything into a practical tool:

use clap::{Parser, Subcommand, Args, ValueEnum};
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "fstat")]
#[command(version)]
#[command(about = "File statistics tool")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Count lines, words, or characters in a file
    Count(CountArgs),

    /// Show file information
    Info {
        /// Path to the file
        path: PathBuf,
    },
}

#[derive(Args)]
struct CountArgs {
    /// Path to the file
    path: PathBuf,

    /// What to count
    #[arg(short, long, default_value = "all")]
    mode: CountMode,
}

#[derive(ValueEnum, Clone)]
enum CountMode {
    Lines,
    Words,
    Chars,
    All,
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Count(args) => {
            let text = match std::fs::read_to_string(&args.path) {
                Ok(t) => t,
                Err(e) => {
                    eprintln!("Error reading file: {}", e);
                    std::process::exit(1);
                }
            };
            match args.mode {
                CountMode::Lines => println!("{} lines", text.lines().count()),
                CountMode::Words => println!("{} words", text.split_whitespace().count()),
                CountMode::Chars => println!("{} characters", text.chars().count()),
                CountMode::All => {
                    println!("{} lines", text.lines().count());
                    println!("{} words", text.split_whitespace().count());
                    println!("{} characters", text.chars().count());
                }
            }
        }
        Commands::Info { path } => {
            match std::fs::metadata(&path) {
                Ok(meta) => {
                    println!("File: {}", path.display());
                    println!("Size: {} bytes", meta.len());
                    println!("Is file: {}", meta.is_file());
                    println!("Is directory: {}", meta.is_dir());
                    println!("Read-only: {}", meta.permissions().readonly());
                }
                Err(e) => {
                    eprintln!("Error: {}", e);
                    std::process::exit(1);
                }
            }
        }
    }
}

This is a small but complete tool. It parses arguments, validates them, reads files, and handles errors properly.

Source Code

You can find the complete source code for this tutorial on GitHub:

kemalcodes/rust-tutorial (branch: tutorial-24-clap)

What’s Next?

In the next tutorial, we learn File I/O and Path Handling — reading files, writing files, walking directories, and working with the filesystem in Rust.