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}