Skip to main content

ctl_core/
app.rs

1//! Fluent typed CLI lifecycle.
2
3use std::ffi::OsString;
4use std::marker::PhantomData;
5use std::process::ExitCode;
6
7use anyhow::Result;
8#[cfg(feature = "usage")]
9use clap::Command;
10use clap::{CommandFactory, Parser};
11
12use crate::color::ColorMode;
13use crate::format::OutputFormat;
14use crate::render::RenderOptions;
15use crate::view::{JsonLayout, Present, View};
16
17type BeforeParse = Box<dyn Fn(&[OsString]) -> Option<ExitCode>>;
18type SelectView<C> = Box<dyn Fn(&C) -> View>;
19#[cfg(feature = "usage")]
20type RenderUsage = Box<dyn Fn(Command, &str) -> String>;
21
22/// A fallback width the owner chose, including `None` to disable it.
23#[derive(Clone, Copy)]
24struct Fallback(Option<u16>);
25
26/// One ctl process: short-circuits, help, parsing, execution, and presentation.
27pub struct App<C> {
28    bin: String,
29    before_parse: Vec<BeforeParse>,
30    select_view: SelectView<C>,
31    automatic_width_buffer: Option<u16>,
32    automatic_width_buffer_envs: Option<&'static [&'static str]>,
33    minimum_automatic_width: Option<u16>,
34    fallback_width: Option<Fallback>,
35    styles: Option<RenderOptions>,
36    json_layout: Option<JsonLayout>,
37    #[cfg(feature = "usage")]
38    mounted_as: Option<String>,
39    #[cfg(feature = "usage")]
40    render_usage: RenderUsage,
41    marker: PhantomData<C>,
42}
43
44impl<C> App<C> {
45    /// Build a CLI with pretty automatic-color output.
46    #[must_use]
47    pub fn new(bin: impl Into<String>) -> Self {
48        Self {
49            bin: bin.into(),
50            before_parse: Vec::new(),
51            select_view: Box::new(|_| View::new(OutputFormat::Pretty, ColorMode::Auto)),
52            automatic_width_buffer: None,
53            automatic_width_buffer_envs: None,
54            minimum_automatic_width: None,
55            fallback_width: None,
56            styles: None,
57            json_layout: None,
58            #[cfg(feature = "usage")]
59            mounted_as: None,
60            #[cfg(feature = "usage")]
61            render_usage: Box::new(crate::usage::spec),
62            marker: PhantomData,
63        }
64    }
65
66    /// Select output, color, and quiet policy from the parsed CLI.
67    #[must_use]
68    pub fn view(mut self, select: impl Fn(&C) -> View + 'static) -> Self {
69        self.select_view = Box::new(select);
70        self
71    }
72
73    /// Override the automatic-width buffer for help, errors, and command
74    /// output. Zero disables buffering; explicit view widths remain exact.
75    #[must_use]
76    pub fn automatic_width_buffer(mut self, columns: u16) -> Self {
77        self.automatic_width_buffer = Some(columns);
78        self
79    }
80
81    /// Replace the ordered environment names for every automatic-width render.
82    /// An empty slice disables environment lookup.
83    #[must_use]
84    pub fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
85        self.automatic_width_buffer_envs = Some(names);
86        self
87    }
88
89    /// Set the floor for automatically detected effective widths.
90    #[must_use]
91    pub fn minimum_automatic_width(mut self, columns: u16) -> Self {
92        self.minimum_automatic_width = Some(columns);
93        self
94    }
95
96    /// Lay out to `width` when no width is detected; `None` disables it.
97    #[must_use]
98    pub fn fallback_width(mut self, width: Option<u16>) -> Self {
99        self.fallback_width = Some(Fallback(width));
100        self
101    }
102
103    /// Take record style, list style, and row separation from `styles` for
104    /// help, errors, and command output.
105    #[must_use]
106    pub fn styles(mut self, styles: RenderOptions) -> Self {
107        self.styles = Some(styles);
108        self
109    }
110
111    /// Lay out JSON output as `layout`.
112    #[must_use]
113    pub fn json_layout(mut self, layout: JsonLayout) -> Self {
114        self.json_layout = Some(layout);
115        self
116    }
117
118    fn configured_view(&self, mut view: View) -> View {
119        if let Some(buffer) = self.automatic_width_buffer {
120            view = view.automatic_width_buffer(buffer);
121        }
122        if let Some(names) = self.automatic_width_buffer_envs {
123            view = view.automatic_width_buffer_envs(names);
124        }
125        if let Some(minimum) = self.minimum_automatic_width {
126            view = view.minimum_automatic_width(minimum);
127        }
128        if let Some(Fallback(width)) = self.fallback_width {
129            view = view.fallback_width(width);
130        }
131        if let Some(styles) = self.styles {
132            view = view.styles(styles);
133        }
134        if let Some(layout) = self.json_layout {
135            view = view.json_layout(layout);
136        }
137        view
138    }
139
140    /// Add an ordered pre-parse short-circuit, such as dynamic completion.
141    #[must_use]
142    pub fn before_parse(
143        mut self,
144        hook: impl Fn(&[OsString]) -> Option<ExitCode> + 'static,
145    ) -> Self {
146        self.before_parse.push(Box::new(hook));
147        self
148    }
149
150    /// Expose a mise Usage spec under a mounted task name.
151    #[cfg(feature = "usage")]
152    #[must_use]
153    pub fn mounted_as(mut self, task: impl Into<String>) -> Self {
154        self.mounted_as = Some(task.into());
155        self
156    }
157
158    /// Customize the mounted Usage document while keeping App's short-circuit
159    /// and stream ownership. The callback receives the declared Clap graph and
160    /// the requested mounted binary name.
161    #[cfg(feature = "usage")]
162    #[must_use]
163    pub fn usage_spec(mut self, render: impl Fn(Command, &str) -> String + 'static) -> Self {
164        self.render_usage = Box::new(render);
165        self
166    }
167}
168
169impl<C> App<C>
170where
171    C: Parser + CommandFactory,
172{
173    /// Run against process argv.
174    #[must_use = "return it from main, or the command exits 0"]
175    pub fn run<T>(self, execute: impl FnOnce(C) -> Result<T>) -> ExitCode
176    where
177        T: Present,
178    {
179        self.run_from(std::env::args_os(), execute)
180    }
181
182    /// Run against explicit argv. The first item is the binary name.
183    #[must_use = "return it from main, or the command exits 0"]
184    pub fn run_from<T>(
185        self,
186        args: impl IntoIterator<Item = impl Into<OsString>>,
187        execute: impl FnOnce(C) -> Result<T>,
188    ) -> ExitCode
189    where
190        T: Present,
191    {
192        let raw = args.into_iter().map(Into::into).collect::<Vec<_>>();
193        let words = raw
194            .iter()
195            .map(|arg| arg.to_string_lossy().into_owned())
196            .collect::<Vec<_>>();
197
198        #[cfg(feature = "usage")]
199        if let Some(task) = &self.mounted_as
200            && let Some(spec_bin) = crate::usage::spec_bin(words.iter().skip(1), task)
201        {
202            let mut command = C::command();
203            command.set_bin_name(&spec_bin);
204            let spec = (self.render_usage)(command, &spec_bin);
205            return crate::view::write_stdout(spec.as_bytes(), ColorMode::Never)
206                .map_or(ExitCode::FAILURE, |()| ExitCode::SUCCESS);
207        }
208
209        for hook in &self.before_parse {
210            if let Some(code) = hook(&raw) {
211                return code;
212            }
213        }
214
215        let raw_view = self.configured_view(raw_view::<C>(&raw));
216        if words.len() == 1 && crate::parser::requires_input::<C>() {
217            return crate::help::emit_bare_with_options::<C>(raw_view.render_options())
218                .map_or(ExitCode::FAILURE, |()| ExitCode::from(2));
219        }
220        match crate::help::try_emit_from_with_options::<C>(&words, raw_view.render_options()) {
221            Ok(true) => return ExitCode::SUCCESS,
222            Ok(false) => {}
223            Err(_) => return ExitCode::FAILURE,
224        }
225
226        let mut command = crate::parser::apply_defaults(C::command());
227        if raw_view.format.is_json() {
228            command = command.color(clap::ColorChoice::Never);
229        }
230        let matches = match command.try_get_matches_from(&raw) {
231            Ok(matches) => matches,
232            Err(error) => return self.clap_error(&error, raw_view),
233        };
234        let cli = match C::from_arg_matches(&matches) {
235            Ok(cli) => cli,
236            Err(error) => return self.clap_error(&error, raw_view),
237        };
238        let view = self.configured_view((self.select_view)(&cli));
239        match execute(cli) {
240            Ok(value) => view.show(&value).unwrap_or(ExitCode::FAILURE),
241            Err(error) => view
242                .emit_err(&self.bin, &format!("{error:#}"))
243                .unwrap_or(ExitCode::FAILURE),
244        }
245    }
246
247    fn clap_error(&self, error: &clap::Error, view: View) -> ExitCode {
248        let code = exit_code(error.exit_code());
249        if is_clap_display(error.kind()) {
250            let _ = error.print();
251            return code;
252        }
253        view.emit_err(&self.bin, error.to_string().trim())
254            .map_or(ExitCode::FAILURE, |_| code)
255    }
256}
257
258fn raw_view<C: CommandFactory>(raw: &[OsString]) -> View {
259    let parsed = crate::parser::parsed_output::<C>(raw);
260    View::new(parsed.format, parsed.color)
261}
262
263fn is_clap_display(kind: clap::error::ErrorKind) -> bool {
264    matches!(
265        kind,
266        clap::error::ErrorKind::DisplayVersion
267            | clap::error::ErrorKind::DisplayHelp
268            | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
269    )
270}
271
272fn exit_code(code: i32) -> ExitCode {
273    u8::try_from(code).map_or(ExitCode::FAILURE, ExitCode::from)
274}
275
276#[cfg(test)]
277mod tests {
278    use std::cell::Cell;
279    use std::ffi::OsString;
280    use std::rc::Rc;
281
282    use clap::{Parser, Subcommand};
283    use serde::Serialize;
284
285    use super::{App, is_clap_display, raw_view};
286    use crate::document::{Document, Fields};
287    use crate::render::{RecordStyle, RenderOptions};
288    use crate::view::{JsonLayout, Present, View};
289    use crate::{ColorMode, OutputArgs, OutputFormat};
290
291    #[derive(Parser)]
292    #[command(version, about = "toy")]
293    struct Cli {
294        #[command(flatten)]
295        output: OutputArgs,
296        #[command(subcommand)]
297        command: Command,
298    }
299
300    #[derive(Subcommand)]
301    enum Command {
302        /// Show status.
303        Status(StatusArgs),
304    }
305
306    #[derive(clap::Args)]
307    struct StatusArgs {
308        /// Domain text that may begin with a hyphen.
309        #[arg(short = 'm', long, allow_hyphen_values = true)]
310        message: Option<String>,
311    }
312
313    #[derive(Parser)]
314    #[command(version, about = "optional toy")]
315    struct OptionalCli {}
316
317    #[derive(Parser)]
318    #[command(version, about = "nested toy")]
319    struct NestedCli {
320        #[command(subcommand)]
321        command: NestedCommand,
322    }
323
324    #[derive(Subcommand)]
325    enum NestedCommand {
326        /// Group commands.
327        Group {
328            #[command(subcommand)]
329            command: GroupCommand,
330        },
331    }
332
333    #[derive(Subcommand)]
334    enum GroupCommand {
335        /// Show status.
336        Status,
337    }
338
339    #[derive(Serialize)]
340    struct Status {
341        pending: usize,
342    }
343
344    impl Present for Status {
345        fn present(&self) -> Document {
346            Document::new().fields(Fields::new().row("pending", self.pending.to_string()))
347        }
348    }
349
350    #[test]
351    fn parses_executes_and_suppresses_quiet_pretty() {
352        let ran = Rc::new(Cell::new(false));
353        let observed = Rc::clone(&ran);
354        let code = App::<Cli>::new("toy")
355            .view(|cli| {
356                View::new(cli.output.format, cli.output.color())
357                    .quiet(cli.output.quiet)
358                    .width(80)
359            })
360            .run_from(["toy", "status", "--quiet"], move |_| {
361                observed.set(true);
362                Ok(Status { pending: 0 })
363            });
364        assert_eq!(code, std::process::ExitCode::SUCCESS);
365        assert!(ran.get());
366    }
367
368    #[test]
369    fn pre_parse_hook_runs_before_clap() {
370        let code = App::<Cli>::new("toy")
371            .before_parse(|args| {
372                args.iter()
373                    .any(|arg| arg == "--complete")
374                    .then_some(std::process::ExitCode::SUCCESS)
375            })
376            .run_from(["toy", "--complete"], |_| Ok(Status { pending: 0 }));
377        assert_eq!(code, std::process::ExitCode::SUCCESS);
378    }
379
380    #[cfg(feature = "usage")]
381    #[test]
382    fn mounted_usage_accepts_a_custom_renderer() {
383        use std::cell::RefCell;
384
385        let observed = Rc::new(RefCell::new(None));
386        let callback_observed = Rc::clone(&observed);
387        let code = App::<Cli>::new("toy")
388            .mounted_as("q")
389            .usage_spec(move |command, bin| {
390                *callback_observed.borrow_mut() =
391                    Some((bin.to_owned(), command.get_bin_name().map(str::to_owned)));
392                format!("custom {bin}\n")
393            })
394            .run_from(["toy", "--usage-spec=mounted"], |_| {
395                Ok(Status { pending: 0 })
396            });
397        assert_eq!(code, std::process::ExitCode::SUCCESS);
398        assert_eq!(
399            observed.borrow().as_ref(),
400            Some(&("mounted".to_owned(), Some("mounted".to_owned())))
401        );
402    }
403
404    #[test]
405    fn app_width_policy_configures_every_view() {
406        let app = App::<Cli>::new("toy")
407            .automatic_width_buffer(0)
408            .automatic_width_buffer_envs(&["TOY_BUFFER", "LEGACY_BUFFER"])
409            .minimum_automatic_width(8);
410        let view = app.configured_view(View::new(OutputFormat::Pretty, ColorMode::Never));
411        assert_eq!(view.explicit_automatic_width_buffer(), Some(0));
412        assert_eq!(
413            view.automatic_width_buffer_env_names(),
414            ["TOY_BUFFER", "LEGACY_BUFFER"]
415        );
416        assert_eq!(view.automatic_width_minimum(), 8);
417    }
418
419    #[test]
420    fn app_styles_and_fallback_reach_every_view() {
421        let app = App::<Cli>::new("toy")
422            .fallback_width(None)
423            .styles(RenderOptions::new(ColorMode::Never).record_style(RecordStyle::Boxed))
424            .json_layout(JsonLayout::Compact);
425        let options = app
426            .configured_view(View::new(OutputFormat::Pretty, ColorMode::Never))
427            .render_options();
428        assert_eq!(options.fallback(), None);
429        assert_eq!(options.record(), RecordStyle::Boxed);
430        let json = app
431            .configured_view(View::new(OutputFormat::Json, ColorMode::Never))
432            .capture(&Status { pending: 1 })
433            .unwrap();
434        assert_eq!(json.text(), "{\"pending\":1}\n");
435    }
436
437    #[test]
438    fn defaults_are_pretty_auto() {
439        let app = App::<Cli>::new("toy").view(|_| {
440            View::new(OutputFormat::Pretty, ColorMode::Auto)
441                .quiet(true)
442                .width(80)
443        });
444        let code = app.run_from(["toy", "status"], |_| Ok(Status { pending: 0 }));
445        assert_eq!(code, std::process::ExitCode::SUCCESS);
446    }
447
448    #[test]
449    fn clap_help_kinds_stay_on_claps_display_path() {
450        use clap::error::ErrorKind;
451
452        assert!(is_clap_display(ErrorKind::DisplayVersion));
453        assert!(is_clap_display(ErrorKind::DisplayHelp));
454        assert!(is_clap_display(
455            ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
456        ));
457        assert!(!is_clap_display(ErrorKind::UnknownArgument));
458    }
459
460    #[test]
461    fn missing_nested_subcommand_uses_claps_help_exit() {
462        let code =
463            App::<NestedCli>::new("toy").run_from(["toy", "group"], |_| Ok(Status { pending: 0 }));
464        assert_eq!(code, std::process::ExitCode::from(2));
465    }
466
467    #[test]
468    fn bare_invocation_is_usage_error() {
469        let code = App::<Cli>::new("toy").run_from(["toy"], |_| Ok(Status { pending: 0 }));
470        assert_eq!(code, std::process::ExitCode::from(2));
471    }
472
473    #[test]
474    fn bare_invocation_executes_when_the_root_accepts_it() {
475        let ran = Rc::new(Cell::new(false));
476        let observed = Rc::clone(&ran);
477        let code = App::<OptionalCli>::new("toy").run_from(["toy"], move |_| {
478            observed.set(true);
479            Ok(Status { pending: 0 })
480        });
481        assert_eq!(code, std::process::ExitCode::SUCCESS);
482        assert!(ran.get());
483    }
484
485    #[test]
486    fn explicit_help_used_as_a_domain_value_reaches_execution() {
487        let ran = Rc::new(Cell::new(false));
488        let observed = Rc::clone(&ran);
489        let code = App::<Cli>::new("toy").run_from(["toy", "status", "-m", "--help"], move |_| {
490            observed.set(true);
491            Ok(Status { pending: 0 })
492        });
493        assert_eq!(code, std::process::ExitCode::SUCCESS);
494        assert!(ran.get());
495    }
496
497    #[test]
498    fn raw_view_uses_clap_to_separate_globals_from_domain_values() {
499        let domain_value = ["toy", "status", "-m", "--format=json", "--bogus"].map(OsString::from);
500        let view = raw_view::<Cli>(&domain_value);
501        assert_eq!(view.format, OutputFormat::Pretty);
502        assert_eq!(view.color, ColorMode::Auto);
503
504        let global_after_subcommand =
505            ["toy", "status", "-fjson", "-cnever", "--bogus"].map(OsString::from);
506        let view = raw_view::<Cli>(&global_after_subcommand);
507        assert_eq!(view.format, OutputFormat::Json);
508        assert_eq!(view.color, ColorMode::Never);
509    }
510}