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        // For a passthrough command, hand its captured trailing tokens to
86        // the dispatched `App` so it re-parses them via
87        // `crate::parse_passthrough` instead of re-reading global argv.
88        // `try_get_many` is panic-safe when the subcommand has no `rest`
89        // arg (i.e. non-passthrough commands) — it yields an empty slice.
90        let trailing: Vec<std::ffi::OsString> = sub_matches
91            .try_get_many::<std::ffi::OsString>("rest")
92            .ok()
93            .flatten()
94            .map(|vals| vals.cloned().collect())
95            .unwrap_or_default();
96        let app = self.app.clone().with_trailing_args(trailing);
97
98        // Run registered pre-run hooks (e.g. the self-update policy check)
99        // before dispatching the matched command. Leaf crates register
100        // these via `BUILTIN_PRERUN_HOOKS`; a hook returning `Err` aborts
101        // the run. Help/version already short-circuited above, so hooks do
102        // not fire for `--help`/`--version`.
103        for hook in rtb_app::command::BUILTIN_PRERUN_HOOKS {
104            hook(app.clone()).await?;
105        }
106
107        cmd.run(app).await
108    }
109
110    /// Run, render any error through the installed diagnostic handler, and
111    /// return the process exit code.
112    ///
113    /// Honours a code attached via [`rtb_error::WithExitCode`] (read once,
114    /// at this boundary — not an error funnel); defaults to `1` on error and
115    /// `0` on success. This is the boundary a binary's `main` should use:
116    ///
117    /// ```no_run
118    /// # async fn wrap(app: rtb_cli::Application) -> std::process::ExitCode {
119    /// app.run_and_exit().await
120    /// # }
121    /// ```
122    pub async fn run_and_exit(self) -> std::process::ExitCode {
123        match self.run().await {
124            Ok(()) => std::process::ExitCode::SUCCESS,
125            Err(report) => report_to_exit_code(&report),
126        }
127    }
128}
129
130/// Render `report` and map it to a process exit code.
131///
132/// Prints the diagnostic through the installed handler (matching what
133/// `Termination` for `Result<(), Report>` does) and honours a code attached
134/// via [`rtb_error::WithExitCode`] (default `1`).
135///
136/// Exposed so a binary's `main` can map a *builder* error — raised before
137/// [`Application::run_and_exit`] is reachable — to the same exit path.
138#[must_use]
139pub fn report_to_exit_code(report: &miette::Report) -> std::process::ExitCode {
140    eprintln!("{report:?}");
141    std::process::ExitCode::from(rtb_error::exit_code_of(report).unwrap_or(1))
142}
143
144// -----------------------------------------------------------------
145// Typestate builder
146// -----------------------------------------------------------------
147
148/// Phantom marker: metadata has not been set.
149pub struct NoMetadata;
150/// Phantom marker: metadata has been set.
151pub struct HasMetadata(ToolMetadata);
152/// Phantom marker: version has not been set.
153pub struct NoVersion;
154/// Phantom marker: version has been set.
155pub struct HasVersion(VersionInfo);
156
157/// Typestate-guarded builder. `metadata` and `version` are required;
158/// omitting either is a compile error (the `build()` method is only
159/// implemented on `ApplicationBuilder<HasMetadata, HasVersion>`).
160#[must_use]
161pub struct ApplicationBuilder<M, V> {
162    metadata: M,
163    version: V,
164    assets: Option<Assets>,
165    features: Option<Features>,
166    install_hooks: bool,
167    credentials_provider: Option<Arc<dyn rtb_app::credentials::CredentialProvider>>,
168    /// Captured at builder time when `config<C>` is called: the
169    /// erased `Config<C>` storage and the closure-based ops bundle
170    /// that drives schema-aware `config_cmd` paths.
171    typed_config: Option<rtb_app::typed_config::ErasedConfig>,
172    typed_config_ops: Option<Arc<rtb_app::typed_config::TypedConfigOps>>,
173}
174
175impl ApplicationBuilder<NoMetadata, NoVersion> {
176    /// Construct an empty builder.
177    pub fn new() -> Self {
178        Self {
179            metadata: NoMetadata,
180            version: NoVersion,
181            assets: None,
182            features: None,
183            install_hooks: true,
184            credentials_provider: None,
185            typed_config: None,
186            typed_config_ops: None,
187        }
188    }
189}
190
191impl Default for ApplicationBuilder<NoMetadata, NoVersion> {
192    fn default() -> Self {
193        Self::new()
194    }
195}
196
197impl<V> ApplicationBuilder<NoMetadata, V> {
198    /// Set the static tool metadata. Required.
199    pub fn metadata(self, m: ToolMetadata) -> ApplicationBuilder<HasMetadata, V> {
200        ApplicationBuilder {
201            metadata: HasMetadata(m),
202            version: self.version,
203            assets: self.assets,
204            features: self.features,
205            install_hooks: self.install_hooks,
206            credentials_provider: self.credentials_provider,
207            typed_config: self.typed_config,
208            typed_config_ops: self.typed_config_ops,
209        }
210    }
211}
212
213impl<M> ApplicationBuilder<M, NoVersion> {
214    /// Set the build-time version info. Required.
215    pub fn version(self, v: VersionInfo) -> ApplicationBuilder<M, HasVersion> {
216        ApplicationBuilder {
217            metadata: self.metadata,
218            version: HasVersion(v),
219            assets: self.assets,
220            features: self.features,
221            install_hooks: self.install_hooks,
222            credentials_provider: self.credentials_provider,
223            typed_config: self.typed_config,
224            typed_config_ops: self.typed_config_ops,
225        }
226    }
227}
228
229impl<M, V> ApplicationBuilder<M, V> {
230    /// Override the embedded-assets overlay. Defaults to an empty
231    /// [`Assets`].
232    pub fn assets(mut self, a: Assets) -> Self {
233        self.assets = Some(a);
234        self
235    }
236
237    /// Override the runtime feature set. Defaults to
238    /// [`Features::default`].
239    pub fn features(mut self, f: Features) -> Self {
240        self.features = Some(f);
241        self
242    }
243
244    /// Control installation of the `miette` report/panic hooks.
245    /// `true` by default. Pass `false` from tests that want to
246    /// manage hooks themselves.
247    pub const fn install_hooks(mut self, yes: bool) -> Self {
248        self.install_hooks = yes;
249        self
250    }
251
252    /// Wire a credential provider so the v0.4 `credentials list /
253    /// test / doctor` subcommands can enumerate the tool's
254    /// `CredentialRef`s. The argument is anything that implements
255    /// [`rtb_credentials::CredentialBearing`] — typically the tool's
256    /// typed config struct passed as `Arc::new(my_config.clone())`.
257    ///
258    /// Tools that don't wire a provider see `credentials list` print
259    /// an empty table — the subtree degrades gracefully rather than
260    /// erroring out at startup.
261    pub fn credentials_from<T>(mut self, provider: Arc<T>) -> Self
262    where
263        T: rtb_credentials::CredentialBearing + Send + Sync + 'static,
264    {
265        self.credentials_provider =
266            Some(provider as Arc<dyn rtb_app::credentials::CredentialProvider>);
267        self
268    }
269
270    /// Wire a typed config so command handlers can reach it via
271    /// `app.typed_config::<C>()` and the framework-supplied
272    /// `config show / get / set / schema / validate` subcommands
273    /// drive their schema-aware paths.
274    ///
275    /// The `JsonSchema` bound is required (per v0.4.1 scope A3
276    /// resolution) so the schema-aware leaves work for everyone
277    /// who opts in. Tools with non-`JsonSchema`-able config shapes
278    /// can keep the v0.4 raw-YAML fallback by *not* calling this
279    /// step; the rest of the framework continues to work
280    /// unchanged.
281    pub fn config<C>(mut self, config: rtb_config::Config<C>) -> Self
282    where
283        C: serde::Serialize
284            + serde::de::DeserializeOwned
285            + schemars::JsonSchema
286            + Send
287            + Sync
288            + 'static,
289    {
290        let ops = rtb_app::typed_config::TypedConfigOps::new::<C>();
291        self.typed_config = Some(rtb_app::typed_config::erase(config));
292        self.typed_config_ops = Some(Arc::new(ops));
293        self
294    }
295}
296
297impl ApplicationBuilder<HasMetadata, HasVersion> {
298    /// Finalise the builder. Only compiles when both
299    /// [`ApplicationBuilder::metadata`] and
300    /// [`ApplicationBuilder::version`] have been supplied.
301    pub fn build(self) -> miette::Result<Application> {
302        let HasMetadata(metadata) = self.metadata;
303        let HasVersion(version) = self.version;
304
305        let features = self.features.unwrap_or_default();
306        let assets = self.assets.unwrap_or_default();
307
308        // Build a basic App via the public constructor; if
309        // `.config(...)` was called the typed-config bundle gets
310        // attached afterwards (replacing the placeholder
311        // `Config<()>` storage with the same allocation
312        // `ApplicationBuilder::config<C>` set up).
313        let app =
314            App::new(metadata, version, Config::<()>::default(), assets, self.credentials_provider);
315        let app = match (self.typed_config, self.typed_config_ops) {
316            (Some(erased), Some(ops)) => app.with_typed_config(erased, ops),
317            _ => app,
318        };
319
320        // Materialise BUILTIN_COMMANDS filtered by the runtime
321        // Features set.
322        let mut commands: Vec<Box<dyn RtbCommand>> = Vec::new();
323        for factory in BUILTIN_COMMANDS {
324            let cmd = factory();
325            let enabled_here = cmd.spec().feature.is_none_or(|f| features.is_enabled(f));
326            if enabled_here {
327                commands.push(cmd);
328            }
329        }
330
331        // Deduplicate by command name. `linkme`'s slice order is
332        // link-time-determined and not stable across compiler
333        // versions or dep graph changes, so we cannot rely on
334        // "last-registered wins". Instead, the LAST entry in slice
335        // order for each name wins — which matches the intuition
336        // that a downstream crate's real command overrides the
337        // rtb-cli stub of the same name.
338        let mut seen: std::collections::HashMap<&'static str, usize> =
339            std::collections::HashMap::new();
340        for (idx, cmd) in commands.iter().enumerate() {
341            seen.insert(cmd.spec().name, idx);
342        }
343        let keep: std::collections::HashSet<usize> = seen.values().copied().collect();
344        let mut i = 0usize;
345        commands.retain(|_| {
346            let keep_this = keep.contains(&i);
347            i += 1;
348            keep_this
349        });
350
351        // Stable order by command name keeps `--help` output
352        // deterministic regardless of link-time slice ordering.
353        commands.sort_by(|a, b| a.spec().name.cmp(b.spec().name));
354
355        Ok(Application { app, commands, install_hooks: self.install_hooks })
356    }
357}
358
359// -----------------------------------------------------------------
360// clap glue
361// -----------------------------------------------------------------
362
363fn build_clap_tree(metadata: &ToolMetadata, commands: &[Box<dyn RtbCommand>]) -> ClapCommand {
364    let mut root = ClapCommand::new(metadata.name.clone())
365        .about(metadata.summary.clone())
366        .arg_required_else_help(true)
367        .subcommand_required(true)
368        // Global `--output text|json` flag. Declared once at the
369        // root with `global = true`; clap propagates it onto every
370        // subcommand automatically. Subcommands that print
371        // structured data honour it via [`crate::render::output`];
372        // interactive ones (init, mcp serve, update run) ignore it.
373        // See v0.4 scope addendum §2.5 / O5.
374        .arg(
375            clap::Arg::new("output")
376                .long("output")
377                .global(true)
378                .value_parser(clap::value_parser!(crate::render::OutputMode))
379                .default_value("text")
380                .help("Output rendering mode for structured-output subcommands"),
381        );
382
383    if !metadata.description.is_empty() {
384        root = root.long_about(metadata.description.clone());
385    }
386
387    for cmd in commands {
388        let spec = cmd.spec();
389        let mut sub = ClapCommand::new(spec.name).about(spec.about);
390        for alias in spec.aliases {
391            sub = sub.alias(*alias);
392        }
393        if let Some(short) = spec.short {
394            sub = sub.short_flag(short);
395        }
396        if let Some(long) = spec.long_about {
397            sub = sub.long_about(long);
398        }
399        if cmd.subcommand_passthrough() {
400            // Let the command own its inner clap subtree. The
401            // `trailing_var_arg` arg captures every token after
402            // `<name>` (including `--help`, `--flag value`, sub-sub-
403            // commands) without further validation — the command
404            // re-parses `std::env::args_os()` itself.
405            sub = sub.arg(
406                clap::Arg::new("rest")
407                    .num_args(0..)
408                    // `OsString` (not the default `String`) so non-UTF-8
409                    // arguments survive to the command's own parser.
410                    .value_parser(clap::value_parser!(std::ffi::OsString))
411                    .trailing_var_arg(true)
412                    .allow_hyphen_values(true),
413            );
414            // Drop the auto-injected `--help` so it reaches the inner
415            // parser instead of clap's default help screen at the
416            // outer layer.
417            sub = sub.disable_help_flag(true);
418        }
419        root = root.subcommand(sub);
420    }
421
422    root
423}
424
425/// `true` when the clap error is a "successful" user-facing output
426/// (help or version) that should return `Ok(())` rather than bubble
427/// up through the diagnostic pipeline.
428fn is_help_or_version(err: &clap::Error) -> bool {
429    use clap::error::ErrorKind;
430    matches!(err.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion)
431}
432
433fn map_clap_error(err: &clap::Error) -> miette::Report {
434    use clap::error::ErrorKind;
435    match err.kind() {
436        ErrorKind::InvalidSubcommand | ErrorKind::UnknownArgument => {
437            let name = err
438                .get(clap::error::ContextKind::InvalidSubcommand)
439                .or_else(|| err.get(clap::error::ContextKind::InvalidArg))
440                .map_or_else(|| err.to_string(), |v| format!("{v}"));
441            rtb_error::Error::CommandNotFound(name).into()
442        }
443        _ => miette::miette!("{}", err),
444    }
445}