Skip to content

rtb-cli v0.1 — Application scaffolding and built-in commands

Status: IMPLEMENTED — closes the 0.1 workspace milestone. 10 unit + 5 BDD + 2 trybuild acceptance criteria all green. Target crate: rtb-cli Feeds: every downstream RTB-based tool. Parent contract: §8 of the framework spec and §3.5.


1. Motivation

rtb-cli is the entry point every downstream tool touches. It:

  • wires clap to rtb_app::command::BUILTIN_COMMANDS,
  • installs tracing-subscriber, miette hooks (via rtb-error), and tokio signal handlers,
  • owns App construction so downstream main() stays a one-liner,
  • provides built-in version, config, doctor, init commands and placeholders for update/docs/mcp until those crates ship.

rtb-cli is the first crate where user-facing UX lives. Its surface goals: one-liner main(), typestate-enforced required metadata, errors rendered uniformly through the edge hook installed by rtb-error.

2. Scope boundaries

In scope for v0.1

  • Application struct + typestate builder — required: metadata, version; optional: features, commands, tracing layers, assets.
  • Application::run() async entry — returns miette::Result<()>. Parses CLI, filters commands by runtime Features, dispatches.
  • Tracing registry: pretty fmt on TTY, JSON off-TTY or when --log-format json; RUST_LOG respected.
  • Error pipeline wiring: rtb_error::hook::install_report_handler, install_panic_hook, install_with_footer sourced from ToolMetadata::help.footer().
  • Signal binding: tokio ctrl_c + Unix SIGTERM cancel App::shutdown.
  • Built-in commands (registered via linkme slice):
  • version — prints semver / commit / date / rustc target.
  • doctor — runs registered HealthCheck trait objects + reports.
  • init — runs registered Initialiser trait objects (wizard TBD → inline prompt for v0.1).
  • config — (subtree) config show, config get <jsonpath>.
  • Feature-gated stubs for update, docs, mcp: registered when their runtime Feature is enabled; their run returns rtb_error::Error::FeatureDisabled(...) pointing at the Cargo feature name. Real impls land with each crate's own v0.1.

Deferred

  • update real implementation (→ rtb-update v0.1).
  • docs TUI browser (→ rtb-docs v0.1).
  • mcp server (→ rtb-mcp v0.1).
  • config set, config schema, config validate — needs richer rtb-config API.
  • JSON envelope output (--output json) — requires per-command DTO design; deferred to 0.2.
  • #[rtb::command] attribute macro — commands in 0.1 are hand-written impl Command for … with inline CommandSpec. Macro lands once patterns stabilise.
  • Subcommand nesting beyond one level.

3. Public API

3.1 Crate root

pub mod application;
pub mod builtins;
pub mod health;
pub mod init;
pub mod runtime;

pub use application::Application;
pub use health::{HealthCheck, HealthReport, HealthStatus};
pub use init::Initialiser;

/// Re-exports for downstream tool `fn main()` convenience.
pub mod prelude {
    pub use rtb_app::prelude::*;
    pub use rtb_error::{Error as RtbError, Result as RtbResult};
    pub use crate::application::Application;
}

3.2 Application + ApplicationBuilder

pub struct Application {
    /* owns the constructed App, clap command tree, and a handle into
       rtb_app's BUILTIN_COMMANDS filtered by Features */
}

impl Application {
    /// Start a new builder. `metadata` and `version` are required
    /// before `.build()` compiles — enforced by the `bon` typestate.
    pub fn builder() -> ApplicationBuilder;
}

impl Application {
    /// Parse `std::env::args_os()`, dispatch, return.
    pub async fn run(self) -> miette::Result<()>;

    /// Programmatic dispatch — for tests.
    pub async fn run_with_args<I, S>(self, args: I) -> miette::Result<()>
    where
        I: IntoIterator<Item = S>,
        S: Into<std::ffi::OsString> + Clone;
}

#[must_use]
pub struct ApplicationBuilder { /* typestate — generated by bon */ }

impl ApplicationBuilder {
    pub fn metadata(self, m: ToolMetadata) -> Self;     // required
    pub fn version(self, v: VersionInfo) -> Self;       // required
    pub fn assets(self, a: Assets) -> Self;             // optional
    pub fn features(self, f: Features) -> Self;         // optional (defaults)
    pub fn install_hooks(self, yes: bool) -> Self;      // optional (default true)
    pub fn build(self) -> miette::Result<Application>;
}

Note: we do not use bon::Builder macro here because the builder owns non-Default inputs and gains custom validation at build(). v0.1 uses a hand-rolled typestate pattern; bon may be revisited later.

3.3 HealthCheck + reporting

#[async_trait::async_trait]
pub trait HealthCheck: Send + Sync + 'static {
    fn name(&self) -> &'static str;
    async fn check(&self, app: &App) -> HealthStatus;
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HealthStatus {
    Ok { summary: String },
    Warn { summary: String },
    Fail { summary: String },
}

pub struct HealthReport {
    pub entries: Vec<(&'static str, HealthStatus)>,
}

impl HealthReport {
    pub fn is_ok(&self) -> bool;
    pub fn render(&self) -> String;
}

Downstream tools register checks via a linkme distributed slice:

#[distributed_slice(rtb_cli::health::HEALTH_CHECKS)]
fn register() -> Box<dyn HealthCheck> { Box::new(MyCheck) }

Built-in checks for v0.1: config-present (warns if no user config file loaded), no-literal-credentials (warns if any credential field is a literal in config — placeholder hook; teeth added with rtb-credentials v0.1).

3.4 Initialiser

#[async_trait::async_trait]
pub trait Initialiser: Send + Sync + 'static {
    fn name(&self) -> &'static str;
    async fn is_configured(&self, app: &App) -> bool;
    async fn configure(&self, app: &App) -> miette::Result<()>;
}

