Skip to main content

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}