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 —
--helpworks without writing anything - Type-safe parsing — arguments are parsed into Rust types
- Validation — reject invalid values before your code runs
- Subcommands — like
git commitorcargo 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 outputname: String— a positional argument (required by default)#[arg(short, long)]— creates both-cand--countflags#[arg(default_value = "1")]— makes the argument optional with a defaultboolfields become flags — present meanstrue, absent meansfalse
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.