How to Add Subcommands to a Rust CLI with clap

Add subcommands to a Rust CLI by defining a Subcommand enum and matching on the parsed result.

When flags get out of hand

You built a CLI tool. It started with a single job: mytool process input.txt. Then you added --output, --verbose, and --format. Now you need a completely different mode. You want to list files, remove entries, and configure settings. Adding --list, --remove, and --config flags turns your help text into a wall of text. Users have to memorize which flags go with which mode. The interface is becoming unmanageable.

Subcommands solve this. They partition your tool into distinct verbs. mytool list, mytool remove, mytool config. Each subcommand has its own set of arguments and flags. The user sees a clean menu. clap makes subcommands feel like defining a tree structure. You write an enum, derive a trait, and attach it to your parser. The library handles the routing, help generation, and error messages.

Subcommands as a hierarchy

Think of a Swiss Army knife. The handle is your binary. The blade, the screwdriver, and the file are subcommands. You don't use them all at once. You select one tool to perform a specific action. Each tool has its own shape and purpose. In CLI terms, the main program is the handle. git commit, git push, and git log are the tools. They share the binary but have different arguments. commit takes a message. push takes a remote. log takes a range.

clap models this hierarchy using Rust enums. An enum represents a value that can be one of several variants. Each variant maps to a subcommand. The fields inside a variant map to the arguments for that subcommand. This matches the mental model of a CLI perfectly. You pick a command, then you provide the data that command needs. The compiler enforces that you handle every command in your main function. You can't forget a case.

Minimal example

Start with the derive macros. Parser generates the argument definition for your CLI struct. Subcommand generates the subcommand definition for your enum. You link them with the #[command(subcommand)] attribute.

use clap::{Parser, Subcommand};

/// A simple tool with subcommands
#[derive(Parser)]
#[command(name = "mytool")]
struct Cli {
    /// The subcommand to execute
    #[command(subcommand)]
    command: Commands,
}

/// Available subcommands
#[derive(Subcommand)]
enum Commands {
    /// Add a new item
    Add {
        /// Name of the item to add
        name: String,
    },
    /// Remove an existing item
    Remove {
        /// Name of the item to remove
        name: String,
    },
}

fn main() {
    // Parse arguments from std::env::args()
    let cli = Cli::parse();

    // Route to the appropriate handler
    match cli.command {
        Commands::Add { name } => println!("Adding {name}"),
        Commands::Remove { name } => println!("Removing {name}"),
    }
}

The #[command(subcommand)] attribute tells clap that the command field holds the subcommand enum. Without this attribute, clap treats the field as a regular argument and fails to parse subcommands. The #[derive(Subcommand)] macro inspects the enum variants and generates code to match command-line tokens to variants.

What happens under the hood

When you call Cli::parse(), the generated code reads std::env::args(). It skips the first argument, which is the program name. It looks at the second argument. If the second argument is add, it matches Commands::Add. It then consumes the remaining arguments and assigns them to the name field. If the second argument is remove, it matches Commands::Remove. If the second argument is unknown, clap prints an error message and exits. If there is no second argument, clap prints the help text and exits.

The derive macros do the heavy work at compile time. Parser generates a clap::Command builder call that defines the structure. Subcommand generates the logic to parse the subcommand variant. The runtime cost is minimal. clap uses a fast parser. The generated code is optimized by the compiler. You get the safety of enums and the ergonomics of a builder pattern without writing the boilerplate.

If you forget #[derive(Subcommand)] on the enum, the compiler rejects you with E0277 (trait bound not satisfied). The Parser derive expects the field to implement Subcommand, and a plain enum doesn't. The error message points to the field definition. Add the derive and the error disappears.

Realistic example with nested commands

Real tools often have deeper hierarchies. git remote add has two levels. remote is a subcommand. add is a sub-subcommand. clap supports nesting by putting #[command(subcommand)] inside a variant. You can recurse as deep as needed.

Global flags apply to all subcommands. Use #[command(global = true)] for flags like --verbose or --color. These flags can appear before or after the subcommand. mytool --verbose add foo and mytool add --verbose foo both work.

