Skip to content

rtb-app v0.1 — Application context, metadata, and command registry

Status: IMPLEMENTED — spec, tests, and implementation landed in the same commit (acceptance-package already green on first run). Target crate: rtb-app Feeds: rtb-cli, rtb-update, rtb-vcs, rtb-docs, rtb-ai, every built-in command — all import the App context and Command trait. Parent contract: §3 of the framework spec.


1. Motivation

rtb-app defines the shapes that every other rtb-* crate types against:

  • Application context — what a command handler receives.
  • Tool metadata — name, summary, release-source, support channel.
  • Version info — build-time semver + commit + date.
  • Feature gating — runtime on/off for built-in subcommands.
  • Command trait + registry — the contract commands implement and the link-time slice they self-register into.

The crate is deliberately light — no I/O, no clap, no tokio tasks. It owns types and traits. Construction, parsing, and execution all live in consumer crates (rtb-cli primarily).

2. Scope boundaries (explicit)

In scope for v0.1

  • Concrete, non-generic App struct with Arc-wrapped fields.
  • ToolMetadata + ReleaseSource + HelpChannel with bon::Builder and serde::Deserialize/Serialize.
  • VersionInfo builder chain + is_development + from_env() helper.
  • Feature enum + Features immutable set + FeaturesBuilder.
  • Command async trait + CommandSpec descriptor.
  • BUILTIN_COMMANDS linkme::distributed_slice of command factories.

Deferred to later versions

  • App<C: AppConfig> generic parametrisation on user config type. Blocked on rtb-config v0.1 shipping a generic Config<C>. See O1 below.
  • App::run() / Application::builder() — lives in rtb-cli.
  • ArgMatches threaded to Command::run — commands in v0.1 express args via their own struct fields; passing raw clap matches is an opt-in sidecar we can add via a second trait when a real use case arises.
  • Initialiser trait + BUILTIN_INITIALISERS slice — associated with the init subcommand, ships with rtb-cli v0.1.
  • HealthCheck trait — ships with rtb-cli's doctor command.

3. Public API

3.1 Crate root

pub mod app;
pub mod command;
pub mod features;
pub mod metadata;
pub mod version;

pub use linkme;  // re-exported so downstream #[distributed_slice] users
                 // don't need to add linkme directly.

pub mod prelude {
    pub use crate::app::App;
    pub use crate::command::{Command, CommandSpec, BUILTIN_COMMANDS};
    pub use crate::features::{Feature, Features};
    pub use crate::metadata::{HelpChannel, ReleaseSource, ToolMetadata};
    pub use crate::version::VersionInfo;
}

3.2 App

#[derive(Clone)]
pub struct App {
    pub metadata: Arc<ToolMetadata>,
    pub version:  Arc<VersionInfo>,
    pub config:   Arc<rtb_config::Config>,
    pub assets:   Arc<rtb_assets::Assets>,
    pub shutdown: tokio_util::sync::CancellationToken,
}

Normative:

  • Production construction happens via rtb-cli::Application::builder. A #[doc(hidden)] App::for_testing(metadata, version) helper exists for integration tests that don't want the full Application wiring — it's hidden from rustdoc so it doesn't appear as public API, but it isn't feature-gated because integration-test binaries can't cleanly opt into a self-referential dev-dependencies feature.
  • Clone is derived. Every field is reference-counted, so clones are O(1) and command handlers take App by value.
  • shutdown is a CancellationToken; children created via .child_token() cascade from a parent cancellation.

3.3 ToolMetadata

#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
pub struct ToolMetadata {
    #[builder(into)]                       pub name: String,
    #[builder(into)]                       pub summary: String,
    #[builder(into, default)]              pub description: String,
    #[builder(default)]                    pub release_source: Option<ReleaseSource>,
    #[builder(default)]                    pub help: HelpChannel,
}
  • name + summary are required at compile time (typestate via bon::Builder). Missing either is a compile error.
  • The struct is Serialize + Deserialize so it can round-trip through a .rtb/manifest.toml file.

