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.
Why commands register themselves at link time¶
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_tracingisOnce-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.
Related¶
- Argument parsing — how a command owns its own subtree.
- Crates, features and registries — the four registries in table form.
- What this family does not do.