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, so 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¶
- CLI reference: the tree it builds.
- How the application is assembled: why it is built this way.
- Add a command
- API reference on docs.rs
- Source