Write your first command¶
By the end of this you'll have a crate that describes a tool, implements one command,
registers that command with the framework, and has two passing tests proving it. About
twenty minutes, most of which is the first cargo test compiling the dependency tree.
What you need before you start¶
- Rust 1.82 or newer.
rustc --versionwill tell you. - A network connection for the first build. It pulls roughly forty crates and takes around thirty seconds on a warm cache, a few minutes cold.
You do not need rtb-cli for this. That is the crate that turns registered
commands into a working binary with argument parsing, and it is deliberately not part
of this tutorial — everything here is testable without it. The last section says what
changes when you add it.
Create the crate¶
A library, not a binary. Commands live in libraries; the binary that ties them together comes later.
Add the dependencies¶
Put this in Cargo.toml:
[dependencies]
rtb-app = "0.9"
linkme = "0.3"
async-trait = "0.1"
miette = "7"
[dev-dependencies]
rtb-test-support = "0.9"
tokio = { version = "1", features = ["macros", "rt"] }
linkme looks redundant — rtb-app already depends on it and re-exports it. It
isn't. The registration attribute you'll use in a moment expands to absolute
::linkme::… paths, which only resolve if linkme is a direct dependency of your
crate. Leave it out and you get cannot find linkme in the crate root from a line
that mentions no linkme at all.
rtb-test-support goes under [dev-dependencies]. That's what stops a production
build reaching the test-only App constructor.
Describe the tool¶
Replace src/lib.rs with this much to start:
use rtb_app::prelude::*;
/// The tool's static identity.
pub fn metadata() -> ToolMetadata {
ToolMetadata::builder()
.name("greeter")
.summary("says hello to whoever is running it")
.help(HelpChannel::Url { url: "https://example.com/greeter/support".into() })
.build()
}
name and summary are the only required fields, and they're required at compile
time — the builder uses a typestate, so leaving one out is a type error rather than a
runtime panic. Try deleting .summary(...) and running cargo check if you want to
see it.
help is optional. It's here because it's the field that most changes what users see:
it puts support: https://example.com/greeter/support under every error diagnostic
the tool prints.
Write the command¶
Add this below the metadata function:
pub struct Greet;
#[async_trait::async_trait]
impl Command for Greet {
fn spec(&self) -> &CommandSpec {
static SPEC: CommandSpec = CommandSpec {
name: "greet",
about: "Print a greeting",
..CommandSpec::DEFAULT
};
&SPEC
}
async fn run(&self, app: App) -> miette::Result<()> {
println!("hello from {} v{}", app.metadata.name, app.version.version);
Ok(())
}
}
Two things worth noticing.
The spec is a static, and every field on it is 'static. Commands are compile-time
entities — there's no way to build one from a runtime value.
And ..CommandSpec::DEFAULT matters more than it looks. CommandSpec has six fields;
naming all six compiles today and breaks the next time an optional one is added. That
has already happened once, when short and long_about arrived in 0.7.0, so
published examples written the long way now fail with missing fields long_about and
short.
Register it¶
Add this import at the top:
and this at the bottom:
#[distributed_slice(BUILTIN_COMMANDS)]
fn __register_greet() -> Box<dyn Command> {
Box::new(Greet)
}
That's the whole registration. There's no list to add yourself to and no register()
call to remember — BUILTIN_COMMANDS is a link-time slice, and the attribute puts
your factory into it while the binary is being linked.
The flip side: nothing tells you when it didn't work. Forget the attribute and the
code still compiles, still passes any test that calls Greet::run directly, and the
command simply never appears. That's why the next step writes a test for it.
Prove the command registered¶
Create tests/greet.rs:
use greeter::Greet;
use rtb_app::command::{Command, BUILTIN_COMMANDS};
use rtb_test_support::{TestAppBuilder, TestWitness};
#[test]
fn greet_is_registered() {
let names: Vec<&str> = BUILTIN_COMMANDS.iter().map(|f| f().spec().name).collect();
assert!(names.contains(&"greet"), "got: {names:?}");
}
BUILTIN_COMMANDS holds factories, not commands, so f() builds one and .spec()
reads its descriptor.
Run the command in a test¶
Add this to the same file:
#[tokio::test]
async fn greet_runs() {
let app = TestAppBuilder::new(TestWitness::new()).tool("greeter", "0.1.0").build();
Greet.run(app).await.expect("greet should succeed");
}
TestAppBuilder gives you an App without the logging, error-hook and signal wiring
that a real run installs. tool("greeter", "0.1.0") sets the name and version
together — note it also fixes summary to the literal "test", so don't assert on
the summary after using it.
The TestWitness is a zero-sized value proving the calling crate depends on
rtb-test-support. It's why this builder can't be reached from a crate that only
depends on rtb-app.
Run the tests¶
The first run compiles the dependency tree. You should see:
running 2 tests
test greet_is_registered ... ok
test greet_runs ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
To see the greeting itself, pass --nocapture:
v0.1.0 is the version TestAppBuilder::tool was given, not the crate's own. In a
real tool you'd capture the crate's version with rtb_app::version_info!() — and
specifically not with VersionInfo::from_env(), which is deprecated because it
reports the framework's version instead of yours.
What you have, and what you don't¶
You have a command the framework can find, and tests that fail if it stops being findable.
You do not have a runnable CLI. There's no greeter binary yet, no --help, and
nothing parses arguments — rtb-app deliberately contains no clap and no argument
handling at all. Building the binary that reads BUILTIN_COMMANDS, filters it by the
runtime feature set and hands it to clap is
rtb-cli's job.
Where to go next¶
- Register a command — aliases, short flags, hiding a command behind a runtime feature, and taking arguments the outer parser shouldn't touch.
- Report your tool's own version — do this before you ship anything that self-updates.
- Tool metadata reference — every field on
ToolMetadataand what it defaults to. - Why registration happens at link time — what the distributed slice buys and what it costs.