rtb-app¶
rtb-app is the structural heart of the framework. It defines:
App— the cheap-to-clone application context threaded through every command handler.ToolMetadata— static name, summary, release source, support channel.VersionInfo— build-time semver + optional commit- date.
Features— runtime gating for built-in commands.Command— async trait every subcommand implements, plus theBUILTIN_COMMANDSlinkmedistributed slice that collects them at link time.
The crate is deliberately light: no I/O, no clap, no tokio tasks
spawned. Construction, parsing, and execution all live in consumer
crates (rtb-cli primarily).
Overview¶
Go Tool Base's Props struct is a heterogeneous bag of services.
rtb-app::App is the Rust-idiomatic counterpart: typed fields,
Arc-wrapped for cheap cloning, no Box<dyn Any> container
anywhere. Downstream tools don't register services at runtime —
they construct an App with the services they want and pass it to
handlers explicitly.
Design rationale¶
- Type-erased typed config (since 0.4.1). The framework spec
called for
App<C: AppConfig>so commands could access typed config. Rather than makeAppgeneric (which would force everyCommandimpl through adyn-incompatible boundary), v0.4.1 stores the config type-erased and exposesApp::typed_config::<C>() -> Option<Arc<Config<C>>>andApp::config_as::<C>() -> Arc<Config<C>>(panicking) as the recovery seam. See the v0.4.1 scope addendum §3 for the option-(a) rationale. linkmeover runtime registration.BUILTIN_COMMANDSis a#[distributed_slice]populated at link time. No life-before-main, no mutex-guarded registry, no per-commandArc<Mutex<...>>. It does come with a caveat: callers needlinkmeas a direct dep (see Link-time registration).bon::BuilderforToolMetadata. Required fields enforced at compile time via typestate; missing fields are type errors.
Core types¶
App¶
#[derive(Clone)]
pub struct App {
pub metadata: Arc<ToolMetadata>,
pub version: Arc<VersionInfo>,
// Type-erased since 0.4.1: reach the typed handle through
// `typed_config::<C>()` or `config_as::<C>()`. Stored as
// `Arc<dyn Any + Send + Sync>` so `Arc::downcast` recovers the
// typed `Arc<Config<C>>` sharing the same backing allocation.
pub(crate) config: ErasedConfig,
pub assets: Arc<Assets>,
pub shutdown: CancellationToken,
pub credentials_provider: Option<Arc<dyn CredentialProvider>>, // since 0.4.0
pub(crate) typed_config_ops: Option<Arc<TypedConfigOps>>, // since 0.4.1
}
impl App {
/// Typed access to the wired configuration. `Some` when
/// `Application::builder().config(...)` was called with a
/// `Config<C>`; `None` otherwise.
pub fn typed_config<C>(&self) -> Option<Arc<Config<C>>>
where C: serde::de::DeserializeOwned + Send + Sync + 'static; // since 0.4.1
/// Same as `typed_config`, but panics with a diagnostic naming
/// the requested type when no matching typed config is wired.
/// Surfaces the call-site location via `#[track_caller]`.
pub fn config_as<C>(&self) -> Arc<Config<C>>
where C: serde::de::DeserializeOwned + Send + Sync + 'static; // since 0.4.1
/// JSON Schema for the wired typed config. `None` when no
/// typed-config ops were attached. Drives `config schema /
/// validate` in `rtb-cli`.
pub fn config_schema(&self) -> Option<&serde_json::Value>; // since 0.4.1
/// Merged typed-config value rendered as a `serde_json::Value`.
/// Drives `config show / get` in `rtb-cli`.
pub fn config_value(&self) -> Option<serde_json::Value>; // since 0.4.1
/// Yield the configured credentials. Returns `Vec::new()` when
/// no provider has been wired — `credentials list` reports the
/// empty set, which is the right thing for a tool that hasn't
/// declared any credentials yet.
pub fn credentials(&self) -> Vec<(String, CredentialRef)>; // since 0.4.0
}
Every field is Arc-wrapped. App::clone() is O(1) — refcount
increments, no deep copy. Command handlers take App by value; fan-out
.clone()s freely across tokio::spawn.
No public constructor
Production construction happens via rtb_cli::Application::builder.
An App::for_testing(metadata, version) helper exists for tests
within this crate (and is available to downstream tests via the
rtb-test-support crate's TestAppBuilder,
which is the promoted path).
See also: App context concept page.
ToolMetadata¶
#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
#[serde(deny_unknown_fields)]
pub struct ToolMetadata {
pub name: String, // required
pub summary: String, // required
pub description: String, // optional
pub release_source: Option<ReleaseSource>, // optional
pub release_credential: Option<CredentialRef>, // optional
pub help: HelpChannel, // optional
pub update_public_keys: Vec<[u8; 32]>, // optional
pub update_checksums_asset: Option<&'static str>, // optional
pub update_asset_pattern: Option<&'static str>, // optional
pub telemetry_notice: Option<&'static str>, // optional, since 0.4.0
}
name and summary are required by the bon::Builder typestate —
omitting either is a compile error (trybuild fixture in the test
suite proves this). release_source is required only when
Feature::Update is runtime-enabled; missing it when update runs
yields a runtime diagnostic. telemetry_notice is read by
rtb-cli's v0.4 telemetry enable subcommand to print a
tool-specific privacy notice; None falls back to a generic line.
ReleaseSource¶
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
#[non_exhaustive]
pub enum ReleaseSource {
Github { owner: String, repo: String, host: String },
Gitlab { project: String, host: String },
Direct { url_template: String },
}
host defaults to github.com / gitlab.com so minimal configs
round-trip cleanly.
HelpChannel¶
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
#[non_exhaustive]
pub enum HelpChannel {
#[default]
None,
Slack { team: String, channel: String },
Teams { team: String, channel: String },
Url { url: String },
}
impl HelpChannel {
pub fn footer(&self) -> Option<String>;
}
HelpChannel::footer() is what rtb-cli feeds to
rtb_error::hook::install_with_footer. Sample renders:
| Variant | Output |
|---|---|
Slack { "platform", "cli-tools" } |
support: slack #cli-tools (in platform) |
Teams { "SRE", "oncall" } |
support: Teams → SRE / oncall |
Url { "https://support.example.com" } |
support: https://support.example.com |
None |
(no footer) |
VersionInfo¶
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionInfo {
pub version: semver::Version,
pub commit: Option<String>,
pub date: Option<String>,
}
impl VersionInfo {
pub const fn new(version: Version) -> Self;
pub fn with_commit(self, commit: impl Into<String>) -> Self;
pub fn with_date(self, date: impl Into<String>) -> Self;
pub fn from_env() -> Self; // reads CARGO_PKG_VERSION
pub fn is_development(&self) -> bool; // pre-release or major == 0
}
Feature¶
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Feature {
Init, Version, Update, Docs, Mcp, Doctor,
Ai, Telemetry, Config, Changelog,
Credentials, // since 0.4.0
}
impl Feature {
pub fn defaults() -> Features;
pub const fn all() -> &'static [Self];
}
The default-enabled set is Init, Version, Update, Docs,
Mcp, Doctor, Credentials, Telemetry, Config.
Credentials, Telemetry, and Config all joined the default
set in 0.4.0 alongside the new credentials, telemetry, and
extended config subtrees in rtb-cli.
all() returns a slice, not an array
Feature is #[non_exhaustive]; returning [Self; N] from
all() would mean every new variant is a breaking API change
(the array size is part of the type). Returning &'static [Self]
keeps the length a value rather than a type parameter.
Features¶
pub struct Features { /* ... */ }
impl Features {
pub fn builder() -> FeaturesBuilder;
pub fn is_enabled(&self, feature: Feature) -> bool;
pub fn iter(&self) -> impl Iterator<Item = Feature> + '_;
}
pub struct FeaturesBuilder { /* ... */ }
impl FeaturesBuilder {
pub fn new() -> Self; // defaults pre-populated
pub fn none() -> Self; // empty set
pub fn enable(self, Feature) -> Self;
pub fn disable(self, Feature) -> Self;
pub fn build(self) -> Features;
}
Default-enabled: Init, Version, Update, Docs, Mcp, Doctor.
Opt-in: Ai, Telemetry, Config, Changelog.
Runtime vs compile-time features
Cargo features (on the rtb umbrella) decide what's compiled in.
Runtime Features decide what's visible to users for this
invocation. The two are orthogonal: a command compiled in but
runtime-disabled returns CommandNotFound; a command not
compiled in doesn't register into BUILTIN_COMMANDS at all.
Command¶
#[async_trait::async_trait]
pub trait Command: Send + Sync + 'static {
fn spec(&self) -> &CommandSpec;
async fn run(&self, app: App) -> miette::Result<()>;
/// `true` → the outer clap parser passes every arg after `<name>`
/// straight to `run`. Commands that own their own clap subtree
/// (e.g. `docs`, `update`, `mcp`) opt in. Default `false`.
fn subcommand_passthrough(&self) -> bool { false }
/// `true` → registered as an MCP tool by `rtb_mcp::McpServer`.
/// Default `false`. See [MCP exposure](../concepts/mcp-exposure.md).
fn mcp_exposed(&self) -> bool { false }
/// JSON Schema for the command's arguments — surfaced to MCP
/// clients via `tools/list`. Default `None`. Authors with a
/// `clap::Args` struct typically derive this from
/// `serde_json::to_value(schemars::schema_for!(MyArgs))`.
fn mcp_input_schema(&self) -> Option<serde_json::Value> { None }
}
#[derive(Debug, Clone)]
pub struct CommandSpec {
pub name: &'static str,
pub about: &'static str,
pub aliases: &'static [&'static str],
pub feature: Option<Feature>, // runtime-gated when Some
}
Every field on CommandSpec is 'static — commands are compile-time
entities. feature: None means unconditionally visible. The four
default trait methods (subcommand_passthrough, mcp_exposed,
mcp_input_schema, plus run/spec which are required) are
additive: existing impls inherit safe defaults and don't need to
change when new opt-ins are added.
BUILTIN_COMMANDS¶
use linkme::distributed_slice;
#[distributed_slice]
pub static BUILTIN_COMMANDS: [fn() -> Box<dyn Command>];
Link-time registry of every Command the framework should offer.
rtb-cli::Application::build iterates this slice, filters by the
runtime Features, deduplicates by name (last-in-slice-order wins),
and installs into the clap tree.
Link-time registration¶
Downstream crates register into BUILTIN_COMMANDS via the linkme
attribute macro:
use rtb_app::command::{BUILTIN_COMMANDS, Command, CommandSpec};
use linkme::distributed_slice;
pub struct MyCommand;
#[async_trait::async_trait]
impl Command for MyCommand {
fn spec(&self) -> &CommandSpec {
static SPEC: CommandSpec = CommandSpec {
name: "my-cmd",
about: "do the thing",
aliases: &[],
feature: None,
};
&SPEC
}
async fn run(&self, app: App) -> miette::Result<()> { /* ... */ }
}
#[distributed_slice(BUILTIN_COMMANDS)]
fn __register_my_cmd() -> Box<dyn Command> { Box::new(MyCommand) }
linkme must be a direct dependency
The #[distributed_slice] attribute expands to ::linkme::...
paths, so every consumer crate needs linkme = { workspace = true }
in its own Cargo.toml. Re-exporting through
rtb_app::linkme is not sufficient.
For library-level replaceability, a downstream crate can override a
built-in command by registering a Command with the same name. The
deduplication in Application::build keeps the last entry in slice
order — so a downstream tool can ship its own version (or any other
built-in) and the framework's default falls away.
API surface¶
| Item | Kind | Since |
|---|---|---|
App |
struct | 0.1.0 |
App::for_testing |
fn (#[doc(hidden)]) |
0.1.0 |
App::new |
fn (public constructor) | 0.4.1 |
App::with_typed_config |
method | 0.4.1 |
App::typed_config<C> |
method | 0.4.1 |
App::config_as<C> |
method (panicking) | 0.4.1 |
App::config_schema |
method | 0.4.1 |
App::config_value |
method | 0.4.1 |
App::credentials |
method | 0.4.0 |
App::credentials_provider |
optional Arc<dyn CredentialProvider> field |
0.4.0 |
typed_config::{ErasedConfig, TypedConfigOps, erase} |
type alias + struct + fn | 0.4.1 |
credentials::{CredentialProvider, NoCredentials} |
trait + struct | 0.4.0 |
ToolMetadata |
struct + bon::Builder |
0.1.0 |
ToolMetadata::telemetry_notice |
Option<&'static str> field |
0.4.0 |
ReleaseSource, HelpChannel |
enum | 0.1.0 |
VersionInfo |
struct + fluent setters | 0.1.0 |
Feature, Features, FeaturesBuilder |
enum + structs | 0.1.0 |
Feature::Credentials |
enum variant (default-on) | 0.4.0 |
Command |
async trait | 0.1.0 |
Command::subcommand_passthrough |
default trait method | 0.2.0 |
Command::mcp_exposed |
default trait method | 0.3.0 |
Command::mcp_input_schema |
default trait method | 0.3.0 |
CommandSpec |
struct | 0.1.0 |
BUILTIN_COMMANDS |
linkme distributed slice |
0.1.0 |
prelude::{CredentialBearing, CredentialRef} |
re-export from rtb-credentials |
0.4.0 |
Re-exports: linkme (so downstream #[distributed_slice] resolves
::linkme::... paths when users add linkme as a direct dep — the
re-export is convenience, not sufficient).
Consumers¶
| Crate | Uses |
|---|---|
| rtb-config | App.config is type-erased Arc<dyn Any + Send + Sync> storage; the typed Arc<Config<C>> is recovered via App::typed_config::<C>(). |
| rtb-assets | App.assets holds Arc<Assets>. |
| rtb-cli | Builds the App; registers built-in commands. |
| Every downstream command | Implements Command; reads app.metadata, app.version. |
Testing¶
37 acceptance criteria across:
- 21 unit tests (
tests/unit.rs) — T1–T18. - 13 Gherkin scenarios (
tests/features/core.feature) — S1–S8 (S6 is a scenario outline over 6 version-string cases). - 3 trybuild fixtures —
ToolMetadata::builder()required-field enforcement,#[non_exhaustive]onFeatureandReleaseSource.
Spec and status¶
- Status:
IMPLEMENTEDsince 0.1.0. - Spec:
docs/development/specs/2026-04-22-rtb-app-v0.1.md. - Source:
crates/rtb-app/.
Related¶
- App context — concept-level overview.
- rtb-error — error types + rendering pipeline.
- rtb-cli —
Application::builderconsumes these types.