rtb_app/typed_config.rs
1//! Type-erased typed-config storage for [`crate::app::App`].
2//!
3//! Two pieces work together:
4//!
5//! - [`ErasedConfig`] — `Arc<dyn Any + Send + Sync>` storage so
6//! [`Arc::downcast`] can recover `Arc<Config<C>>` sharing the
7//! same allocation as the trait object (cheap clone, single
8//! refcount).
9//! - [`TypedConfigOps`] — captured-at-builder-time closures that
10//! render the schema and the merged value as `serde_json::Value`,
11//! without consumers needing to know `C`. The `rtb-cli` config
12//! subtree reads these to drive the schema-aware
13//! `show / get / set / schema / validate` leaves.
14//!
15//! See `docs/development/specs/2026-05-09-v0.4.1-scope.md` §3 for
16//! the design rationale (option (a) — type-erased `App` with
17//! `Any`-downcast plus closure-based ops).
18
19use std::any::Any;
20use std::sync::Arc;
21
22use rtb_config::Config;
23
24/// Type-erased config storage.
25///
26/// Internally an `Arc<dyn Any + Send + Sync>` so [`Arc::downcast`]
27/// can recover `Arc<Config<C>>` sharing the same allocation.
28/// Re-exported as a type alias for clarity at callsites.
29pub type ErasedConfig = Arc<dyn Any + Send + Sync>;
30
31/// Wrap a `Config<C>` as an `ErasedConfig` for storage on
32/// [`crate::app::App`]. Used by `App::new` and `TestAppBuilder`.
33#[must_use]
34pub fn erase<C>(config: Config<C>) -> ErasedConfig
35where
36 C: serde::de::DeserializeOwned + Send + Sync + 'static,
37{
38 Arc::new(config)
39}
40
41/// Closure type for [`TypedConfigOps::render_value`].
42type RenderValueFn =
43 Box<dyn Fn(&(dyn Any + Send + Sync)) -> Option<serde_json::Value> + Send + Sync>;
44
45/// Type-erased view onto a wired typed config.
46///
47/// Carries the JSON Schema for `C` and a closure that renders the
48/// merged `C` value as a `serde_json::Value`. Constructed via
49/// [`TypedConfigOps::new`] at builder time when `C` is still in
50/// scope.
51///
52/// Stored on [`crate::app::App`] as `Option<Arc<TypedConfigOps>>`
53/// — `Some` when the host tool called
54/// `Application::builder().config(c)`, `None` for the v0.4-style
55/// raw-YAML fallback path.
56pub struct TypedConfigOps {
57 /// JSON Schema for `C`, generated via
58 /// `schemars::SchemaGenerator::root_schema_for::<C>()` and
59 /// serialised to a `serde_json::Value`.
60 pub schema: serde_json::Value,
61 /// Renders the merged `C` value as a `serde_json::Value`.
62 /// Receives `&dyn Any` (the trait object underneath
63 /// [`ErasedConfig`]) and downcasts internally — captured `C`
64 /// is private to the closure.
65 render_value: RenderValueFn,
66}
67
68impl std::fmt::Debug for TypedConfigOps {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.debug_struct("TypedConfigOps")
71 .field("schema", &self.schema)
72 .field("render_value", &"<closure>")
73 .finish()
74 }
75}
76
77impl TypedConfigOps {
78 /// Build the ops bundle for `C`. Captures `C` in the
79 /// `render_value` closure; the resulting struct is `dyn`-stable
80 /// and `Send + Sync`.
81 #[must_use]
82 pub fn new<C>() -> Self
83 where
84 C: serde::Serialize
85 + serde::de::DeserializeOwned
86 + schemars::JsonSchema
87 + Send
88 + Sync
89 + 'static,
90 {
91 let mut generator = schemars::SchemaGenerator::default();
92 let schema = generator.root_schema_for::<C>();
93 let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::Value::Null);
94 Self {
95 schema: schema_value,
96 render_value: Box::new(|any: &(dyn Any + Send + Sync)| -> Option<serde_json::Value> {
97 let typed = any.downcast_ref::<Config<C>>()?;
98 let value = typed.get();
99 serde_json::to_value(&*value).ok()
100 }),
101 }
102 }
103
104 /// Render the merged value backing `erased` as a
105 /// `serde_json::Value`. Returns `None` if `erased` does not
106 /// contain a `Config<C>` of the type captured at construction
107 /// time — in practice this should only happen when the same
108 /// `App` is constructed with mismatched ops + erased-config
109 /// pairings, which the public surface prevents.
110 #[must_use]
111 pub fn render(&self, erased: &ErasedConfig) -> Option<serde_json::Value> {
112 // Borrow the trait object out of the Arc; the closure will
113 // re-downcast to the captured `Config<C>` type.
114 let any: &(dyn Any + Send + Sync) = erased.as_ref();
115 (self.render_value)(any)
116 }
117}