Why the config is type-erased¶
The problem¶
Commands need their tool's configuration, and that configuration is a different type in every tool. The direct expression of that is a generic parameter:
pub struct App<C: AppConfig> { /* … */ }
pub trait Command<C: AppConfig> {
async fn run(&self, app: App<C>) -> miette::Result<()>;
}
This does not work, and the reason is not aesthetic. BUILTIN_COMMANDS stores
Box<dyn Command>, which requires Command to be object-safe. A trait with a type
parameter can still be made into an object, but only once that parameter is fixed —
so the slice would have to be Box<dyn Command<MySettings>>, a different type per
tool, and rtb-app cannot declare a slice whose element type depends on a crate that
does not exist yet.
Every alternative that keeps App generic pushes the generic parameter through the
Command trait, the registry, and the rtb-cli dispatch path, and ends at the same
wall.
What it does instead¶
The config is stored erased:
and recovered by naming the type on the way out:
App stays a concrete type. Command stays object-safe. BUILTIN_COMMANDS stays a
single slice every tool shares. The tool's config type appears only where the tool's
own code names it.
Why Arc<dyn Any> and not Arc<dyn SomeTrait>¶
Erasing behind a bespoke trait would also work for storage, but it loses something
specific: Arc::downcast on an Arc<dyn Any + Send + Sync> produces an
Arc<Config<C>> sharing the same allocation. There is no copy and no second
refcount.
That is what makes the round trip honest. App::clone() followed by
typed_config::<C>() on both copies yields two Arcs for which Arc::ptr_eq holds
— they are the same object, not two views of equal data. A tool can clone an App
into a dozen spawned tasks and every one of them reads the same configuration.
The two seams this leaves¶
Type erasure moves a compile-time guarantee to runtime, and the crate is upfront about where.
The type must match, and a mismatch is a runtime outcome. typed_config::<C>()
returns None when C is not the wired type; config_as::<C>() panics. Neither can
be caught by the compiler, so config_as carries #[track_caller] and a panic
message naming the requested type, so the failure at least diagnoses itself:
Schema and rendering need bounds storage does not. Storing a config needs only
DeserializeOwned. Producing a JSON Schema needs JsonSchema, and rendering the
merged value needs Serialize. Rather than raise App::new's bound and force every
tool to derive both, the schema and render closures live in a separate
TypedConfigOps bundle, built where C is still in scope and attached alongside the
erased value.
That is why App::config_schema() and App::config_value() return Option. It is
also why they can return None while typed_config::<C>() returns Some — the
config was stored through App::new, which cannot build the bundle. It is a real
seam, not a defensive Option.
What this buys rtb-cli¶
rtb-cli implements config show / get / schema / validate for every tool built on
the toolkit without any of its signatures mentioning a config type. It reads
app.config_schema() and app.config_value(), both plain serde_json::Value, and
the tool's C is captured inside a closure it never has to name. Generic behaviour
over an unknown type, with the erasure confined to one small module.