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
Appstruct withArc-wrapped fields. ToolMetadata+ReleaseSource+HelpChannelwithbon::Builderandserde::Deserialize/Serialize.VersionInfobuilder chain +is_development+from_env()helper.Featureenum +Featuresimmutable set +FeaturesBuilder.Commandasync trait +CommandSpecdescriptor.BUILTIN_COMMANDSlinkme::distributed_sliceof command factories.
Deferred to later versions¶
App<C: AppConfig>generic parametrisation on user config type. Blocked onrtb-configv0.1 shipping a genericConfig<C>. See O1 below.App::run()/Application::builder()— lives inrtb-cli.ArgMatchesthreaded toCommand::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.Initialisertrait +BUILTIN_INITIALISERSslice — associated with theinitsubcommand, ships withrtb-cliv0.1.HealthChecktrait — ships withrtb-cli'sdoctorcommand.
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 fullApplicationwiring — 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-referentialdev-dependenciesfeature. Cloneis derived. Every field is reference-counted, so clones are O(1) and command handlers takeAppby value.shutdownis aCancellationToken; 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+summaryare required at compile time (typestate viabon::Builder). Missing either is a compile error.- The struct is
Serialize + Deserializeso it can round-trip through a.rtb/manifest.tomlfile.
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:
None→NoneSlack { 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>,
}
&'staticthroughout — commands are compile-time entities.featureoptionally gates the command behind a runtimeFeature;rtb-clifiltersBUILTIN_COMMANDSbyFeatures::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 —
AppisSend + Sync + Clone(compile-time bound assert). - T2 —
App::clone()shares Arcs. Construct viafor_testing; assertArc::ptr_eq(&orig.metadata, &clone.metadata)and likewise forversion,config,assets. - T3 —
App::shutdownchild token cascades.parent.cancel(); assert!(child.is_cancelled())wherechild = app.shutdown.child_token(). - T4 —
ToolMetadata::builder()requiresnameandsummary.trybuildfixture calling.build()without.name(…)fails to compile. - T5 —
ToolMetadataserde round-trip.to_string→from_strpreserves every field. - T6 —
ReleaseSource::Githubdefault host. Deserialising{ type: github, owner: o, repo: r }yieldshost = "github.com". - T7 —
ReleaseSource::Gitlabdefault host. Same forgitlab.com. - T8 —
HelpChannel::footerformats. One assertion per variant matching §3.5. - T9 —
VersionInfo::new+with_commit+with_datereturns a fully-populated instance. - T10 —
VersionInfo::is_development— table-driven:0.1.0,0.0.0,1.0.0-alpha,1.2.3-dev.5all true;1.0.0,2.3.4false. - T11 —
VersionInfo::from_env— compiles and returns a validVersionfromenv!("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_COMMANDSregistration from test binary. Register a test-only command; assert it appears in the slice. - T15 —
CommandSpecisClone+Debug. - T16 —
Command::spec()returns a reference with the'staticlifetime (compile-time via dispatch through aBox<dyn Command>). - T17 —
#[non_exhaustive]onFeature— trybuild fixture. - T18 —
#[non_exhaustive]onReleaseSource— trybuild fixture.
4.2 Gherkin scenarios (S#)¶
Feature file: crates/rtb-app/tests/features/core.feature.
- S1 — Minimal
ToolMetadatabuilt with just name + summary renders a sensibleDebugrepresentation. - S2 —
ToolMetadataround-trips through YAML preserving release source host defaults. - S3 —
Featuresruntime gating — starting from defaults, disabling Update and enabling Ai produces the expected enabled set. - S4 —
HelpChannel::Slackfooter reads naturally as user-facing support text. - S5 — Registering a command into
BUILTIN_COMMANDSmakes it observable in the slice. - S6 —
VersionInfo::is_developmenttable-driven, same cases as T10. - S7 —
Command::runreturnsmiette::Result— a test command that returnsErr(miette::miette!("nope"))surfaces the diagnostic through the normal?path. - S8 —
ReleaseSource::Githubdeserialises 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::Resultwhere appropriate; constructors cannot fail. - No runtime I/O in
rtb-app.from_envis the only env read and falls back silently. - No logging.
tracingis not a dep of rtb-app. - Serde deserialisation is
#[serde(deny_unknown_fields)]onToolMetadata,ReleaseSource,HelpChannelto catch typos in user config early.
6. Non-goals (explicit)¶
- No async in this crate beyond the
Commandtrait signature — no executors spawned, no tokio primitives used exceptCancellationToken. - No clap dep in rtb-app. Clap-specific types live in rtb-cli.
- No concrete
Commandimplementations. The trait is defined; impls land in rtb-cli (built-in commands) and downstream tools. - No
App::new(). Construction viartb-cli::Application::builderonly (orfor_testingin tests).
7. Rollout plan¶
- Land the spec + Gherkin + failing tests + API stubs in one
test(core)commit, keeping the tree green. - Implement the filled-in API in a
feat(core)commit. - Update
rtb-cli/src/lib.rsto re-exportrtb_app::prelude::*(follow-up, not in scope for this rollout).
8. Open questions¶
-
O1 — When do we make
Appgeneric over the user config type? The framework-level spec calls forApp<C: AppConfig = ()>.Configitself is a stub inrtb-config. Proposed resolution: ship v0.1 ofrtb-appnon-generic, transition to generic in a minor bump simultaneously withrtb-configv0.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::runtake&clap::ArgMatches? The framework-level spec shows commands receiving bothAppand&ArgMatches.ArgMatchesrequires 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 traitCommandArgscan providefn apply(&mut self, matches: &ArgMatches)if needed. Leaning: keep minimal for v0.1. -
O3 —
BUILTIN_COMMANDSvs a user-facing registration API.linkmedistributed slices work for most platforms but are unusable on iOS and some wasm targets. Fallback? Proposed resolution: shiplinkme-only for v0.1; addinventory-based fallback behind aplugin-inventoryCargo feature in a later version when a concrete target demands it. -
O4 —
HelpChannel::footerformat. The examples in §3.5 are user-visible copy. Do they feel right? Happy to adjust before implementation lands.