If you are looking at the landscape of modern command-line tools—ripgrep, bat, exa, delta—they all share a common DNA: they are written in Rust. By 2025, Rust has firmly established itself as the de facto language for building high-performance, safe, and distributable CLI tools.
For years, the debate in the ecosystem was between using clap (the Command Line Argument Parser) via its Builder pattern or using structopt, a crate that allowed you to define arguments using structs and derive macros.
Here is the good news for the modern Rust developer: You no longer have to choose.
In this guide, we will explore how to build a professional-grade CLI tool using modern clap (v4+), which has absorbed the powers of structopt. We will build a log-processing utility called log-sift, covering everything from basic argument parsing to complex subcommands and custom validation.
Why Rust for CLIs? #
Before we open the terminal, it is worth reiterating why we are here. Python and Node.js are great for quick scripts, but Rust offers distinct advantages for tools meant to be distributed:
- Single Binary: No need to ask the user to
pip installor check their node version. You ship one binary, and it just works. - Startup Time: Rust’s zero-runtime overhead means your CLI responds instantly—crucial for tools invoked inside loops or CI/CD pipelines.
- Type Safety:
clapleverages Rust’s type system. If your CLI expects anu32and the user provides “hello”, the parser catches it before your logic even runs.
Prerequisites and Environment #
To follow along, ensure you have a standard Rust development environment set up.
- Rust Toolchain: Version 1.75 or higher (stable).
- Package Manager: Cargo.
- IDE: VS Code with
rust-analyzeror RustRover.
We will be creating a fresh project for this tutorial.
cargo new log-sift
cd log-siftThe Evolution: StructOpt vs. Clap #
For those of you who have been in the Rust game for a while, you might remember adding structopt to your dependencies. As of clap v3 and refined in v4, the “Derive API” is now a core part of clap.
Here is a quick comparison of the ecosystem:
| Feature | Legacy (structopt) |
Modern (clap v4+) |
|---|---|---|
| Dependency | structopt + clap |
clap (with “derive” feature) |
| Ergonomics | High (Declarative) | High (Declarative) |
| Builder API | Not available directly | Available for dynamic needs |
| Maintenance | Deprecated/Frozen | Active Development |
| Binary Size | Moderate | Optimized |
We will focus strictly on the Modern approach, which is the industry standard for 2025.
Step 1: Setting Up Dependencies #
Let’s configure our Cargo.toml. We need clap with the derive feature enabled to unlock the struct-based configuration. We will also add anyhow for ergonomic error handling.
File: Cargo.toml
[package]
name = "log-sift"
version = "0.1.0"
edition = "2021"
[dependencies]
# The star of the show. We enable "derive" to use structs.
clap = { version = "4.5", features = ["derive"] }
# For easy error propagation
anyhow = "1.0"Step 2: The Basics of the Derive API #
The core philosophy of the Derive API is simple: Define your command line interface as a Rust struct.
Instead of writing imperative code (“add an argument named ‘file’”), you define a struct field, and clap generates the parsing logic at compile time.
Let’s create a basic CLI that accepts a filename and an optional verbosity flag.
File: src/main.rs
use clap::Parser;
use std::path::PathBuf;
use anyhow::Result;
/// A fast log filtering tool built in Rust.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// The path to the log file to read
#[arg(short, long)]
file: PathBuf,
/// Turn on verbose output
#[arg(short, long, action = clap::ArgAction::SetTrue)]
verbose: bool,
/// Optional pattern to search for (default: search for "ERROR")
#[arg(short, long, default_value = "ERROR")]
pattern: String,
}
fn main() -> Result<()> {
// 1. Parse the arguments
let args = Cli::parse();
// 2. Use the arguments
if args.verbose {
println!("Debug mode enabled.");
println!("Looking for pattern '{}' in file: {:?}", args.pattern, args.file);
}
// Simulating file work
println!("Processing complete.");
Ok(())
}Running the Code #
Try running the help command. clap auto-generates this based on your doc comments (///).
cargo run -- --helpOutput:
A fast log filtering tool built in Rust.
Usage: log-sift [OPTIONS] --file <FILE>
Options:
-f, --file <FILE> The path to the log file to read
-v, --verbose Turn on verbose output
-p, --pattern <PATTERN> Optional pattern to search for (default: search for "ERROR") [default: ERROR]
-h, --help Print help
-V, --version Print versionNotice how structopt’s spirit lives on? The variable file is mandatory because it is PathBuf. If we made it Option<PathBuf>, it would automatically become optional.
Step 3: Architecting with Subcommands #
Real-world tools rarely just take flags. They usually have “modes” or actions, like git commit vs. git push. In clap, we handle this using Rust enums.
This architecture is cleaner because it enforces mutual exclusivity at the type level. You cannot try to “commit” and “push” at the same time if the CLI structure forbids it.
Let’s visualize the flow of our log-sift tool:
Let’s implement this structure.
File: src/main.rs (Updated)
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Global verbose flag
#[arg(short, long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Scans a file once for a pattern
Scan {
/// Input file
#[arg(value_name = "FILE")]
path: PathBuf,
/// Pattern to find
#[arg(short, long)]
pattern: String,
},
/// Watches a file for changes (tail)
Watch {
/// Input file
path: PathBuf,
},
/// Generates statistics
Stats {
/// Input file
path: PathBuf,
/// Output format (json or text)
#[arg(long, default_value = "text")]
format: String,
},
}
fn main() {
let cli = Cli::parse();
match &cli.command {
Commands::Scan { path, pattern } => {
if cli.verbose { println!("Scanning {:?} for {}", path, pattern); }
// Logic for scanning...
println!("Found 3 occurrences.");
}
Commands::Watch { path } => {
if cli.verbose { println!("Watching {:?}", path); }
// Logic for watching...
println!("Listening for changes...");
}
Commands::Stats { path, format } => {
println!("Generating {} stats for {:?}", format, path);
}
}
}Why this rocks #
The match statement ensures you handle every single command. If you add a new command to the enum but forget to handle it in main, Rust won’t compile. This is the safety we want in production tools.
Step 4: Advanced Validation and Types #
One common pitfall in CLI development is accepting string inputs and validating them after the program starts running. With clap, we can do better.
For example, in our Stats command, we accepted a format string. But what if the user types xml and we only support json and text?
Instead of String, let’s use an enum with ValueEnum.
Update your code:
use clap::{Parser, Subcommand, ValueEnum};
// ... inside the Commands enum ...
Stats {
path: PathBuf,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
// ...
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
enum OutputFormat {
Text,
Json,
Yaml,
}Now, if a user tries log-sift stats --format xml, clap intercepts it:
error: invalid value 'xml' for '--format <FORMAT>'
[possible values: text, json, yaml]Custom Validation Functions #
Sometimes an Enum isn’t enough. Suppose we want to ensure the pattern in the Scan command is at least 3 characters long. We can attach a validator function.
fn validate_pattern(s: &str) -> Result<String, String> {
if s.len() < 3 {
return Err(String::from("Pattern must be at least 3 chars"));
}
Ok(s.to_string())
}
// In the Scan struct:
#[arg(short, long, value_parser = validate_pattern)]
pattern: String,Step 5: Testing Your CLI #
A CLI is software, and software needs tests. While you can unit test your internal logic, testing the actual argument parsing is vital to ensure you haven’t broken the user interface.
We use the crate assert_cmd (part of the assert_cli family) for integration testing.
Add this to [dev-dependencies] in Cargo.toml:
[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.0" # specific version matching assert_cmd requirementsCreate a test file tests/cli_tests.rs:
use assert_cmd::Command;
use predicates::prelude::*;
#[test]
fn scan_fails_without_arguments() {
let mut cmd = Command::cargo_bin("log-sift").unwrap();
cmd.assert()
.failure()
.stderr(predicate::str::contains("Usage: log-sift"));
}
#[test]
fn stats_format_validation() {
let mut cmd = Command::cargo_bin("log-sift").unwrap();
cmd.arg("stats")
.arg("logfile.log")
.arg("--format")
.arg("xml") // Invalid format
.assert()
.failure()
.stderr(predicate::str::contains("possible values: text, json, yaml"));
}Running cargo test now compiles your binary and runs it like a real user would, capturing stdout and stderr for verification.
Performance & Best Practices for 2025 #
When distributing your tool, size and speed matter.
- Strip Symbols: In your
Cargo.toml, add a release profile to reduce binary size significantly.[profile.release] strip = true # Automatically strip symbols from the binary. opt-level = "z" # Optimize for size. lto = true codegen-units = 1 - Laziness: If your CLI has hundreds of flags, initialization can take milliseconds.
clapis highly optimized, but avoid doing heavy IO (like reading config files) before parsing arguments. Letclapparse first, then read files based on the result. - Use
std::io::stdout().lock(): If your CLI prints massive amounts of text (likecatorls), locking stdout prevents the mutex overhead on every line, speeding up output by 10x-100x.
Conclusion #
The transition from structopt to the integrated clap v4 Derive API has unified the Rust CLI ecosystem. It provides the perfect balance: the type safety and declarative nature of StructOpt, with the raw power and maintenance backing of the main Clap project.
By using Enums for subcommands, ValueEnum for strict options, and assert_cmd for testing, you elevate your tool from a “hacky script” to a professional product ready for distribution.
Key Takeaways:
- Use
clapwith the"derive"feature. - Model your CLI structure using
structsandenums. - Leverage
ValueEnumto prevent invalid inputs. - Always integration test your CLI commands.
Now, go build the next ripgrep!
Found this article helpful? Subscribe to Rust DevPro for more deep dives into systems programming, async Rust, and performance optimization.