Skip to main content

rtb_app/
app.rs

1//! The [`App`] application context.
2
3use std::sync::Arc;
4
5use rtb_assets::Assets;
6use rtb_config::Config;
7use rtb_credentials::CredentialRef;
8use tokio_util::sync::CancellationToken;
9
10use crate::credentials::{list_or_empty, CredentialProvider};
11use crate::metadata::ToolMetadata;
12use crate::typed_config::{erase, ErasedConfig, TypedConfigOps};
13use crate::version::VersionInfo;
14
15/// Strongly-typed application context threaded through every command handler.
16///
17/// Unlike Go Tool Base's heterogeneous `Props` struct, `App` holds its
18/// services as concrete `Arc<T>`. `App` is cheap to `clone()` — every
19/// field is reference-counted — so command handlers may take it by value.
20///
21/// # Construction
22///
23/// There is no public `App::new(...)`. Construction happens either:
24///
25/// * In production, via `rtb_cli::Application::builder().build()`.
26/// * In tests, via [`App::for_testing`] (available under `cfg(test)`
27///   locally or with the `test-util` Cargo feature from other crates).
28///
29/// This deliberate friction keeps logging/error-hook/signal wiring
30/// centralised in `rtb-cli`.
31#[derive(Clone)]
32pub struct App {
33    /// Static tool metadata populated at construction time.
34    pub metadata: Arc<ToolMetadata>,
35    /// Build-time version information.
36    pub version: Arc<VersionInfo>,
37    /// Layered configuration (figment-backed, `serde::Deserialize`-typed).
38    ///
39    /// The field is type-erased — direct access surfaces nothing
40    /// useful. Reach the typed handle through [`Self::typed_config`]
41    /// or [`Self::config_as`] (per the v0.4.1 scope addendum, A2
42    /// resolution). Defaults to `Arc<Config<()>>` when no
43    /// `Application::builder().config(...)` was wired.
44    ///
45    /// Stored as `Arc<dyn Any + Send + Sync>` rather than `Arc<dyn
46    /// SomeTrait>` so [`Arc::downcast`] preserves the same backing
47    /// allocation when recovering the typed `Arc<Config<C>>` —
48    /// `App::clone()` ↔ `App::typed_config()` chains share Arcs.
49    pub(crate) config: ErasedConfig,
50    /// Virtual filesystem overlay: embedded defaults + user overrides.
51    pub assets: Arc<Assets>,
52    /// Root cancellation token propagated to every subsystem. Derive
53    /// child tokens via `shutdown.child_token()` so a parent
54    /// cancellation cascades.
55    pub shutdown: CancellationToken,
56    /// Optional credential listing for the v0.4 `credentials`
57    /// subtree. Wired by `Application::builder().credentials_from(…)`;
58    /// `None` for tools that don't yet implement `CredentialBearing`
59    /// on their typed config — `App::credentials` returns an empty
60    /// list in that case so the subtree degrades gracefully.
61    pub credentials_provider: Option<Arc<dyn CredentialProvider>>,
62    /// Schema + render closures for the wired typed config. `Some`
63    /// when the host tool called `Application::builder().config(c)`
64    /// with a `C` that implements `Serialize + JsonSchema`; `None`
65    /// for the v0.4 raw-YAML fallback path. Drives the
66    /// schema-aware `config show / get / set / schema / validate`
67    /// leaves in `rtb-cli`.
68    pub(crate) typed_config_ops: Option<Arc<TypedConfigOps>>,
69}
70
71impl App {
72    /// Production constructor. Used by
73    /// `rtb_cli::Application::builder().build()`; downstream code
74    /// reaches `App` via `Application::run`'s dispatch path. Tests
75    /// should use `rtb_test_support::TestAppBuilder` (which calls
76    /// here under the hood with the test value wrapped in a
77    /// `Config<C>`).
78    ///
79    /// `C` is the tool's typed config type. Passing
80    /// `Config::<()>::default()` works for tools that haven't typed
81    /// their config yet — the `AnyConfig` blanket impl over
82    /// `Config<C>` covers `C = ()` so the call still satisfies the
83    /// trait bound.
84    #[must_use]
85    pub fn new<C>(
86        metadata: ToolMetadata,
87        version: VersionInfo,
88        config: Config<C>,
89        assets: Assets,
90        credentials_provider: Option<Arc<dyn CredentialProvider>>,
91    ) -> Self
92    where
93        C: serde::de::DeserializeOwned + Send + Sync + 'static,
94    {
95        Self {
96            metadata: Arc::new(metadata),
97            version: Arc::new(version),
98            config: erase(config),
99            assets: Arc::new(assets),
100            shutdown: CancellationToken::new(),
101            credentials_provider,
102            typed_config_ops: None,
103        }
104    }
105
106    /// Attach a typed-config bundle to the `App` — used by
107    /// `rtb_cli::Application::builder().config<C>(...)` after
108    /// constructing a basic `App` via [`Self::new`]. Replaces the
109    /// erased config storage with `erased` and attaches the
110    /// schema/render ops. The two are paired by the builder so
111    /// callers can't accidentally mismatch them.
112    #[must_use]
113    pub fn with_typed_config(mut self, erased: ErasedConfig, ops: Arc<TypedConfigOps>) -> Self {
114        self.config = erased;
115        self.typed_config_ops = Some(ops);
116        self
117    }
118
119    /// Test-only constructor. Assembles an `App` from fresh defaults
120    /// for the `assets`/`config` placeholders and the supplied
121    /// `metadata`/`version`. The resulting `shutdown` token is a fresh
122    /// root token; `credentials_provider` is `None`.
123    ///
124    /// This bypass exists so integration tests can construct an `App`
125    /// without pulling in `rtb-cli`'s full wiring. It is intentionally
126    /// `#[doc(hidden)]` — production code should use
127    /// `rtb_cli::Application::builder` so logging, error hooks, signal
128    /// handlers, and command registration are set up consistently.
129    #[doc(hidden)]
130    #[must_use]
131    pub fn for_testing(metadata: ToolMetadata, version: VersionInfo) -> Self {
132        Self::new(metadata, version, Config::<()>::default(), Assets::default(), None)
133    }
134
135    /// Yield the configured credentials. Returns an empty `Vec` when
136    /// no provider has been wired — `credentials list` reports the
137    /// empty set, which is the right thing for a tool that hasn't
138    /// declared any credentials yet.
139    #[must_use]
140    pub fn credentials(&self) -> Vec<(String, CredentialRef)> {
141        list_or_empty(self.credentials_provider.as_ref())
142    }
143
144    /// Typed access to the wired configuration. Returns
145    /// `Some(Arc<Config<C>>)` when `Application::builder().config(...)`
146    /// was called with a `Config<C>`; `None` otherwise.
147    ///
148    /// The downcast is a single `Any::downcast_ref` round-trip —
149    /// safe to call once at the top of every command body without
150    /// caching.
151    ///
152    /// # Errors
153    ///
154    /// Infallible — returns `None` on type mismatch rather than
155    /// panicking. See [`Self::config_as`] for the panicking
156    /// counterpart.
157    #[must_use]
158    pub fn typed_config<C>(&self) -> Option<Arc<Config<C>>>
159    where
160        C: serde::de::DeserializeOwned + Send + Sync + 'static,
161    {
162        // `Arc::clone` increments the refcount on the type-erased
163        // trait object; `Arc::downcast::<Config<C>>` reinterprets
164        // it as the concrete type. The returned `Arc<Config<C>>`
165        // shares the *same* backing allocation, so `Arc::ptr_eq`
166        // round-trips across `App::clone()` ↔
167        // `App::typed_config::<C>()`.
168        Arc::clone(&self.config).downcast::<Config<C>>().ok()
169    }
170
171    /// Typed access to the wired configuration; panics when no
172    /// matching typed config is wired.
173    ///
174    /// The panic message names the requested type so the failure
175    /// is self-diagnosing. Use this from command bodies that
176    /// already know the host tool wired its typed config at startup
177    /// — for example, the same crate that called
178    /// `Application::builder().config(...)`.
179    ///
180    /// # Panics
181    ///
182    /// When `App::typed_config::<C>()` returns `None`. Surfaces
183    /// the call-site location via `#[track_caller]`.
184    #[must_use]
185    #[track_caller]
186    pub fn config_as<C>(&self) -> Arc<Config<C>>
187    where
188        C: serde::de::DeserializeOwned + Send + Sync + 'static,
189    {
190        self.typed_config::<C>().unwrap_or_else(|| {
191            panic!(
192                "App::config_as::<{}>() — no matching typed config wired \
193                 (did `Application::builder().config(...)` get called \
194                 with the right type?)",
195                std::any::type_name::<C>(),
196            )
197        })
198    }
199
200    /// JSON Schema for the wired typed config. `None` when no
201    /// typed-config ops were attached (i.e. the host tool did not
202    /// call `Application::builder().config(c)`). Used by the
203    /// `rtb-cli` `config schema / validate` subcommands to drive
204    /// schema-aware behaviour without knowing the tool's `C` type.
205    #[must_use]
206    pub fn config_schema(&self) -> Option<&serde_json::Value> {
207        self.typed_config_ops.as_ref().map(|ops| &ops.schema)
208    }
209
210    /// Merged typed-config value rendered as a `serde_json::Value`.
211    /// `None` when no typed-config ops were attached. Used by the
212    /// `rtb-cli` `config show / get` subcommands to read the
213    /// merged config without knowing `C`.
214    #[must_use]
215    pub fn config_value(&self) -> Option<serde_json::Value> {
216        self.typed_config_ops.as_ref().and_then(|ops| ops.render(&self.config))
217    }
218}