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}