Skip to main content

rtb_cli/
application.rs

1//! The [`Application`] entry-point type and its hand-rolled typestate
2//! builder.
3
4use std::ffi::OsString;
5use std::sync::Arc;
6
7use clap::Command as ClapCommand;
8use rtb_app::app::App;
9use rtb_app::command::{Command as RtbCommand, BUILTIN_COMMANDS};
10use rtb_app::features::Features;
11use rtb_app::metadata::ToolMetadata;
12use rtb_app::version::VersionInfo;
13use rtb_assets::Assets;
14use rtb_config::Config;
15
16use crate::runtime::{self, LogFormat};
17
18/// A fully-configured application ready to dispatch.
19pub struct Application {
20    app: App,
21    commands: Vec<Box<dyn RtbCommand>>,
22    install_hooks: bool,
23}
24
25impl Application {
26    /// Start building a new application. `metadata` and `version`
27    /// must be provided before [`ApplicationBuilder::build`] will
28    /// compile — enforced by the phantom-typed typestate below.
29    pub fn builder() -> ApplicationBuilder<NoMetadata, NoVersion> {
30        ApplicationBuilder::new()
31    }
32
33    /// Parse CLI arguments from `std::env::args_os()`, dispatch,
34    /// return.
35    pub async fn run(self) -> miette::Result<()> {
36        // Collect eagerly — `std::env::ArgsOs` is not `Send`, which
37        // would poison the returned Future for multi-thread runtimes.
38        let args: Vec<OsString> = std::env::args_os().collect();
39        self.run_with_args(args).await
40    }
41
42    /// Programmatic dispatch. Useful in tests.
43    pub async fn run_with_args<I, S>(self, args: I) -> miette::Result<()>
44    where
45        I: IntoIterator<Item = S>,
46        S: Into<OsString> + Clone,
47    {
48        if self.install_hooks {
49            rtb_error::hook::install_report_handler();
50            rtb_error::hook::install_panic_hook();
51            if let Some(footer) = self.app.metadata.help.footer() {
52                rtb_error::hook::install_with_footer(move || footer.clone());
53            }
54        }
55
56        runtime::install_tracing(LogFormat::auto());
57        runtime::bind_shutdown_signals(self.app.shutdown.clone());
58
59        let clap_cmd = build_clap_tree(&self.app.metadata, &self.commands);
60        let matches = match clap_cmd.try_get_matches_from(args) {
61            Ok(m) => m,
62            Err(e) if is_help_or_version(&e) => {
63                // clap already printed help/version to stdout; exit
64                // successfully rather than bubble a neutral error up
65                // through the diagnostic pipeline.
66                print!("{e}");
67                return Ok(());
68            }
69            Err(e) => return Err(map_clap_error(&e)),
70        };
71
72        let Some((sub, _sub_matches)) = matches.subcommand() else {
73            // No subcommand — clap's `arg_required_else_help` makes
74            // the parser itself error in this case, but guard
75            // defensively in case a downstream override disables it.
76            return Err(rtb_error::Error::CommandNotFound("<none>".into()).into());
77        };
78
79        let cmd = self
80            .commands
81            .iter()
82            .find(|c| c.spec().name == sub)
83            .ok_or_else(|| rtb_error::Error::CommandNotFound(sub.to_string()))?;
84
85        cmd.run(self.app.clone()).await
86    }
87}
88
89// -----------------------------------------------------------------
90// Typestate builder
91// -----------------------------------------------------------------
92
93/// Phantom marker: metadata has not been set.
94pub struct NoMetadata;
95/// Phantom marker: metadata has been set.
96pub struct HasMetadata(ToolMetadata);
97/// Phantom marker: version has not been set.
98pub struct NoVersion;
99/// Phantom marker: version has been set.
100pub struct HasVersion(VersionInfo);
101
102/// Typestate-guarded builder. `metadata` and `version` are required;
103/// omitting either is a compile error (the `build()` method is only
104/// implemented on `ApplicationBuilder<HasMetadata, HasVersion>`).
105#[must_use]
106pub struct ApplicationBuilder<M, V> {
107    metadata: M,
108    version: V,
109    assets: Option<Assets>,
110    features: Option<Features>,
111    install_hooks: bool,
112    credentials_provider: Option<Arc<dyn rtb_app::credentials::CredentialProvider>>,
113    /// Captured at builder time when `config<C>` is called: the
114    /// erased `Config<C>` storage and the closure-based ops bundle
115    /// that drives schema-aware `config_cmd` paths.
116    typed_config: Option<rtb_app::typed_config::ErasedConfig>,
117    typed_config_ops: Option<Arc<rtb_app::typed_config::TypedConfigOps>>,
118}
119
120impl ApplicationBuilder<NoMetadata, NoVersion> {
121    /// Construct an empty builder.
122    pub fn new() -> Self {
123        Self {
124            metadata: NoMetadata,
125            version: NoVersion,
126            assets: None,
127            features: None,
128            install_hooks: true,
129            credentials_provider: None,
130            typed_config: None,
131            typed_config_ops: None,
132        }
133    }
134}
135
136impl Default for ApplicationBuilder<NoMetadata, NoVersion> {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142impl<V> ApplicationBuilder<NoMetadata, V> {
143    /// Set the static tool metadata. Required.
144    pub fn metadata(self, m: ToolMetadata) -> ApplicationBuilder<HasMetadata, V> {
145        ApplicationBuilder {
146            metadata: HasMetadata(m),
147            version: self.version,
148            assets: self.assets,
149            features: self.features,
150            install_hooks: self.install_hooks,
151            credentials_provider: self.credentials_provider,
152            typed_config: self.typed_config,
153            typed_config_ops: self.typed_config_ops,
154        }
155    }
156}
157
158impl<M> ApplicationBuilder<M, NoVersion> {
159    /// Set the build-time version info. Required.
160    pub fn version(self, v: VersionInfo) -> ApplicationBuilder<M, HasVersion> {
161        ApplicationBuilder {
162            metadata: self.metadata,
163            version: HasVersion(v),
164            assets: self.assets,
165            features: self.features,
166            install_hooks: self.install_hooks,
167            credentials_provider: self.credentials_provider,
168            typed_config: self.typed_config,
169            typed_config_ops: self.typed_config_ops,
170        }
171    }
172}
173
174impl<M, V> ApplicationBuilder<M, V> {
175    /// Override the embedded-assets overlay. Defaults to an empty
176    /// [`Assets`].
177    pub fn assets(mut self, a: Assets) -> Self {
178        self.assets = Some(a);
179        self
180    }
181
182    /// Override the runtime feature set. Defaults to
183    /// [`Features::default`].
184    pub fn features(mut self, f: Features) -> Self {
185        self.features = Some(f);
186        self
187    }
188
189    /// Control installation of the `miette` report/panic hooks.
190    /// `true` by default. Pass `false` from tests that want to
191    /// manage hooks themselves.
192    pub const fn install_hooks(mut self, yes: bool) -> Self {
193        self.install_hooks = yes;
194        self
195    }
196
197    /// Wire a credential provider so the v0.4 `credentials list /
198    /// test / doctor` subcommands can enumerate the tool's
199    /// `CredentialRef`s. The argument is anything that implements
200    /// [`rtb_credentials::CredentialBearing`] — typically the tool's
201    /// typed config struct passed as `Arc::new(my_config.clone())`.
202    ///
203    /// Tools that don't wire a provider see `credentials list` print
204    /// an empty table — the subtree degrades gracefully rather than
205    /// erroring out at startup.
206    pub fn credentials_from<T>(mut self, provider: Arc<T>) -> Self
207    where
208        T: rtb_credentials::CredentialBearing + Send + Sync + 'static,
209    {
210        self.credentials_provider =
211            Some(provider as Arc<dyn rtb_app::credentials::CredentialProvider>);
212        self
213    }
214
215    /// Wire a typed config so command handlers can reach it via
216    /// `app.typed_config::<C>()` and the framework-supplied
217    /// `config show / get / set / schema / validate` subcommands
218    /// drive their schema-aware paths.
219    ///
220    /// The `JsonSchema` bound is required (per v0.4.1 scope A3
221    /// resolution) so the schema-aware leaves work for everyone
222    /// who opts in. Tools with non-`JsonSchema`-able config shapes
223    /// can keep the v0.4 raw-YAML fallback by *not* calling this
224    /// step; the rest of the framework continues to work
225    /// unchanged.
226    pub fn config<C>(mut self, config: rtb_config::Config<C>) -> Self
227    where
228        C: serde::Serialize
229            + serde::de::DeserializeOwned
230            + schemars::JsonSchema
231            + Send
232            + Sync
233            + 'static,
234    {
235        let ops = rtb_app::typed_config::TypedConfigOps::new::<C>();
236        self.typed_config = Some(rtb_app::typed_config::erase(config));
237        self.typed_config_ops = Some(Arc::new(ops));
238        self
239    }
240}
241
242impl ApplicationBuilder<HasMetadata, HasVersion> {
243    /// Finalise the builder. Only compiles when both
244    /// [`ApplicationBuilder::metadata`] and
245    /// [`ApplicationBuilder::version`] have been supplied.
246    pub fn build(self) -> miette::Result<Application> {
247        let HasMetadata(metadata) = self.metadata;
248        let HasVersion(version) = self.version;
249
250        let features = self.features.unwrap_or_default();
251        let assets = self.assets.unwrap_or_default();
252
253        // Build a basic App via the public constructor; if
254        // `.config(...)` was called the typed-config bundle gets
255        // attached afterwards (replacing the placeholder
256        // `Config<()>` storage with the same allocation
257        // `ApplicationBuilder::config<C>` set up).
258        let app =
259            App::new(metadata, version, Config::<()>::default(), assets, self.credentials_provider);
260        let app = match (self.typed_config, self.typed_config_ops) {
261            (Some(erased), Some(ops)) => app.with_typed_config(erased, ops),
262            _ => app,
263        };
264
265        // Materialise BUILTIN_COMMANDS filtered by the runtime
266        // Features set.
267        let mut commands: Vec<Box<dyn RtbCommand>> = Vec::new();
268        for factory in BUILTIN_COMMANDS {
269            let cmd = factory();
270            let enabled_here = cmd.spec().feature.is_none_or(|f| features.is_enabled(f));
271            if enabled_here {
272                commands.push(cmd);
273            }
274        }
275
276        // Deduplicate by command name. `linkme`'s slice order is
277        // link-time-determined and not stable across compiler
278        // versions or dep graph changes, so we cannot rely on
279        // "last-registered wins". Instead, the LAST entry in slice
280        // order for each name wins — which matches the intuition
281        // that a downstream crate's real command overrides the
282        // rtb-cli stub of the same name.
283        let mut seen: std::collections::HashMap<&'static str, usize> =
284            std::collections::HashMap::new();
285        for (idx, cmd) in commands.iter().enumerate() {
286            seen.insert(cmd.spec().name, idx);
287        }
288        let keep: std::collections::HashSet<usize> = seen.values().copied().collect();
289        let mut i = 0usize;
290        commands.retain(|_| {
291            let keep_this = keep.contains(&i);
292            i += 1;
293            keep_this
294        });
295
296        // Stable order by command name keeps `--help` output
297        // deterministic regardless of link-time slice ordering.
298        commands.sort_by(|a, b| a.spec().name.cmp(b.spec().name));
299
300        Ok(Application { app, commands, install_hooks: self.install_hooks })
301    }
302}
303
304// -----------------------------------------------------------------
305// clap glue
306// -----------------------------------------------------------------
307
308fn build_clap_tree(metadata: &ToolMetadata, commands: &[Box<dyn RtbCommand>]) -> ClapCommand {
309    let mut root = ClapCommand::new(metadata.name.clone())
310        .about(metadata.summary.clone())
311        .arg_required_else_help(true)
312        .subcommand_required(true)
313        // Global `--output text|json` flag. Declared once at the
314        // root with `global = true`; clap propagates it onto every
315        // subcommand automatically. Subcommands that print
316        // structured data honour it via [`crate::render::output`];
317        // interactive ones (init, mcp serve, update run) ignore it.
318        // See v0.4 scope addendum §2.5 / O5.
319        .arg(
320            clap::Arg::new("output")
321                .long("output")
322                .global(true)
323                .value_parser(clap::value_parser!(crate::render::OutputMode))
324                .default_value("text")
325                .help("Output rendering mode for structured-output subcommands"),
326        );
327
328    if !metadata.description.is_empty() {
329        root = root.long_about(metadata.description.clone());
330    }
331
332    for cmd in commands {
333        let spec = cmd.spec();
334        let mut sub = ClapCommand::new(spec.name).about(spec.about);
335        for alias in spec.aliases {
336            sub = sub.alias(*alias);
337        }
338        if cmd.subcommand_passthrough() {
339            // Let the command own its inner clap subtree. The
340            // `trailing_var_arg` arg captures every token after
341            // `<name>` (including `--help`, `--flag value`, sub-sub-
342            // commands) without further validation — the command
343            // re-parses `std::env::args_os()` itself.
344            sub = sub.arg(
345                clap::Arg::new("rest")
346                    .num_args(0..)
347                    .trailing_var_arg(true)
348                    .allow_hyphen_values(true),
349            );
350            // Drop the auto-injected `--help` so it reaches the inner
351            // parser instead of clap's default help screen at the
352            // outer layer.
353            sub = sub.disable_help_flag(true);
354        }
355        root = root.subcommand(sub);
356    }
357
358    root
359}
360
361/// `true` when the clap error is a "successful" user-facing output
362/// (help or version) that should return `Ok(())` rather than bubble
363/// up through the diagnostic pipeline.
364fn is_help_or_version(err: &clap::Error) -> bool {
365    use clap::error::ErrorKind;
366    matches!(err.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion)
367}
368
369fn map_clap_error(err: &clap::Error) -> miette::Report {
370    use clap::error::ErrorKind;
371    match err.kind() {
372        ErrorKind::InvalidSubcommand | ErrorKind::UnknownArgument => {
373            let name = err
374                .get(clap::error::ContextKind::InvalidSubcommand)
375                .or_else(|| err.get(clap::error::ContextKind::InvalidArg))
376                .map_or_else(|| err.to_string(), |v| format!("{v}"));
377            rtb_error::Error::CommandNotFound(name).into()
378        }
379        _ => miette::miette!("{}", err),
380    }
381}