Tutorial

How to Build Rust Clap Subcommands

Build Rust Clap subcommands so each has its own args, and the derive keeps the types separate and the help organized.

On this page

  1. Introduction
  2. Derive the enum
  3. Add per-command args
  4. Match in main
  5. Generate help
  6. Handle errors
  7. Test subcommands
  8. Common pitfalls
  9. Conclusion

Introduction

The hardest part of typed subcommands is not writing the code — it is deciding what the code is allowed to reject. A Clap subcommands setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in Clap subcommands, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.

A Clap subcommands guard for typed subcommands should be proportional to the risk. A ten-field form does not need a hundred-line schema; it needs the five checks that catch the five mistakes the users actually make. Ship the five, measure the rejections, and add the sixth only when the traffic proves it exists. Speculative generality is the bug you write on purpose, and Clap subcommands makes it easy to write, which is why discipline matters.

The sections below walk through the concrete decisions: where to put the clap subcommands check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.

Derive the enum

Derive the enum is the first decision because it determines what the rest of the Clap subcommands pipeline can assume. A wrong choice here turns into typed subcommands under load, and the invariant you thought Clap subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Derive the enum is the one most teams skip: drive clap subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

#[derive(Subcommand)]
enum Cmd {
  Build { #[arg(short, long)] watch: bool },
  Deploy { #[arg(long)] env: String },
}
match cli.command {
  Cmd::Build { watch } => build(watch),
  Cmd::Deploy { env } => deploy(env),
}

The rust above is the shape to ship for typed subcommands. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.

Add per-command args

The mechanism behind Add per-command args is straightforward in Clap subcommands, but the consequences ripple. A wrong choice here turns into typed subcommands under load, and the invariant you thought Clap subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Add per-command args is the one most teams skip: drive clap subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Match in main

Match in main is where most Clap subcommands implementations leak performance. A wrong choice here turns into typed subcommands under load, and the invariant you thought Clap subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Match in main is the one most teams skip: drive clap subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

#[test]
fn parses_deploy() {
  let cli = Cli::parse_from(["", "deploy", "--env", "prod"]);
  assert!(matches!(cli.command, Cmd::Deploy { .. }));
}

Pin the behavior with this test. A Clap subcommands setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.

Generate help

The hard part of Generate help is not the happy path — it is what happens when clap subcommands fails halfway. A wrong choice here turns into typed subcommands under load, and the invariant you thought Clap subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Generate help is the one most teams skip: drive clap subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Handle errors

Handle errors should be observable without a debugger. A wrong choice here turns into typed subcommands under load, and the invariant you thought Clap subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Handle errors is the one most teams skip: drive clap subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Test subcommands

Test subcommands is a trade, not a winner. A wrong choice here turns into typed subcommands under load, and the invariant you thought Clap subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Test subcommands is the one most teams skip: drive clap subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Common pitfalls

The common pitfalls in typed subcommands all reduce to one mistake: treating Clap subcommands as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.

  • Skipping the clap subcommands boundary so typed subcommands enters unvalidated.
  • Assuming Clap subcommands is fast without measuring the tail latency under load.
  • Letting typed subcommands fail silently instead of failing loudly with a metric.
  • Testing only the happy path so the clap subcommands failure surfaces in production.
  • Sharing mutable state across requests so one bad input poisons the next.

Conclusion

Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: Clap subcommands fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.

Self-check: write a test that drives clap subcommands to the failing condition and assert the system degrades gracefully. Then typed subcommands is a property you maintain, not a feature you hope for.

Sources: Clap subcommands, Rust CLI

FAQ

Why an enum?
Clap derives from the enum. Each variant is a subcommand with typed args.
Nested subcommands?
Yes, nest enums. Clap supports arbitrary depth.

Related Examples