DEV Community

Rodrigo Mello
Rodrigo Mello

Posted on

Building a 12-Command CLI in 120 Lines with clap Derive Macros

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,
}
Enter fullscreen mode Exit fullscreen mode

#[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
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. If the call returns Ok(value), it unwraps the value and continues.
  2. If it returns Err(e), it immediately returns from main with 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")?;
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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::Command with the name "broll" and the about text.
  • Iterates over each enum variant to register subcommands.
  • Maps Option<String> fields to optional flags, String to required positional arguments, bool to boolean flags, and Vec<String> to variadic positionals.
  • Extracts short and long flag 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

  1. Derive macros turn data definitions into behavior. Your enum is both the type system representation and the CLI specification.
  2. Option<T> models optionality at the type level. No null checks, no sentinel values.
  3. Exhaustive match forces you to handle every case. Adding a variant without a handler is a compile error.
  4. The ? operator keeps error handling concise. Combined with anyhow, you get rich error context without boilerplate.
  5. 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)