use clap::{Parser, Subcommand};

/// A package manager with nested subcommands
#[derive(Parser)]
#[command(name = "pkg", version, about = "Manage packages")]
struct Cli {
    /// Enable verbose output for all commands
    #[command(global = true)]
    verbose: bool,

    /// The subcommand to execute
    #[command(subcommand)]
    command: Commands,
}

/// Top-level subcommands
#[derive(Subcommand)]
enum Commands {
    /// Install a package
    Install {
        /// Package name
        package: String,
    },
    /// Manage remotes
    Remote {
        /// Remote subcommands
        #[command(subcommand)]
        command: RemoteCommands,
    },
}

/// Subcommands for the 'remote' command
#[derive(Subcommand)]
enum RemoteCommands {
    /// Add a remote
    Add {
        /// Remote name
        name: String,
        /// Remote URL
        url: String,
    },
    /// Remove a remote
    Remove {
        /// Remote name
        name: String,
    },
}

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

    // Handle global flags first
    if cli.verbose {
        println!("Verbose mode enabled");
    }

    // Route to top-level commands
    match cli.command {
        Commands::Install { package } => {
            println!("Installing {package}");
        }
        Commands::Remote { command } => {
            // Route to nested commands
            match command {
                RemoteCommands::Add { name, url } => {
                    println!("Adding remote {name} at {url}");
                }
                RemoteCommands::Remove { name } => {
                    println!("Removing remote {name}");
                }
            }
        }
    }
}

The #[command(global = true)] attribute makes verbose available everywhere. clap generates help text that shows global flags in the main section and subcommand flags in their respective sections. The help output stays organized. Users see pkg remote --help and get documentation for add and remove without noise from install.

Convention aside: use #[command(...)] for attributes. Older versions of clap used #[clap(...)]. The community migrated to command to match the derive macro name Parser. #[clap(...)] still works as an alias, but new code uses command. Stick with command to match the ecosystem.

Pitfalls and validation

Subcommands enforce syntax, not semantics. clap ensures the user provided a string for name. It does not check if the name is valid for your application. You handle semantic validation in the match arms. If name must be alphanumeric, check that after parsing. Return an error or exit with a non-zero code.

Optional subcommands require Option. If you want mytool to do something useful without a subcommand, wrap the enum in Option. #[command(subcommand)] on an Option<Commands> makes the subcommand optional. If the user runs mytool, cli.command is None. You can provide a default action.

If you use Commands directly, mytool fails when run without arguments. clap prints help and exits with error code 1. This is usually the desired behavior. Tools like git require a subcommand. Tools like cargo have a default action. Choose based on your tool's purpose.

Another common issue is argument conflicts. clap detects conflicts automatically. If two subcommands share a flag name, clap isolates them. Flags are scoped to their subcommand. You don't need to worry about collisions. If you want a flag to be mutually exclusive with another flag in the same subcommand, use #[command(args_conflicts_with_subcommands = true)] or ArgGroup. Keep it simple first. Subcommands usually eliminate the need for complex conflict rules.

Treat the enum as your API contract. If you add a new subcommand, the compiler forces you to update the match arm. You can't ship a binary that crashes on a new command. This safety is one of the strongest benefits of the derive approach.

Decision: when to use subcommands

Use subcommands when the tool performs distinct actions that have their own sets of arguments. git commit and git push don't share arguments. They share the binary. Subcommands keep the interface clean and the help text readable.

Use flags when the action is a modifier to a single core operation. ls -la is one action with modifiers. ls doesn't have subcommands like ls list or ls show. Flags work best when the user always does the same thing but tweaks the behavior.

Use positional arguments when the input is the primary focus and the action is implied or simple. cat file.txt. The file is the argument. cat has no subcommands. Positional args shine when the verb is obvious from context.

Use Option<Commands> when the binary has a default mode that runs without a subcommand. cargo runs tests by default but accepts cargo build. The option allows the user to omit the subcommand for the common case.

Structure follows semantics. If the user thinks in verbs, give them subcommands. If the user thinks in options, give them flags. Match the CLI structure to the mental model of the task.

Where to go next