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