1use 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
18pub struct Application {
20 app: App,
21 commands: Vec<Box<dyn RtbCommand>>,
22 install_hooks: bool,
23}
24
25impl Application {
26 pub fn builder() -> ApplicationBuilder<NoMetadata, NoVersion> {
30 ApplicationBuilder::new()
31 }
32
33 pub async fn run(self) -> miette::Result<()> {
36 let args: Vec<OsString> = std::env::args_os().collect();
39 self.run_with_args(args).await
40 }
41
42 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 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 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
89pub struct NoMetadata;
95pub struct HasMetadata(ToolMetadata);
97pub struct NoVersion;
99pub struct HasVersion(VersionInfo);
101
102#[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 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 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 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 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 pub fn assets(mut self, a: Assets) -> Self {
178 self.assets = Some(a);
179 self
180 }
181
182 pub fn features(mut self, f: Features) -> Self {
185 self.features = Some(f);
186 self
187 }
188
189 pub const fn install_hooks(mut self, yes: bool) -> Self {
193 self.install_hooks = yes;
194 self
195 }
196
197 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 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 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 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 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 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 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
304fn 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 .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 sub = sub.arg(
345 clap::Arg::new("rest")
346 .num_args(0..)
347 .trailing_var_arg(true)
348 .allow_hyphen_values(true),
349 );
350 sub = sub.disable_help_flag(true);
354 }
355 root = root.subcommand(sub);
356 }
357
358 root
359}
360
361fn 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}