Skip to content

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 --version will 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

cargo new --lib greeter
cd greeter

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:

use linkme::distributed_slice;

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

cargo test

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:

cargo test --test greet -- --nocapture
running 2 tests
test greet_is_registered ... ok
hello from greeter v0.1.0
test greet_runs ... ok

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