Here’s a pattern came up with for building CLI tools in Rust using Clap with derive and using a custom trait. First, define a “run command” trait that all commands will use.

#[async_trait::async_trait]
pub trait RunCommand {
    async fn run(&self) -> anyhow::Result<()>;
}

I’m using async-trait so my commands can be async, and anyhow for error handling because I don’t particularly care about the types of the errors my commands might return. Neither of these are required though, skip them if you prefer.

The “trick” is to then implement your RunCommand trait for all the commands and subcommands for your Clap parser. This is just how traits work, but what’s useful is that it makes it really easy to organize your commands and subcommands into different folders while keeping the definition of the command next to the implementation of that command.

For example, imagine a program with commands like:

myprogram query --some-params
myprogram account create --other-params
myprogram account delete --even-more-params

I’d recommend organizing the code into a folder structure like this:

.
├── account
│  ├── create.rs
│  ├── delete.rs
│  └── mod.rs
├── mod.rs
├── query.rs
└── run_command.rs  <-- the RunCommand trait is defined here

These files will then look like this:

account/create.rs:
#[derive(Debug, Args)]
pub struct AccountCreate { /* params */ }

#[async_trait::async_trait]
impl RunCommand for AccountCreate {
    async fn run(&self) -> anyhow::Result<()> {
        // Your command implementation here!
    }
}
account/delete.rs

Omitted for brevity, same as create

account/mod.rs:
#[derive(Debug, Subcommand)]
pub enum AccountCommands {
    Create(AccountCreate),
    Delete(AccountDelete),
}

#[async_trait]
impl RunCommand for AccountCommands {
    async fn run(&self) -> anyhow::Result<()> {
        match self {
            Self::Create(cmd) => cmd.run().await,
            Self::Delete(cmd) => cmd.run().await,
        }
    }
}

#[derive(Debug, Args)]
pub struct Account {
    #[command(subcommand)]
    command: AccountCommands,
}

#[async_trait]
impl RunCommand for Account {
    async fn run(&self) -> anyhow::Result<()> {
        self.command.run().await
    }
}
query.rs:
#[derive(Debug, Args)]
pub struct QueryCommand { /* params */ }

#[async_trait::async_trait]
impl RunCommand for QueryCommand {
    async fn run(&self) -> anyhow::Result<()> {
        // Your command implementation here!
    }
}
mod.rs

Finally tying it all together:

#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
    #[command(subcommand)]
    command: Option<Commands>,
    // Any global options that apply to all commands go below!
    #[arg(short, long)]
    pub verbosity: Option<String>,
}

#[async_trait::async_trait]
impl RunCommand for Cli {
    async fn run(&self) -> anyhow::Result<()> {
        if let Some(command) = &self.command {
            command.run().await?;
            exit(0);
        } else {
            Ok(())
        }
    }
}

#[derive(Debug, Subcommand)]
pub enum Commands {
    Account(Account),
    Query(QueryCommand),
}

#[async_trait::async_trait]
impl RunCommand for Commands {
    async fn run(&self) -> anyhow::Result<()> {
        match self {
            Commands::Account(account) => account.run().await,
            Commands::Query(query) => query.run().await,
        }
    }
}

Now, in main all you have to do is call your trait:

let cli = Cli::parse();
cli.run().await.unwrap();

I love the filesystem-routing like feel of this. Whenever you see a command in the CLI, you immediately know what file to go to if you need to change or fix something. Please don’t discount the value of this! As your application grows, it can be a massive headache to come across a bug and then having to dig around the entire codebase to find where that code is coming from. If all commands have a particular file, you can always start your search from that file and use “go to definition” to dig into the implementation. This also helps if you are adding a new command, it’s immediately obvious where the code needs to go. Simple but elegant, and a significant productivity boost.