3.4 ReleaseSource

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ReleaseSource {
    Github { owner: String, repo: String, #[serde(default = "…")] host: String },
    Gitlab { project: String,              #[serde(default = "…")] host: String },
    Direct { url_template: String },
}

host defaults to github.com / gitlab.com via #[serde(default)] so minimal TOML round-trips work. Parsing { "type": "github", "owner": "me", "repo": "it" } fills the host automatically.

3.5 HelpChannel

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum HelpChannel {
    #[default] None,
    Slack { team: String, channel: String },
    Teams { team: String, channel: String },
    Url   { url: String },
}

impl HelpChannel {
    /// The one-line footer shown under error diagnostics.
    pub fn footer(&self) -> Option<String>;
}

footer() produces the string rtb-cli installs into rtb_error::hook::install_with_footer. Formats:

  • NoneNone
  • Slack { team, channel }"support: slack #{channel} (in {team})"
  • Teams { team, channel }"support: Teams → {team} / {channel}"
  • Url { url }"support: {url}"

3.6 VersionInfo

impl VersionInfo {
    pub const fn new(version: semver::Version) -> Self;
    pub fn with_commit(self, commit: impl Into<String>) -> Self;
    pub fn with_date(self, date: impl Into<String>) -> Self;
    pub fn is_development(&self) -> bool;

    /// Convenience: parse CARGO_PKG_VERSION + fall back to "0.0.0" if
    /// mis-set. Use inside `fn main()` when wiring your `Application`.
    pub fn from_env() -> Self;
}

is_development: - major == 0, or - pre-release is non-empty (-alpha, -dev.1, etc.), or - the parsed version is exactly 0.0.0 (from a failed from_env).

3.7 Feature + Features + FeaturesBuilder

Already stubbed in features.rs — v0.1 documents the variants, makes Feature::defaults() return a Features (not a raw HashSet), and adds Features::all() for debug introspection.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Feature { Init, Version, Update, Docs, Mcp, Doctor, Ai, Telemetry, Config, Changelog }

impl Features {
    pub fn is_enabled(&self, f: Feature) -> bool;
    pub fn builder() -> FeaturesBuilder;
    pub fn iter(&self) -> impl Iterator<Item = Feature> + '_;
}

impl FeaturesBuilder {
    pub fn new() -> Self;           // defaults populated
    pub fn none() -> Self;          // empty set
    pub fn enable(self, f: Feature) -> Self;
    pub fn disable(self, f: Feature) -> Self;
    pub fn build(self) -> Features;
}

3.8 Command trait + CommandSpec

#[async_trait::async_trait]
pub trait Command: Send + Sync + 'static {
    fn spec(&self) -> &CommandSpec;
    async fn run(&self, app: App) -> miette::Result<()>;
}

#[derive(Debug, Clone)]
pub struct CommandSpec {
    pub name:    &'static str,
    pub about:   &'static str,
    pub aliases: &'static [&'static str],
    pub feature: Option<Feature>,
}
  • &'static throughout — commands are compile-time entities.
  • feature optionally gates the command behind a runtime Feature; rtb-cli filters BUILTIN_COMMANDS by Features::is_enabled.

3.9 BUILTIN_COMMANDS

use linkme::distributed_slice;

#[distributed_slice]
pub static BUILTIN_COMMANDS: [fn() -> Box<dyn Command>];

Downstream registration pattern (used by rtb-cli, built-in command crates, and downstream tools):

use rtb_app::command::{BUILTIN_COMMANDS, Command};
use rtb_app::linkme::distributed_slice;

#[distributed_slice(BUILTIN_COMMANDS)]
fn register() -> Box<dyn Command> { Box::new(MyCommand) }

Link-time, zero-cost, no life-before-main. rtb-cli::Application::run materialises BUILTIN_COMMANDS into the clap tree at startup.

4. Acceptance criteria

