Skip to content

rtb-cli

The crate a downstream tool's main() touches. It turns ToolMetadata and VersionInfo into a running application: a clap command tree, a diagnostic pipeline, a tracing subscriber, signal handling, and dispatch.

What main() looks like

use rtb_cli::prelude::*;

#[tokio::main]
async fn main() -> std::process::ExitCode {
    let app = Application::builder()
        .metadata(ToolMetadata::builder().name("mytool").summary("a tool").build())
        .version(rtb_app::version_info!())
        .build();

    match app {
        Ok(app) => app.run_and_exit().await,
        Err(report) => rtb_cli::report_to_exit_code(&report),
    }
}

run_and_exit is the boundary that renders a failure through the installed diagnostic handler and maps it to a process exit code, honouring rtb_error::WithExitCode. report_to_exit_code is exported so a builder error — raised before run_and_exit is reachable — takes the same path.

A main returning miette::Result<()> also works and is shorter. It gives up the custom exit code: Rust's Termination impl always exits 1 on error.

The six built-ins it implements itself

version, doctor, init, config, credentials and telemetry. The other three commands in a full build (docs, update, mcp) come from their own crates.

Each is gated on a runtime Feature, and each is registered through the same BUILTIN_COMMANDS slice a downstream command uses — there is no privileged registration path. That is what makes replacing one a matter of registering your own command with the same name.

The extension points

You want You register into Consumed by
A new subcommand rtb_app::command::BUILTIN_COMMANDS The clap tree
A diagnostic check rtb_cli::health::HEALTH_CHECKS doctor
A first-run setup step rtb_cli::init::INITIALISERS init
Something that runs before every command rtb_app::command::BUILTIN_PRERUN_HOOKS Application::run_with_args

All four are linkme distributed slices, so a crate contributes to them without the tool author wiring anything — and does not contribute at all if nothing references it. See Crates, features and registries.

Why HealthCheck and Initialiser are separate traits

They look similar — both are async, both take an &App, both are collected from a slice. But they answer different questions and fail differently.

HealthCheck answers "is this working?" and has three outcomes: Ok, Warn and Fail. It never changes anything, Warn is a first-class result rather than a soft failure, and doctor runs every check regardless of what the others reported.

Initialiser answers "is this set up, and if not, set it up". It is expected to mutate: write a config file, prompt for a credential. init skips anything already configured, and the first failure stops the run — there is no point configuring step four when step two did not take.

Collapsing them would mean either a health check that can write to your disk or a setup step that reports Warn and carries on.

Testing hooks

run_with_args(iter) dispatches from a supplied argument list instead of std::env::args_os(), so a test drives the whole application in-process. install_hooks(false) suppresses the miette report and panic hooks, which are process-global and set-once — a test binary that installs them affects every other test in the same process.

Together they are why the crate's own suite can run cucumber scenarios against a real Application without spawning a subprocess.

Where to go next