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