4.1 Unit tests (T#)

  • T1 — App is Send + Sync + Clone (compile-time bound assert).
  • T2 — App::clone() shares Arcs. Construct via for_testing; assert Arc::ptr_eq(&orig.metadata, &clone.metadata) and likewise for version, config, assets.
  • T3 — App::shutdown child token cascades. parent.cancel(); assert!(child.is_cancelled()) where child = app.shutdown.child_token().
  • T4 — ToolMetadata::builder() requires name and summary. trybuild fixture calling .build() without .name(…) fails to compile.
  • T5 — ToolMetadata serde round-trip. to_stringfrom_str preserves every field.
  • T6 — ReleaseSource::Github default host. Deserialising { type: github, owner: o, repo: r } yields host = "github.com".
  • T7 — ReleaseSource::Gitlab default host. Same for gitlab.com.
  • T8 — HelpChannel::footer formats. One assertion per variant matching §3.5.
  • T9 — VersionInfo::new + with_commit + with_date returns a fully-populated instance.
  • T10 — VersionInfo::is_development — table-driven: 0.1.0, 0.0.0, 1.0.0-alpha, 1.2.3-dev.5 all true; 1.0.0, 2.3.4 false.
  • T11 — VersionInfo::from_env — compiles and returns a valid Version from env!("CARGO_PKG_VERSION").
  • T12 — Features::default() equals the documented defaults. Init/Version/Update/Docs/Mcp/Doctor enabled; Ai/Telemetry/Config/ Changelog disabled.
  • T13 — Features::builder().disable(Update).build() returns a set without Update, preserving the other defaults.
  • T14 — BUILTIN_COMMANDS registration from test binary. Register a test-only command; assert it appears in the slice.
  • T15 — CommandSpec is Clone + Debug.
  • T16 — Command::spec() returns a reference with the 'static lifetime (compile-time via dispatch through a Box<dyn Command>).
  • T17 — #[non_exhaustive] on Feature — trybuild fixture.
  • T18 — #[non_exhaustive] on ReleaseSource — trybuild fixture.

4.2 Gherkin scenarios (S#)

Feature file: crates/rtb-app/tests/features/core.feature.

  • S1 — Minimal ToolMetadata built with just name + summary renders a sensible Debug representation.
  • S2 — ToolMetadata round-trips through YAML preserving release source host defaults.
  • S3 — Features runtime gating — starting from defaults, disabling Update and enabling Ai produces the expected enabled set.
  • S4 — HelpChannel::Slack footer reads naturally as user-facing support text.
  • S5 — Registering a command into BUILTIN_COMMANDS makes it observable in the slice.
  • S6 — VersionInfo::is_development table-driven, same cases as T10.
  • S7 — Command::run returns miette::Result — a test command that returns Err(miette::miette!("nope")) surfaces the diagnostic through the normal ? path.
  • S8 — ReleaseSource::Github deserialises from YAML with and without an explicit host.

5. Security & operational requirements

  • #![forbid(unsafe_code)] at crate root.
  • No panic in any public fn. Error paths use miette::Result where appropriate; constructors cannot fail.
  • No runtime I/O in rtb-app. from_env is the only env read and falls back silently.
  • No logging. tracing is not a dep of rtb-app.
  • Serde deserialisation is #[serde(deny_unknown_fields)] on ToolMetadata, ReleaseSource, HelpChannel to catch typos in user config early.

6. Non-goals (explicit)

  • No async in this crate beyond the Command trait signature — no executors spawned, no tokio primitives used except CancellationToken.
  • No clap dep in rtb-app. Clap-specific types live in rtb-cli.
  • No concrete Command implementations. The trait is defined; impls land in rtb-cli (built-in commands) and downstream tools.
  • No App::new(). Construction via rtb-cli::Application::builder only (or for_testing in tests).

7. Rollout plan

  1. Land the spec + Gherkin + failing tests + API stubs in one test(core) commit, keeping the tree green.
  2. Implement the filled-in API in a feat(core) commit.
  3. Update rtb-cli/src/lib.rs to re-export rtb_app::prelude::* (follow-up, not in scope for this rollout).

8. Open questions

  • O1 — When do we make App generic over the user config type? The framework-level spec calls for App<C: AppConfig = ()>. Config itself is a stub in rtb-config. Proposed resolution: ship v0.1 of rtb-app non-generic, transition to generic in a minor bump simultaneously with rtb-config v0.1. That bump will be a breaking API change to rtb-app; because we are pre-1.0, a minor bump is sufficient per CLAUDE.md § API Stability.

  • O2 — Should Command::run take &clap::ArgMatches? The framework-level spec shows commands receiving both App and &ArgMatches. ArgMatches requires a clap dep in rtb-app. For v0.1 we omit it — commands express args via their own struct fields, parsed by clap in rtb-cli. A future sidecar trait CommandArgs can provide fn apply(&mut self, matches: &ArgMatches) if needed. Leaning: keep minimal for v0.1.

  • O3 — BUILTIN_COMMANDS vs a user-facing registration API. linkme distributed slices work for most platforms but are unusable on iOS and some wasm targets. Fallback? Proposed resolution: ship linkme-only for v0.1; add inventory-based fallback behind a plugin-inventory Cargo feature in a later version when a concrete target demands it.

  • O4 — HelpChannel::footer format. The examples in §3.5 are user-visible copy. Do they feel right? Happy to adjust before implementation lands.