Registered via #[distributed_slice(rtb_cli::init::INITIALISERS)]. init iterates the slice, prints what it's doing, and skips anything is_configured already reports true for unless --force.

3.5 Built-in commands

All implement rtb_app::command::Command and register into rtb_app::command::BUILTIN_COMMANDS:

  • version — always visible when Feature::Version enabled (default). Outputs semver, commit (if known), build date, rustc target triple.
  • doctor — when Feature::Doctor enabled. Iterates HEALTH_CHECKS, renders a summary table, exits non-zero if any check reports Fail.
  • init — when Feature::Init enabled. Iterates INITIALISERS.
  • config show — when Feature::Config enabled (default off for v0.1 — opt-in via Features::builder().enable(Config)). Dumps the currently-resolved config as YAML.

Feature-gated placeholders (real impls in later crate versions):

  • updateFeature::Update enabled by default; command returns Error::FeatureDisabled("update") in v0.1.
  • docsFeature::Docs enabled by default; stub.
  • mcpFeature::Mcp enabled by default; stub.

Downstream tools that don't want the stubs visible can Features::builder().disable(Feature::Update)….

4. Acceptance criteria

4.1 Unit tests (T#)

  • T1 — Application::builder typestate. Omitting .metadata(…) or .version(…) is a compile error. trybuild fixtures.
  • T2 — ApplicationBuilder::build() returns miette::Result<Application> and succeeds for a minimal valid input.
  • T3 — Application::run_with_args(["tool", "version"]) prints the version info (captured via a custom tracing writer) and exits with Ok(()).
  • T4 — run_with_args(["tool", "version", "--commit"]) prints the commit SHA if populated, or empty line otherwise.
  • T5 — Unknown subcommand returns rtb_error::Error::CommandNotFound.
  • T6 — Disabled feature hides the command. Disabling Feature::Update via Features omits update from the clap tree; invoking update returns CommandNotFound.
  • T7 — Feature-disabled stub. With Feature::Update enabled but the real rtb-update not wired, invoking update returns Error::FeatureDisabled("update").
  • T8 — doctor aggregates HEALTH_CHECKS. Register a test check that returns Fail; doctor exits non-zero.
  • T9 — init iterates INITIALISERS. Register a test initialiser that records a call; init triggers it.
  • T10 — install_hooks(false) skips hook installationmiette::set_hook not called (verified by counting a test sentinel).
  • T11 — signal wiring. Cancelling the shutdown token externally causes run_with_args to surface a cancellation path cleanly (without crashing). Hard to assert without forking a thread — instead, assert App::shutdown is a fresh token at start.
  • T12 — config show dumps the merged config as YAML when the Config feature is enabled.

4.2 Gherkin scenarios (S#)

File: crates/rtb-cli/tests/features/cli.feature.

  • S1 — version prints semver.
  • S2 — Unknown command emits a CommandNotFound diagnostic.
  • S3 — doctor with all-OK checks exits zero and names every check.
  • S4 — doctor with a failing check exits non-zero.
  • S5 — Disabling the Update feature hides update from --help.
  • S6 — The Update placeholder returns a FeatureDisabled diagnostic pointing at the Cargo feature name.
  • S7 — init runs a registered initialiser.
  • S8 — --help lists every enabled built-in.

4.3 Testing aids

  • All tests use Application::run_with_args so nothing touches std::env::args.
  • App::for_testing from rtb-app provides the context; we do not construct a real App from Application::builder in unit tests (too much incidental setup). Instead, unit tests drive individual command run impls directly.
  • HEALTH_CHECKS and INITIALISERS use linkme::distributed_slice; test binaries register fixtures at module scope.

5. Security & operational requirements

  • #![forbid(unsafe_code)].
  • install_hooks(true) (default) idempotently installs the miette pipeline so subsequent errors render through the framework.
  • run_with_args never logs secrets — redaction is deferred to rtb-telemetry, but no command introduced here emits secrets.
  • No blocking operations on the async runtime — every built-in uses tokio::task::spawn_blocking if it would block (none do in v0.1).

6. Non-goals (explicit)

  • No macro attribute (#[rtb::command]) yet.
  • No --output json envelope.
  • No subcommand-level middleware / middleware chains.
  • No signal-specific behaviour beyond cancelling shutdown (e.g. SIGHUP for config reload is a v0.2 concern tied to rtb-config's deferred hot-reload).

7. Rollout plan

  1. Land spec + tests + impl in one feat(cli) commit.
  2. examples/minimal updates to use Application::builder() — bundled into the same commit so the example stays live.
  3. Subsequent v0.1 commits for rtb-update, rtb-docs, rtb-mcp replace their stub dispatch functions with real impls by simply registering a new Command in their own #[distributed_slice] — no rtb-cli changes needed.

8. Open questions

  • O1 — Should Application::run_with_args accept an AsyncWrite sink for test-capturing stdout? Or is a tracing with_writer enough? Proposed: tracing for logs, and commands write their user-facing output via tracing::info! rather than println! so tests can capture both uniformly. version and config show follow this pattern.
  • O2 — doctor failure exit code. Go Tool Base returns 1. Proposed: same.
  • O3 — Command name parameter to Error::FeatureDisabled — currently &'static str. For v0.1 stubs we pass the Cargo feature name (e.g. "update"). Leaning: keep &'static str.
  • O4 — config show output format. YAML for v0.1 (matches the embedded-default format). JSON added when --output json lands.