Skip to main content

ctl_core/
app.rs

1//! Fluent typed CLI lifecycle.
2
3use std::ffi::OsString;
4use std::fmt;
5use std::marker::PhantomData;
6use std::process::ExitCode;
7
8use anyhow::Result;
9#[cfg(feature = "usage")]
10use clap::Command;
11use clap::{CommandFactory, Parser};
12
13use crate::color::ColorMode;
14use crate::format::OutputFormat;
15use crate::render::RenderOptions;
16use crate::view::{JsonLayout, Present, View};
17
18type BeforeParse = Box<dyn Fn(&[OsString]) -> Option<ExitCode>>;
19type SelectView<C> = Box<dyn Fn(&C) -> View>;
20#[cfg(feature = "usage")]
21type RenderUsage = Box<dyn Fn(Command, &str) -> String>;
22
23/// A fallback width the owner chose, including `None` to disable it.
24#[derive(Clone, Copy)]
25struct Fallback(Option<u16>);
26
27/// One ctl process: short-circuits, help, parsing, execution, and presentation.
28pub struct App<C> {
29    bin: String,
30    before_parse: Vec<BeforeParse>,
31    select_view: SelectView<C>,
32    automatic_width_buffer: Option<u16>,
33    automatic_width_buffer_envs: Option<&'static [&'static str]>,
34    minimum_automatic_width: Option<u16>,
35    fallback_width: Option<Fallback>,
36    styles: Option<RenderOptions>,
37    json_layout: Option<JsonLayout>,
38    json_layout_envs: Option<&'static [&'static str]>,
39    #[cfg(feature = "usage")]
40    mounted_as: Option<String>,
41    #[cfg(feature = "usage")]
42    render_usage: RenderUsage,
43    marker: PhantomData<C>,
44}
45
46/// Names the binary only: the hooks are closures, which have no `Debug`.
47impl<C> fmt::Debug for App<C> {
48    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49        formatter
50            .debug_struct("App")
51            .field("bin", &self.bin)
52            .finish_non_exhaustive()
53    }
54}
55
56impl<C> App<C> {
57    /// Build a CLI with pretty automatic-color output.
58    #[must_use]
59    pub fn new(bin: impl Into<String>) -> Self {
60        Self {
61            bin: bin.into(),
62            before_parse: Vec::new(),
63            select_view: Box::new(|_| View::new(OutputFormat::Pretty, ColorMode::Auto)),
64            automatic_width_buffer: None,
65            automatic_width_buffer_envs: None,
66            minimum_automatic_width: None,
67            fallback_width: None,
68            styles: None,
69            json_layout: None,
70            json_layout_envs: None,
71            #[cfg(feature = "usage")]
72            mounted_as: None,
73            #[cfg(feature = "usage")]
74            render_usage: Box::new(crate::usage::spec),
75            marker: PhantomData,
76        }
77    }
78
79    /// Select output, color, and quiet policy from the parsed CLI.
80    #[must_use]
81    pub fn view(mut self, select: impl Fn(&C) -> View + 'static) -> Self {
82        self.select_view = Box::new(select);
83        self
84    }
85
86    /// Override the automatic-width buffer for help, errors, and command
87    /// output. Zero disables buffering; explicit view widths remain exact.
88    #[must_use]
89    pub fn automatic_width_buffer(mut self, columns: u16) -> Self {
90        self.automatic_width_buffer = Some(columns);
91        self
92    }
93
94    /// Replace the ordered environment names for every automatic-width render.
95    /// An empty slice disables environment lookup.
96    #[must_use]
97    pub fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
98        self.automatic_width_buffer_envs = Some(names);
99        self
100    }
101
102    /// Set the floor for automatically detected effective widths.
103    #[must_use]
104    pub fn minimum_automatic_width(mut self, columns: u16) -> Self {
105        self.minimum_automatic_width = Some(columns);
106        self
107    }
108
109    /// Lay out to `width` when no width is detected; `None` disables it.
110    #[must_use]
111    pub fn fallback_width(mut self, width: Option<u16>) -> Self {
112        self.fallback_width = Some(Fallback(width));
113        self
114    }
115
116    /// Set the process-wide input limits when called. A repository's
117    /// `.ctl/input.yaml` and a single call's limits both beat them.
118    #[cfg(feature = "input")]
119    #[must_use]
120    pub fn input_limits(self, limits: crate::input::Limits) -> Self {
121        crate::input::set_default_limits(limits);
122        self
123    }
124
125    /// Take record style, list style, and row separation from `styles` for
126    /// help, errors, and command output.
127    #[must_use]
128    pub fn styles(mut self, styles: RenderOptions) -> Self {
129        self.styles = Some(styles);
130        self
131    }
132
133    /// Lay out JSON output as `layout`.
134    #[must_use]
135    pub fn json_layout(mut self, layout: JsonLayout) -> Self {
136        self.json_layout = Some(layout);
137        self
138    }
139
140    /// Replace the ordered environment names read for the JSON layout.
141    /// An empty slice disables lookup.
142    #[must_use]
143    pub fn json_layout_envs(mut self, names: &'static [&'static str]) -> Self {
144        self.json_layout_envs = Some(names);
145        self
146    }
147
148    fn configured_view(&self, mut view: View) -> View {
149        if let Some(buffer) = self.automatic_width_buffer {
150            view = view.automatic_width_buffer(buffer);
151        }
152        if let Some(names) = self.automatic_width_buffer_envs {
153            view = view.automatic_width_buffer_envs(names);
154        }
155        if let Some(minimum) = self.minimum_automatic_width {
156            view = view.minimum_automatic_width(minimum);
157        }
158        if let Some(Fallback(width)) = self.fallback_width {
159            view = view.fallback_width(width);
160        }
161        if let Some(styles) = self.styles {
162            view = view.styles(styles);
163        }
164        if let Some(layout) = self.json_layout {
165            view = view.json_layout(layout);
166        }
167        if let Some(names) = self.json_layout_envs {
168            view = view.json_layout_envs(names);
169        }
170        view
171    }
172
173    /// Add an ordered pre-parse short-circuit, such as dynamic completion.
174    #[must_use]
175    pub fn before_parse(
176        mut self,
177        hook: impl Fn(&[OsString]) -> Option<ExitCode> + 'static,
178    ) -> Self {
179        self.before_parse.push(Box::new(hook));
180        self
181    }
182
183    /// Expose a mise Usage spec under a mounted task name.
184    #[cfg(feature = "usage")]
185    #[must_use]
186    pub fn mounted_as(mut self, task: impl Into<String>) -> Self {
187        self.mounted_as = Some(task.into());
188        self
189    }
190
191    /// Customize the mounted Usage document while keeping App's short-circuit
192    /// and stream ownership. The callback receives the declared Clap graph and
193    /// the requested mounted binary name.
194    #[cfg(feature = "usage")]
195    #[must_use]
196    pub fn usage_spec(mut self, render: impl Fn(Command, &str) -> String + 'static) -> Self {
197        self.render_usage = Box::new(render);
198        self
199    }
200}
201
202impl<C> App<C>
203where
204    C: Parser + CommandFactory,
205{
206    /// Run against process argv.
207    #[must_use = "return it from main, or the command exits 0"]
208    pub fn run<T>(self, execute: impl FnOnce(C) -> Result<T>) -> ExitCode
209    where
210        T: Present,
211    {
212        self.run_from(std::env::args_os(), execute)
213    }
214
215    /// Run against explicit argv. The first item is the binary name.
216    #[must_use = "return it from main, or the command exits 0"]
217    pub fn run_from<T>(
218        self,
219        args: impl IntoIterator<Item = impl Into<OsString>>,
220        execute: impl FnOnce(C) -> Result<T>,
221    ) -> ExitCode
222    where
223        T: Present,
224    {
225        let raw = args.into_iter().map(Into::into).collect::<Vec<_>>();
226        let words = raw
227            .iter()
228            .map(|arg| arg.to_string_lossy().into_owned())
229            .collect::<Vec<_>>();
230
231        #[cfg(feature = "usage")]
232        if let Some(task) = &self.mounted_as
233            && let Some(spec_bin) = crate::usage::spec_bin(words.iter().skip(1), task)
234        {
235            let mut command = C::command();
236            command.set_bin_name(&spec_bin);
237            let spec = (self.render_usage)(command, &spec_bin);
238            return crate::view::write_stdout(spec.as_bytes(), ColorMode::Never)
239                .map_or(ExitCode::FAILURE, |()| ExitCode::SUCCESS);
240        }
241
242        for hook in &self.before_parse {
243            if let Some(code) = hook(&raw) {
244                return code;
245            }
246        }
247
248        let raw_view = self.configured_view(raw_view::<C>(&raw));
249        for warning in raw_view.env_warnings(crate::render::process_env) {
250            eprintln!("{}: warning: {warning}", self.bin);
251        }
252        if words.len() == 1 && crate::parser::requires_input::<C>() {
253            return crate::help::emit_bare_with_options::<C>(raw_view.render_options())
254                .map_or(ExitCode::FAILURE, |()| ExitCode::from(2));
255        }
256        match crate::help::try_emit_from_with_options::<C>(&words, raw_view.render_options()) {
257            Ok(true) => return ExitCode::SUCCESS,
258            Ok(false) => {}
259            Err(_) => return ExitCode::FAILURE,
260        }
261
262        let mut command = crate::parser::apply_defaults(C::command());
263        if raw_view.format.is_json() {
264            command = command.color(clap::ColorChoice::Never);
265        }
266        let command = match crate::flags::env_under_flags(command, &raw) {
267            Ok(command) => command,
268            Err(error) => return self.clap_error(&error, parse_error_view(&raw, raw_view)),
269        };
270        let matches = match command.try_get_matches_from(&raw) {
271            Ok(matches) => matches,
272            Err(error) => return self.clap_error(&error, parse_error_view(&raw, raw_view)),
273        };
274        let cli = match C::from_arg_matches(&matches) {
275            Ok(cli) => cli,
276            Err(error) => return self.clap_error(&error, parse_error_view(&raw, raw_view)),
277        };
278        let view = self.configured_view((self.select_view)(&cli));
279        match execute(cli) {
280            Ok(value) => view.show(&value).unwrap_or(ExitCode::FAILURE),
281            Err(error) => view
282                .emit_err(&self.bin, &format!("{error:#}"))
283                .unwrap_or(ExitCode::FAILURE),
284        }
285    }
286
287    fn clap_error(&self, error: &clap::Error, view: View) -> ExitCode {
288        let code = exit_code(error.exit_code());
289        if is_clap_display(error.kind()) {
290            let _ = error.print();
291            return code;
292        }
293        view.emit_err(&self.bin, error.to_string().trim())
294            .map_or(ExitCode::FAILURE, |_| code)
295    }
296}
297
298fn raw_view<C: CommandFactory>(raw: &[OsString]) -> View {
299    let parsed = crate::parser::parsed_output::<C>(raw);
300    View::new(parsed.format, parsed.color)
301}
302
303/// The view for a parse error. Clap's lenient read stops at the bad argument,
304/// so a `--format json` after it goes unseen, and the error would print as
305/// text, coloured when `CLICOLOR_FORCE` or `FORCE_COLOR` is set, to a caller
306/// that asked for JSON and pipes it to `jq`. A long `--format json` or
307/// `--format=json` before `--` makes the error JSON, which never carries
308/// ANSI. The last `--format` value Clap accepts wins in either direction, as
309/// it does in a full parse, so a later `--format pretty` undoes an earlier
310/// JSON choice. The short `-f`
311/// is left out: a consumer may bind it to another flag. The scan is lexical,
312/// so a domain option whose value happens to be `--format=json` also gets a
313/// JSON error; that only changes how a failed parse is reported.
314fn parse_error_view(raw: &[OsString], mut view: View) -> View {
315    let mut args = raw.iter().skip(1).map(|arg| arg.to_str());
316    let mut last = None;
317    while let Some(arg) = args.next() {
318        let value = match arg {
319            Some("--") => break,
320            Some("--format") => args.next().flatten(),
321            Some(arg) => arg.strip_prefix("--format="),
322            None => None,
323        };
324        if let Some(format) =
325            value.and_then(|value| <OutputFormat as clap::ValueEnum>::from_str(value, false).ok())
326        {
327            last = Some(format);
328        }
329    }
330    if let Some(format) = last {
331        view.format = format;
332        if format.is_json() {
333            view.color = ColorMode::Never;
334        }
335    }
336    view
337}
338
339fn is_clap_display(kind: clap::error::ErrorKind) -> bool {
340    matches!(
341        kind,
342        clap::error::ErrorKind::DisplayVersion
343            | clap::error::ErrorKind::DisplayHelp
344            | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
345    )
346}
347
348fn exit_code(code: i32) -> ExitCode {
349    u8::try_from(code).map_or(ExitCode::FAILURE, ExitCode::from)
350}
351
352#[cfg(test)]
353mod tests {
354    use std::cell::Cell;
355    use std::ffi::OsString;
356    use std::rc::Rc;
357
358    use clap::{Parser, Subcommand};
359    use serde::Serialize;
360
361    use super::{App, is_clap_display, parse_error_view, raw_view};
362    use crate::document::{Document, Fields};
363    use crate::render::{RecordStyle, RenderOptions};
364    use crate::view::{JsonLayout, Present, View};
365    use crate::{ColorMode, OutputArgs, OutputFormat};
366
367    #[derive(Parser)]
368    #[command(version, about = "toy")]
369    struct Cli {
370        #[command(flatten)]
371        output: OutputArgs,
372        #[command(subcommand)]
373        command: Command,
374    }
375
376    #[derive(Subcommand)]
377    enum Command {
378        /// Show status.
379        Status(StatusArgs),
380    }
381
382    #[derive(clap::Args)]
383    struct StatusArgs {
384        /// Domain text that may begin with a hyphen.
385        #[arg(short = 'm', long, allow_hyphen_values = true)]
386        message: Option<String>,
387    }
388
389    #[derive(Parser)]
390    #[command(version, about = "optional toy")]
391    struct OptionalCli {}
392
393    #[derive(Parser)]
394    #[command(version, about = "nested toy")]
395    struct NestedCli {
396        #[command(subcommand)]
397        command: NestedCommand,
398    }
399
400    #[derive(Subcommand)]
401    enum NestedCommand {
402        /// Group commands.
403        Group {
404            #[command(subcommand)]
405            command: GroupCommand,
406        },
407    }
408
409    #[derive(Subcommand)]
410    enum GroupCommand {
411        /// Show status.
412        Status,
413    }
414
415    #[derive(Serialize)]
416    struct Status {
417        pending: usize,
418    }
419
420    impl Present for Status {
421        fn present(&self) -> Document {
422            Document::new().fields(Fields::new().row("pending", self.pending.to_string()))
423        }
424    }
425
426    #[test]
427    fn parses_executes_and_suppresses_quiet_pretty() {
428        let ran = Rc::new(Cell::new(false));
429        let observed = Rc::clone(&ran);
430        let code = App::<Cli>::new("toy")
431            .view(|cli| {
432                View::new(cli.output.format, cli.output.color())
433                    .quiet(cli.output.quiet)
434                    .width(80)
435            })
436            .run_from(["toy", "status", "--quiet"], move |_| {
437                observed.set(true);
438                Ok(Status { pending: 0 })
439            });
440        assert_eq!(code, std::process::ExitCode::SUCCESS);
441        assert!(ran.get());
442    }
443
444    /// A bad argument before `--format json` still gets a JSON error, so
445    /// machine output stays plain whatever the colour environment says.
446    #[test]
447    fn a_parse_error_honours_a_later_json_format() {
448        let pretty = View::new(OutputFormat::Pretty, ColorMode::Always);
449        let cases = [
450            (&["toy", "status", "--bogus", "--format", "json"][..], true),
451            (&["toy", "status", "--bogus", "--format=json"][..], true),
452            (&["toy", "status", "--bogus"][..], false),
453            (
454                &["toy", "status", "--bogus", "--", "--format", "json"][..],
455                false,
456            ),
457            (
458                &["toy", "status", "--bogus", "--format", "pretty"][..],
459                false,
460            ),
461            (
462                &["toy", "--bogus", "--format", "json", "--format", "pretty"][..],
463                false,
464            ),
465            (
466                &["toy", "--bogus", "--format=pretty", "--format", "json"][..],
467                true,
468            ),
469        ];
470        for (argv, json) in cases {
471            let raw: Vec<OsString> = argv.iter().map(OsString::from).collect();
472            let view = parse_error_view(&raw, pretty);
473            assert_eq!(view.format.is_json(), json, "{argv:?}");
474            if json {
475                assert_eq!(view.color, ColorMode::Never, "{argv:?}");
476            }
477        }
478        let json_first = View::new(OutputFormat::Json, ColorMode::Never);
479        let raw: Vec<OsString> = ["toy", "--format", "json", "--bogus", "--format", "pretty"]
480            .iter()
481            .map(OsString::from)
482            .collect();
483        assert!(
484            !parse_error_view(&raw, json_first).format.is_json(),
485            "a later --format pretty undoes the pre-parse's JSON"
486        );
487    }
488
489    #[test]
490    fn pre_parse_hook_runs_before_clap() {
491        let code = App::<Cli>::new("toy")
492            .before_parse(|args| {
493                args.iter()
494                    .any(|arg| arg == "--complete")
495                    .then_some(std::process::ExitCode::SUCCESS)
496            })
497            .run_from(["toy", "--complete"], |_| Ok(Status { pending: 0 }));
498        assert_eq!(code, std::process::ExitCode::SUCCESS);
499    }
500
501    #[cfg(feature = "usage")]
502    #[test]
503    fn mounted_usage_accepts_a_custom_renderer() {
504        use std::cell::RefCell;
505
506        let observed = Rc::new(RefCell::new(None));
507        let callback_observed = Rc::clone(&observed);
508        let code = App::<Cli>::new("toy")
509            .mounted_as("q")
510            .usage_spec(move |command, bin| {
511                *callback_observed.borrow_mut() =
512                    Some((bin.to_owned(), command.get_bin_name().map(str::to_owned)));
513                format!("custom {bin}\n")
514            })
515            .run_from(["toy", "--usage-spec=mounted"], |_| {
516                Ok(Status { pending: 0 })
517            });
518        assert_eq!(code, std::process::ExitCode::SUCCESS);
519        assert_eq!(
520            observed.borrow().as_ref(),
521            Some(&("mounted".to_owned(), Some("mounted".to_owned())))
522        );
523    }
524
525    #[test]
526    fn app_width_policy_configures_every_view() {
527        let app = App::<Cli>::new("toy")
528            .automatic_width_buffer(0)
529            .automatic_width_buffer_envs(&["TOY_BUFFER", "LEGACY_BUFFER"])
530            .minimum_automatic_width(8);
531        let view = app.configured_view(View::new(OutputFormat::Pretty, ColorMode::Never));
532        assert_eq!(view.explicit_automatic_width_buffer(), Some(0));
533        assert_eq!(
534            view.automatic_width_buffer_env_names(),
535            ["TOY_BUFFER", "LEGACY_BUFFER"]
536        );
537        assert_eq!(view.automatic_width_minimum(), 8);
538    }
539
540    #[test]
541    fn app_json_layout_envs_reach_every_view() {
542        let compact = |name: &str| (name == "TOY_JSON").then(|| "compact".to_owned());
543        let view = App::<Cli>::new("toy")
544            .json_layout_envs(&["TOY_JSON"])
545            .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
546        assert_eq!(view.layout_with(compact), JsonLayout::Compact);
547        let disabled = App::<Cli>::new("toy")
548            .json_layout_envs(&[])
549            .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
550        assert_eq!(disabled.layout_with(compact), JsonLayout::Pretty);
551    }
552
553    #[test]
554    fn app_styles_and_fallback_reach_every_view() {
555        let app = App::<Cli>::new("toy")
556            .fallback_width(None)
557            .styles(RenderOptions::new(ColorMode::Never).record_style(RecordStyle::Boxed))
558            .json_layout(JsonLayout::Compact);
559        let options = app
560            .configured_view(View::new(OutputFormat::Pretty, ColorMode::Never))
561            .render_options();
562        assert_eq!(options.fallback(), None);
563        assert_eq!(options.record(), RecordStyle::Boxed);
564        let json = app
565            .configured_view(View::new(OutputFormat::Json, ColorMode::Never))
566            .capture(&Status { pending: 1 })
567            .unwrap();
568        assert_eq!(json.text(), "{\"pending\":1}\n");
569    }
570
571    #[test]
572    fn defaults_are_pretty_auto() {
573        let app = App::<Cli>::new("toy").view(|_| {
574            View::new(OutputFormat::Pretty, ColorMode::Auto)
575                .quiet(true)
576                .width(80)
577        });
578        let code = app.run_from(["toy", "status"], |_| Ok(Status { pending: 0 }));
579        assert_eq!(code, std::process::ExitCode::SUCCESS);
580    }
581
582    #[test]
583    fn clap_help_kinds_stay_on_claps_display_path() {
584        use clap::error::ErrorKind;
585
586        assert!(is_clap_display(ErrorKind::DisplayVersion));
587        assert!(is_clap_display(ErrorKind::DisplayHelp));
588        assert!(is_clap_display(
589            ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
590        ));
591        assert!(!is_clap_display(ErrorKind::UnknownArgument));
592    }
593
594    #[test]
595    fn missing_nested_subcommand_uses_claps_help_exit() {
596        let code =
597            App::<NestedCli>::new("toy").run_from(["toy", "group"], |_| Ok(Status { pending: 0 }));
598        assert_eq!(code, std::process::ExitCode::from(2));
599    }
600
601    #[test]
602    fn bare_invocation_is_usage_error() {
603        let code = App::<Cli>::new("toy").run_from(["toy"], |_| Ok(Status { pending: 0 }));
604        assert_eq!(code, std::process::ExitCode::from(2));
605    }
606
607    #[test]
608    fn bare_invocation_executes_when_the_root_accepts_it() {
609        let ran = Rc::new(Cell::new(false));
610        let observed = Rc::clone(&ran);
611        let code = App::<OptionalCli>::new("toy").run_from(["toy"], move |_| {
612            observed.set(true);
613            Ok(Status { pending: 0 })
614        });
615        assert_eq!(code, std::process::ExitCode::SUCCESS);
616        assert!(ran.get());
617    }
618
619    #[test]
620    fn explicit_help_used_as_a_domain_value_reaches_execution() {
621        let ran = Rc::new(Cell::new(false));
622        let observed = Rc::clone(&ran);
623        let code = App::<Cli>::new("toy").run_from(["toy", "status", "-m", "--help"], move |_| {
624            observed.set(true);
625            Ok(Status { pending: 0 })
626        });
627        assert_eq!(code, std::process::ExitCode::SUCCESS);
628        assert!(ran.get());
629    }
630
631    #[test]
632    fn raw_view_uses_clap_to_separate_globals_from_domain_values() {
633        let domain_value = ["toy", "status", "-m", "--format=json", "--bogus"].map(OsString::from);
634        let view = raw_view::<Cli>(&domain_value);
635        assert_eq!(view.format, OutputFormat::Pretty);
636        assert_eq!(view.color, ColorMode::Auto);
637
638        let global_after_subcommand =
639            ["toy", "status", "-fjson", "-cnever", "--bogus"].map(OsString::from);
640        let view = raw_view::<Cli>(&global_after_subcommand);
641        assert_eq!(view.format, OutputFormat::Json);
642        assert_eq!(view.color, ColorMode::Never);
643    }
644}