Skip to content

How the application is assembled

A tool built on this family has a main() that names its metadata and its version and then hands over. Everything else — the command tree, the error rendering, the signal handling — is assembled from parts that registered themselves. This page explains why it is put together that way.

Why the family is four crates and not one

rtb-cli is the entry point. rtb-docs, rtb-update and rtb-mcp each contribute one command and a body of machinery behind it: a TUI and a search index, a signature-verified download flow, an MCP protocol server.

Splitting them means a tool that does not want an MCP server does not compile rmcp, and a tool that does not ship embedded docs does not compile tantivy or ratatui. Those are large dependencies to carry for a command you never expose.

They share one version line because they are coupled by design: the three satellites register into registries rtb-cli reads, and rtb-cli dispatches commands they define. Version skew across that boundary would produce a tool missing a command with no error to explain it. One version line makes the coupling explicit instead of implicit.

A command is an impl Command plus a #[distributed_slice(BUILTIN_COMMANDS)] factory function. Nothing lists commands anywhere. Application::build iterates the slice, filters by the runtime Features set, deduplicates by name, sorts, and hands the result to clap.

The alternative — a central registry the tool author edits — makes adding a command a two-file change, and makes a crate that contributes a command impossible without the tool author knowing about it. Link-time registration is what lets rtb-update add update to a tool that only wrote use rtb_update as _;.

The cost is the one thing that surprises people: a crate nobody references is not linked, so its registrations never happen and its command silently does not exist. There is no error, because from the linker's point of view nothing is missing. That is the price of the mechanism, and it is why the CLI reference says so plainly.

Why last-in-slice-order wins a name collision

Two commands can register the same name. Application::build keeps the last one in slice order, which matches the intuition that a downstream crate's real command overrides a framework stub.

But linker slice order is not stable across compiler versions or dependency-graph changes. So "last wins" is a deterministic rule applied to a non-deterministic order, and it is only reliable when exactly one of the colliding registrations is yours. Commands are then sorted by name before clap sees them, so --help output is stable regardless.

Why the builder is hand-rolled typestate

ApplicationBuilder<M, V> carries two phantom-typed parameters. metadata() is only callable while M = NoMetadata, version() only while V = NoVersion, and build() only when both are set. Omitting either is a compile error, not a runtime one, and two trybuild fixtures pin that.

A derive macro could produce most of this. It was not used because build() needs custom work — defaulting the feature set, defaulting the asset overlay, assembling the App, attaching typed config if it was supplied — and expressing that through a macro's escape hatches is harder to read than the phantom markers are.

The same pattern appears again in UpdaterBuilder<AppMarker, ProviderMarker>, for the same reason: app and provider are both genuinely required.

Why clap lives only in rtb-cli

rtb-app defines Command, CommandSpec and the registries, and it does not depend on clap. rtb-cli is where the clap tree is built from those descriptors.

That boundary is what makes a different argument parser possible. A tool that wants argh or bpaf writes its own equivalent of rtb-cli against the same rtb-app contracts, and every command written for the framework still works — because a command never sees a clap::ArgMatches. It receives an App.

The consequence is that CommandSpec is descriptor-shaped rather than parser-shaped: a name, an about line, aliases, a short flag, a long about. Anything richer belongs to the command's own parser, which is what passthrough exists for.

What runs before your command, and why in that order

Application::run_with_args installs the diagnostic handler and panic hook first, so that a failure anywhere later renders through the same pipeline instead of a raw panic message. Tracing comes next, then signal binding, then the parse, then pre-run hooks, then dispatch.

Two details follow from that ordering:

  • Hooks do not fire for --help. Clap short-circuits the parse, so nothing after step 6 happens. An automatic update check never delays a help request.
  • install_tracing is Once-gated. A second call is a no-op, which keeps a test process that builds several applications from panicking on a duplicate global subscriber.

install_hooks(false) opts out of the first three steps. It exists for tests: the miette hooks are process-global and set-once, and a test process that installs them pollutes every other test in the same binary.