Most CLI tools start life as a tangle of if statements and hand-rolled argument parsing. In Rust, you can define your entire command structure as a type-safe enum and let the compiler generate everything else. This post walks through how broll, a terminal session recorder, defines 12 subcommands in about 120 lines of declarative code using clap's derive macros.
What Are Derive Macros?
Rust's procedural macros can inspect your struct or enum at compile time and generate additional code. When you write #[derive(Parser)] on a struct, clap's macro reads your field names, types, and attributes, then generates a full argument parser: help text, flag handling, validation, and type conversion.
You never see the generated code in your source files. But at compile time, your simple struct becomes a complete CLI parser with --help output, error messages, and shell completions.
The Top-Level Parser
broll's entire CLI definition lives in src/cli/mod.rs. The entry point is a two-field struct:
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "broll", about = "Terminal session recorder with searchable output")]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Parser)] generates the Cli::parse() method, which reads std::env::args(), validates them, and returns a populated Cli instance or exits with an error. The #[command(subcommand)] attribute tells clap that the command field is dispatched as a subcommand (like broll start, broll search, etc.).
Enums with Data: The Command Type
Here is where things get interesting. Each variant of the Command enum is a subcommand, and its fields become that subcommand's arguments:
#[derive(Subcommand)]
pub enum Command {
/// Start recording a new session (spawns a sub-shell)
Start {
/// Give the session a name for easier identification and lookup
#[arg(short, long)]
name: Option<String>,
/// Tag the session for easier lookup
#[arg(short, long)]
tag: Option<String>,
/// Disable sensitive content filtering
#[arg(long, default_value_t = false)]
no_filter: bool,
/// Working directory for the session (defaults to current directory)
#[arg(short, long)]
dir: Option<PathBuf>,
},
/// Stop the current recording session
Stop,
/// Delete one or more recorded sessions and all their data
Delete {
/// Session IDs, prefixes, or names
#[arg(required = true)]
ids: Vec<String>,
/// Skip confirmation prompt
#[arg(short, long, default_value_t = false)]
force: bool,
},
/// View a recorded session (opens TUI)
View {
/// Session ID, prefix, or name
id: String,
},
// ... 8 more variants
}
Several Rust concepts work together here:
Option<T> vs T for required vs. optional. A field typed Option<String> becomes an optional flag. A field typed String is required. clap infers this from the type alone. In View, the id: String field is a required positional argument. In Start, name: Option<String> becomes --name <VALUE> which can be omitted.
Vec<T> for variadic arguments. The Delete variant accepts ids: Vec<String>, meaning the user can pass one or more session IDs: broll delete abc123 def456. The #[arg(required = true)] attribute ensures at least one value is provided.
PathBuf for file paths. clap automatically converts string arguments into PathBuf values, giving you a typed path without manual conversion.
Doc comments as help text. Every /// comment becomes the description shown in --help. No separate help strings to maintain.
Pattern Matching for Routing
The entire main.rs is a match expression that dispatches each variant to the right module:
use anyhow::Result;
use clap::Parser;
use cli::{Cli, Command};
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Start { name, tag, group, no_filter, dir } => {
recorder::start_session(name, tag, group, no_filter, dir)?;
}
Command::Stop => {
recorder::stop_session()?;
}
Command::List { group } => {
storage::list_sessions(group)?;
}
Command::Search { query, group, terminal } => {
tui::search::run(query, group, terminal)?;
}
Command::View { id } => {
tui::view::run(&id)?;
}
Command::Delete { ids, force } => {
storage::delete_sessions(&ids, force)?;
}
Command::Stats => {
storage::show_stats()?;
}
// ... remaining variants
}
Ok(())
}
Rust's match is exhaustive: the compiler will refuse to compile if you add a new Command variant without handling it here. This is a powerful guarantee. You cannot add a subcommand and forget to wire it up.
Notice how the match arms destructure each variant. Command::Start { name, tag, group, no_filter, dir } extracts all five fields directly into local variables. No .name() getters, no downcasting.
Error Handling with ? and anyhow
The return type Result<()> uses anyhow::Result, which wraps any error type into a single anyhow::Error. The ? operator at the end of each function call does two things:
- If the call returns
Ok(value), it unwraps the value and continues. - If it returns
Err(e), it immediately returns frommainwith that error.
This replaces what would otherwise be nested match statements or .unwrap() calls. The anyhow crate adds context methods too. In the recorder module, you will find patterns like:
let pair = pty_system
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.context("Failed to open PTY")?;
The .context() method wraps the underlying error with a human-readable message, so if PTY creation fails, the user sees "Failed to open PTY" followed by the lower-level cause.
A Standalone Example
Here is a minimal but working CLI using the same patterns. You can try this with cargo new mycli and adding clap = { version = "4", features = ["derive"] } and anyhow = "1" to your Cargo.toml:
use anyhow::Result;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "tasks", about = "A tiny task manager")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Add a new task
Add {
/// Task description
description: String,
/// Priority level (1-5)
#[arg(short, long, default_value_t = 3)]
priority: u8,
},
/// List all tasks
List {
/// Show only high-priority tasks
#[arg(short, long)]
important: bool,
},
/// Remove tasks by ID
Remove {
#[arg(required = true)]
ids: Vec<u32>,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Add { description, priority } => {
println!("Added task (priority {}): {}", priority, description);
}
Command::List { important } => {
if important {
println!("Showing high-priority tasks only");
} else {
println!("Showing all tasks");
}
}
Command::Remove { ids } => {
println!("Removing {} task(s)", ids.len());
}
}
Ok(())
}
Running cargo run -- add "Write blog post" --priority 5 prints Added task (priority 5): Write blog post. Running cargo run -- --help gives you auto-generated help for every subcommand.
What clap Generates Behind the Scenes
When you write #[derive(Parser)] on Cli, clap generates an implementation that:
- Creates a
clap::Commandwith the name"broll"and the about text. - Iterates over each enum variant to register subcommands.
- Maps
Option<String>fields to optional flags,Stringto required positional arguments,boolto boolean flags, andVec<String>to variadic positionals. - Extracts
shortandlongflag names from the attribute or field name. - Uses doc comments as the help description.
All of this happens at compile time. There is no runtime reflection, no dynamic dispatch, and no allocation for the parser itself. The resulting binary parses arguments as fast as hand-written code would.
Key Takeaways
- Derive macros turn data definitions into behavior. Your enum is both the type system representation and the CLI specification.
-
Option<T>models optionality at the type level. No null checks, no sentinel values. - Exhaustive match forces you to handle every case. Adding a variant without a handler is a compile error.
-
The
?operator keeps error handling concise. Combined withanyhow, you get rich error context without boilerplate. - You get help text, validation, and shell completions for free. The doc comments you write for your own understanding become user-facing documentation.
broll's 12 subcommands, each with their own flags and positional arguments, fit in 123 lines of declarative Rust. The routing logic in main.rs is another 55. No framework, no code generation step, no runtime overhead.
Try it out:
Top comments (0)