Skip to main content

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}