Skip to main content

ctl_core/
app.rs

1//! The typed CLI lifecycle: [`App`] parses argv, runs a handler, and writes the
2//! result through a [`View`].
3//!
4//! [`App::run`] reads the process argv and returns the [`ExitCode`] for `main`.
5//! [`App::run_from`] takes explicit argv, which tests use. In order, a run:
6//!
7//! 1. answers a `--usage-spec` request from mise, then every
8//!    [`App::before_parse`] hook;
9//! 2. warns on stderr about an unusable style or layout variable;
10//! 3. prints help, or the bare-command help, when the arguments ask for it;
11//! 4. parses the arguments, with environment variables yielding to flags;
12//! 5. calls the handler and writes its result, or its error, through the view.
13//!
14//! A handler returns a [`Present`] value and prints nothing. Help, errors, and
15//! results share one renderer, one theme, and one width policy.
16//!
17//! # Exit codes
18//!
19//! - A result exits with its [`Present::exit_code`], 0 by default.
20//! - A handler error exits 1.
21//! - A parse error exits with Clap's code, 2 for a usage error.
22//! - A bare invocation of a command that needs a subcommand or an argument
23//!   prints the root help to stderr and exits 2.
24//! - A reader that closed the output stream changes the code only under
25//!   [`BrokenPipe::Fail`].
26
27use std::ffi::OsString;
28use std::fmt;
29use std::io::{self, Write as _};
30use std::marker::PhantomData;
31use std::process::ExitCode;
32
33use anyhow::Result;
34#[cfg(feature = "usage")]
35use clap::Command;
36use clap::{CommandFactory, Parser};
37
38use crate::color::ColorMode;
39use crate::format::OutputFormat;
40use crate::render::RenderOptions;
41use crate::theme::Theme;
42use crate::view::{JsonLayout, Present, View};
43
44type BeforeParse = Box<dyn Fn(&[OsString]) -> Option<ExitCode>>;
45type SelectView<C> = Box<dyn Fn(&C) -> View>;
46#[cfg(feature = "usage")]
47type RenderUsage = Box<dyn Fn(Command, &str) -> String>;
48
49/// A fallback width the CLI chose, including `None` to disable it.
50#[derive(Clone, Copy)]
51struct Fallback(Option<u16>);
52
53/// How [`App`] exits when the reader closes stdout or stderr before the
54/// output is written, as `head -1` does.
55///
56/// A closed pipe never turns a failure into success, and never hides a more
57/// specific code. A command that failed, or a parse error, keeps its own exit
58/// code under either policy. A script never reads a failed run as a passing
59/// one because its reader went away. A check such as `$? -eq 2` behaves the
60/// same under both.
61///
62/// A CLI built on ctl-core ignores SIGPIPE, as every Rust program does by
63/// default. A closed pipe then reaches it as a failed write, not as a signal.
64///
65/// The set is closed, and the enum is not `#[non_exhaustive]`. A run that
66/// failed keeps its code under either policy. So a policy decides only
67/// whether a successful run whose reader left still exits 0.
68#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
69pub enum BrokenPipe {
70    /// Exit with the code the command would have exited with had every byte
71    /// been read, and write nothing to stderr. A command that succeeded
72    /// exits 0: the reader had what it wanted, and a script under
73    /// `set -o pipefail` keeps going.
74    #[default]
75    Ignore,
76    /// Exit 1 when the command succeeded, for a CLI that treats a reader that
77    /// stopped early as an error. A command that
78    /// already failed keeps its own code.
79    Fail,
80}
81
82/// One ctl process: short-circuits, help, parsing, execution, and presentation.
83///
84/// `C` is the CLI's Clap parser. Build an `App` with [`Self::new`],
85/// configure it with the builder methods, and finish with [`Self::run`].
86/// Every builder takes `self`, so a setting set later replaces an earlier one.
87/// The one exception: [`Self::theme`] wins over the theme in [`Self::styles`],
88/// whichever is called first.
89pub struct App<C> {
90    bin: String,
91    before_parse: Vec<BeforeParse>,
92    select_view: SelectView<C>,
93    automatic_width_buffer: Option<u16>,
94    automatic_width_buffer_envs: Option<&'static [&'static str]>,
95    minimum_automatic_width: Option<u16>,
96    fallback_width: Option<Fallback>,
97    styles: Option<RenderOptions>,
98    json_layout: Option<JsonLayout>,
99    json_layout_envs: Option<&'static [&'static str]>,
100    theme: Option<Theme>,
101    broken_pipe: BrokenPipe,
102    #[cfg(feature = "usage")]
103    mounted_as: Option<String>,
104    #[cfg(feature = "usage")]
105    render_usage: RenderUsage,
106    marker: PhantomData<C>,
107}
108
109/// Names the binary only: the hooks are closures, which have no `Debug`.
110impl<C> fmt::Debug for App<C> {
111    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112        formatter
113            .debug_struct("App")
114            .field("bin", &self.bin)
115            .finish_non_exhaustive()
116    }
117}
118
119impl<C> App<C> {
120    /// Build a CLI named `bin` with pretty output and automatic colour.
121    ///
122    /// `bin` prefixes error lines (`bin: message`) and names the binary in
123    /// JSON error envelopes. [`Self::view`] replaces the default view.
124    #[must_use]
125    pub fn new(bin: impl Into<String>) -> Self {
126        Self {
127            bin: bin.into(),
128            before_parse: Vec::new(),
129            select_view: Box::new(|_| View::new(OutputFormat::Pretty, ColorMode::Auto)),
130            automatic_width_buffer: None,
131            automatic_width_buffer_envs: None,
132            minimum_automatic_width: None,
133            fallback_width: None,
134            styles: None,
135            json_layout: None,
136            json_layout_envs: None,
137            theme: None,
138            broken_pipe: BrokenPipe::Ignore,
139            #[cfg(feature = "usage")]
140            mounted_as: None,
141            #[cfg(feature = "usage")]
142            render_usage: Box::new(crate::usage::spec),
143            marker: PhantomData,
144        }
145    }
146
147    /// Pick the [`View`] for a run from the parsed CLI, usually from
148    /// [`OutputArgs`](crate::OutputArgs).
149    ///
150    /// The `select` closure runs after a successful parse. Help and parse
151    /// errors come before it, so they read the format and colour from the raw
152    /// arguments instead. The width, style, and layout settings of this `App`
153    /// are applied on top of the view `select` returns.
154    #[must_use]
155    pub fn view(mut self, select: impl Fn(&C) -> View + 'static) -> Self {
156        self.select_view = Box::new(select);
157        self
158    }
159
160    /// Override the number of cells subtracted from an automatically
161    /// detected width, for help, errors, and command output. Zero disables the
162    /// subtraction. An explicit view width stays exact.
163    #[must_use]
164    pub fn automatic_width_buffer(mut self, cells: u16) -> Self {
165        self.automatic_width_buffer = Some(cells);
166        self
167    }
168
169    /// Replace the ordered environment names that set the buffer, for every
170    /// automatic-width render. An empty slice disables environment lookup.
171    #[must_use]
172    pub fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
173        self.automatic_width_buffer_envs = Some(names);
174        self
175    }
176
177    /// Set the floor, in cells, for an automatically detected width.
178    #[must_use]
179    pub fn minimum_automatic_width(mut self, cells: u16) -> Self {
180        self.minimum_automatic_width = Some(cells);
181        self
182    }
183
184    /// Lay out to `width` cells when no width is detected. `None` turns the
185    /// fallback off, so tables render at their natural width.
186    #[must_use]
187    pub fn fallback_width(mut self, width: Option<u16>) -> Self {
188        self.fallback_width = Some(Fallback(width));
189        self
190    }
191
192    /// Set the process-wide default input limits as soon as it is called, not
193    /// when the `App` runs. The limits of a single call win over them, and so
194    /// does a repository's `.ctl/input.yaml` with `input-yaml`.
195    #[cfg(any(feature = "input-yaml", feature = "input-toml"))]
196    #[must_use]
197    pub fn input_limits(self, limits: crate::input::Limits) -> Self {
198        crate::input::set_default_limits(limits);
199        self
200    }
201
202    /// Take the theme, record style, table frame, row separation, and the
203    /// variables they read from `styles`, for help, errors, and command
204    /// output. Its colour and width settings are ignored.
205    #[must_use]
206    pub fn styles(mut self, styles: RenderOptions) -> Self {
207        self.styles = Some(styles);
208        self
209    }
210
211    /// Lay out JSON output as `layout`. This beats the environment.
212    #[must_use]
213    pub fn json_layout(mut self, layout: JsonLayout) -> Self {
214        self.json_layout = Some(layout);
215        self
216    }
217
218    /// Replace the ordered environment names read for the JSON layout.
219    /// An empty slice disables lookup.
220    #[must_use]
221    pub fn json_layout_envs(mut self, names: &'static [&'static str]) -> Self {
222        self.json_layout_envs = Some(names);
223        self
224    }
225
226    /// Draw help, errors, and command output in `theme`, in place of
227    /// [`Theme::default`]. It also replaces the theme a [`Self::styles`]
228    /// value carries. Colour is still the colour mode's choice:
229    /// `--color never` writes no style.
230    #[must_use]
231    pub fn theme(mut self, theme: Theme) -> Self {
232        self.theme = Some(theme);
233        self
234    }
235
236    /// Exit as `policy` says when the reader closes stdout or stderr before
237    /// the output is written. The default, [`BrokenPipe::Ignore`], exits
238    /// with the command's own code and writes nothing to stderr.
239    #[must_use]
240    pub fn broken_pipe(mut self, policy: BrokenPipe) -> Self {
241        self.broken_pipe = policy;
242        self
243    }
244
245    /// The exit code once output meant to end with `code` was written.
246    /// `written` is the result of the write.
247    ///
248    /// `code` is the answer when the write went through, and a failure when it
249    /// did not. When the reader closed the stream, [`BrokenPipe::Ignore`]
250    /// keeps `code`. [`BrokenPipe::Fail`] keeps a failing `code` and turns
251    /// success into a failure. Nothing is reported on stderr, which may be the
252    /// stream that closed.
253    fn written(&self, written: io::Result<()>, code: ExitCode) -> ExitCode {
254        match written {
255            Ok(()) => code,
256            Err(error) if error.kind() == io::ErrorKind::BrokenPipe => match self.broken_pipe {
257                BrokenPipe::Fail if code == ExitCode::SUCCESS => ExitCode::FAILURE,
258                BrokenPipe::Ignore | BrokenPipe::Fail => code,
259            },
260            Err(_) => ExitCode::FAILURE,
261        }
262    }
263
264    fn configured_view(&self, mut view: View) -> View {
265        if let Some(buffer) = self.automatic_width_buffer {
266            view = view.automatic_width_buffer(buffer);
267        }
268        if let Some(names) = self.automatic_width_buffer_envs {
269            view = view.automatic_width_buffer_envs(names);
270        }
271        if let Some(minimum) = self.minimum_automatic_width {
272            view = view.minimum_automatic_width(minimum);
273        }
274        if let Some(Fallback(width)) = self.fallback_width {
275            view = view.fallback_width(width);
276        }
277        if let Some(styles) = self.styles {
278            view = view.styles(styles);
279        }
280        if let Some(theme) = self.theme {
281            view = view.theme(theme);
282        }
283        if let Some(layout) = self.json_layout {
284            view = view.json_layout(layout);
285        }
286        if let Some(names) = self.json_layout_envs {
287            view = view.json_layout_envs(names);
288        }
289        view
290    }
291
292    /// Add a hook that runs before parsing and may end the run.
293    ///
294    /// A hook gets the raw argv, program name first. Returning `Some(code)`
295    /// stops the run with that code. Hooks run in the order added, after a
296    /// `--usage-spec` request and before help and parsing.
297    #[must_use]
298    pub fn before_parse(
299        mut self,
300        hook: impl Fn(&[OsString]) -> Option<ExitCode> + 'static,
301    ) -> Self {
302        self.before_parse.push(Box::new(hook));
303        self
304    }
305
306    /// Answer `COMPLETE=<shell>` before parsing, so the script the
307    /// `completion` verb prints can ask this binary for candidates.
308    ///
309    /// A run without the variable, or with it empty or `0`, parses as usual.
310    /// See [the `completion` module](crate::completion).
311    #[cfg(feature = "completion")]
312    #[must_use]
313    pub fn completion(self) -> Self
314    where
315        C: CommandFactory + 'static,
316    {
317        let bin = self.bin.clone();
318        self.before_parse(move |args| crate::completion::complete::<C>(&bin, args))
319    }
320
321    /// Answer a `--usage-spec` request from mise for the task named `task`.
322    ///
323    /// When argv matches, the run prints the Usage spec to stdout and exits 0
324    /// without parsing. See [the `usage` module](crate::usage) for the argv
325    /// shape.
326    #[cfg(feature = "usage")]
327    #[must_use]
328    pub fn mounted_as(mut self, task: impl Into<String>) -> Self {
329        self.mounted_as = Some(task.into());
330        self
331    }
332
333    /// Replace how the Usage document is rendered, keeping the
334    /// short-circuit and the stdout write. The `render` callback receives the
335    /// declared Clap command and the requested task name, and
336    /// returns the document text.
337    #[cfg(feature = "usage")]
338    #[must_use]
339    pub fn usage_spec(mut self, render: impl Fn(Command, &str) -> String + 'static) -> Self {
340        self.render_usage = Box::new(render);
341        self
342    }
343}
344
345impl<C> App<C>
346where
347    C: Parser + CommandFactory,
348{
349    /// Run against the process argv and return the exit code for `main`.
350    ///
351    /// The handler `execute` gets the parsed CLI. An `Err` from it is written
352    /// as an error (`bin: message`, or a JSON envelope) with exit code 1. Its
353    /// message includes the whole `anyhow` chain.
354    ///
355    /// # Panics
356    ///
357    /// Panics when the command disables `-h/--help` or `-V/--version`.
358    #[must_use = "return it from main, or the command exits 0"]
359    pub fn run<T>(self, execute: impl FnOnce(C) -> Result<T>) -> ExitCode
360    where
361        T: Present,
362    {
363        self.run_from(std::env::args_os(), execute)
364    }
365
366    /// Run against explicit argv. The first item is the binary name.
367    ///
368    /// The result is the same as [`Self::run`]. Use it in tests with a fixed
369    /// argv.
370    ///
371    /// # Panics
372    ///
373    /// Panics when the command disables `-h/--help` or `-V/--version`.
374    #[must_use = "return it from main, or the command exits 0"]
375    pub fn run_from<T>(
376        self,
377        args: impl IntoIterator<Item = impl Into<OsString>>,
378        execute: impl FnOnce(C) -> Result<T>,
379    ) -> ExitCode
380    where
381        T: Present,
382    {
383        let raw = args.into_iter().map(Into::into).collect::<Vec<_>>();
384        let words = raw
385            .iter()
386            .map(|arg| arg.to_string_lossy().into_owned())
387            .collect::<Vec<_>>();
388
389        #[cfg(feature = "usage")]
390        if let Some(task) = &self.mounted_as
391            && let Some(spec_bin) = crate::usage::spec_bin(words.iter().skip(1), task)
392        {
393            let mut command = C::command();
394            command.set_bin_name(&spec_bin);
395            let spec = (self.render_usage)(command, &spec_bin);
396            return self.written(
397                crate::view::write_stdout(spec.as_bytes()),
398                ExitCode::SUCCESS,
399            );
400        }
401
402        for hook in &self.before_parse {
403            if let Some(code) = hook(&raw) {
404                return code;
405            }
406        }
407
408        let raw_view = self.configured_view(raw_view::<C>(&raw));
409        for warning in raw_view.env_warnings(crate::render::process_env) {
410            let _ = writeln!(std::io::stderr().lock(), "{}: warning: {warning}", self.bin);
411        }
412        if words.len() == 1 && crate::parser::requires_input::<C>() {
413            return self.written(
414                crate::help::emit_bare_with_options::<C>(raw_view.render_options()),
415                ExitCode::from(2),
416            );
417        }
418        match crate::help::try_emit_from_with_options::<C>(&words, raw_view.render_options()) {
419            Ok(true) => return ExitCode::SUCCESS,
420            Ok(false) => {}
421            Err(error) => return self.written(Err(error), ExitCode::SUCCESS),
422        }
423
424        let command = crate::parser::apply_defaults(C::command());
425        let command = match crate::flags::env_under_flags(command, &raw) {
426            Ok(command) => command,
427            Err(error) => {
428                return self.clap_error(&error, &parse_error_view(&raw, raw_view), &words);
429            }
430        };
431        let matches = match command.try_get_matches_from(&raw) {
432            Ok(matches) => matches,
433            Err(error) => {
434                return self.clap_error(&error, &parse_error_view(&raw, raw_view), &words);
435            }
436        };
437        let cli = match C::from_arg_matches(&matches) {
438            Ok(cli) => cli,
439            Err(error) => {
440                return self.clap_error(&error, &parse_error_view(&raw, raw_view), &words);
441            }
442        };
443        let view = self.configured_view((self.select_view)(&cli));
444        match execute(cli) {
445            Ok(value) => self.written(
446                view.show(&value).map(|_| ()),
447                ExitCode::from(value.exit_code()),
448            ),
449            Err(error) => self.written(
450                view.emit_err(&self.bin, &format!("{error:#}")).map(|_| ()),
451                ExitCode::FAILURE,
452            ),
453        }
454    }
455
456    /// Report a parse error, or print the help or version Clap stopped for.
457    /// Help always comes from ctl-core's renderer, so it needs none of Clap's
458    /// `help` or `color` features.
459    fn clap_error(&self, error: &clap::Error, view: &View, args: &[String]) -> ExitCode {
460        use clap::error::ErrorKind;
461
462        let code = exit_code(error.exit_code());
463        let kind = error.kind();
464        if !is_clap_display(kind) {
465            return self.written(
466                view.emit_err(&self.bin, error.to_string().trim())
467                    .map(|_| ()),
468                code,
469            );
470        }
471        let options = view.render_options();
472        let written = if kind == ErrorKind::DisplayVersion {
473            crate::view::write_stdout(error.to_string().as_bytes())
474        } else if kind == ErrorKind::DisplayHelp {
475            crate::help::emit_selected::<C>(args, options)
476        } else {
477            crate::help::emit_selected_to_stderr::<C>(args, options)
478        };
479        self.written(written, code)
480    }
481}
482
483fn raw_view<C: CommandFactory>(raw: &[OsString]) -> View {
484    let parsed = crate::parser::parsed_output::<C>(raw);
485    View::new(parsed.format, parsed.color)
486}
487
488/// The view for a parse error.
489///
490/// Clap's lenient read stops at the bad argument. A `--format json` after it
491/// goes unseen. The error would then print as text, coloured when
492/// `CLICOLOR_FORCE` or `FORCE_COLOR` is set, to a caller that asked for JSON
493/// and pipes it to `jq`.
494///
495/// So a long `--format json` or `--format=json` before `--` makes the error
496/// JSON, which never carries ANSI. The last `--format` value Clap accepts
497/// wins in either direction, as it does in a full parse, so a later
498/// `--format pretty` undoes an earlier JSON choice.
499///
500/// The short `-f` is left out, because a CLI may bind it to another
501/// flag. The scan is lexical, so a domain option whose value happens to be
502/// `--format=json` also gets a JSON error. That only changes how a failed
503/// parse is reported.
504fn parse_error_view(raw: &[OsString], mut view: View) -> View {
505    let mut args = raw.iter().skip(1).map(|arg| arg.to_str());
506    let mut last = None;
507    while let Some(arg) = args.next() {
508        let value = match arg {
509            Some("--") => break,
510            Some("--format") => args.next().flatten(),
511            Some(arg) => arg.strip_prefix("--format="),
512            None => None,
513        };
514        if let Some(format) =
515            value.and_then(|value| <OutputFormat as clap::ValueEnum>::from_str(value, false).ok())
516        {
517            last = Some(format);
518        }
519    }
520    if let Some(format) = last {
521        view = view.in_format(format);
522        if format.is_json() {
523            view = view.color(ColorMode::Never);
524        }
525    }
526    view
527}
528
529fn is_clap_display(kind: clap::error::ErrorKind) -> bool {
530    matches!(
531        kind,
532        clap::error::ErrorKind::DisplayVersion
533            | clap::error::ErrorKind::DisplayHelp
534            | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
535    )
536}
537
538fn exit_code(code: i32) -> ExitCode {
539    u8::try_from(code).map_or(ExitCode::FAILURE, ExitCode::from)
540}
541
542#[cfg(test)]
543mod tests {
544    use std::cell::Cell;
545    use std::ffi::OsString;
546    use std::rc::Rc;
547
548    use clap::{Parser, Subcommand};
549    use serde::Serialize;
550
551    use super::{App, BrokenPipe, is_clap_display, parse_error_view, raw_view};
552    use crate::document::{Document, Fields};
553    use crate::render::{RecordStyle, RenderOptions};
554    use crate::view::{JsonLayout, Present, View};
555    use crate::{ColorMode, OutputArgs, OutputFormat, Role, Theme};
556
557    #[derive(Parser)]
558    #[command(version, about = "toy")]
559    struct Cli {
560        #[command(flatten)]
561        output: OutputArgs,
562        #[command(subcommand)]
563        command: Command,
564    }
565
566    #[derive(Subcommand)]
567    enum Command {
568        /// Show status.
569        Status(StatusArgs),
570    }
571
572    #[derive(clap::Args)]
573    struct StatusArgs {
574        /// Domain text that may begin with a hyphen.
575        #[arg(short = 'm', long, allow_hyphen_values = true)]
576        message: Option<String>,
577    }
578
579    #[derive(Parser)]
580    #[command(version, about = "optional toy")]
581    struct OptionalCli {}
582
583    #[derive(Parser)]
584    #[command(version, about = "nested toy")]
585    struct NestedCli {
586        #[command(subcommand)]
587        command: NestedCommand,
588    }
589
590    #[derive(Subcommand)]
591    enum NestedCommand {
592        /// Group commands.
593        Group {
594            #[command(subcommand)]
595            command: GroupCommand,
596        },
597    }
598
599    #[derive(Subcommand)]
600    enum GroupCommand {
601        /// Show status.
602        Status,
603    }
604
605    #[derive(Serialize)]
606    struct Status {
607        pending: usize,
608    }
609
610    impl Present for Status {
611        fn present(&self) -> Document {
612            Document::new().fields(Fields::new().row("pending", self.pending.to_string()))
613        }
614    }
615
616    #[test]
617    fn parses_executes_and_suppresses_quiet_pretty() {
618        let ran = Rc::new(Cell::new(false));
619        let observed = Rc::clone(&ran);
620        let code = App::<Cli>::new("toy")
621            .view(|cli| {
622                View::new(cli.output.format, cli.output.color())
623                    .quiet(cli.output.quiet)
624                    .width(80)
625            })
626            .run_from(["toy", "status", "--quiet"], move |_| {
627                observed.set(true);
628                Ok(Status { pending: 0 })
629            });
630        assert_eq!(code, std::process::ExitCode::SUCCESS);
631        assert!(ran.get());
632    }
633
634    /// A bad argument before `--format json` still gets a JSON error, so
635    /// machine output stays plain whatever the colour environment says.
636    #[test]
637    fn a_parse_error_honours_a_later_json_format() {
638        let pretty = View::new(OutputFormat::Pretty, ColorMode::Always);
639        let cases = [
640            (&["toy", "status", "--bogus", "--format", "json"][..], true),
641            (&["toy", "status", "--bogus", "--format=json"][..], true),
642            (&["toy", "status", "--bogus"][..], false),
643            (
644                &["toy", "status", "--bogus", "--", "--format", "json"][..],
645                false,
646            ),
647            (
648                &["toy", "status", "--bogus", "--format", "pretty"][..],
649                false,
650            ),
651            (
652                &["toy", "--bogus", "--format", "json", "--format", "pretty"][..],
653                false,
654            ),
655            (
656                &["toy", "--bogus", "--format=pretty", "--format", "json"][..],
657                true,
658            ),
659        ];
660        for (argv, json) in cases {
661            let raw: Vec<OsString> = argv.iter().map(OsString::from).collect();
662            let view = parse_error_view(&raw, pretty);
663            assert_eq!(view.format().is_json(), json, "{argv:?}");
664            if json {
665                assert_eq!(
666                    view.render_options(),
667                    RenderOptions::new(ColorMode::Never),
668                    "{argv:?}"
669                );
670            }
671        }
672        let json_first = View::new(OutputFormat::Json, ColorMode::Never);
673        let raw: Vec<OsString> = ["toy", "--format", "json", "--bogus", "--format", "pretty"]
674            .iter()
675            .map(OsString::from)
676            .collect();
677        assert!(
678            !parse_error_view(&raw, json_first).format().is_json(),
679            "a later --format pretty undoes the pre-parse's JSON"
680        );
681    }
682
683    #[test]
684    fn pre_parse_hook_runs_before_clap() {
685        let code = App::<Cli>::new("toy")
686            .before_parse(|args| {
687                args.iter()
688                    .any(|arg| arg == "--complete")
689                    .then_some(std::process::ExitCode::SUCCESS)
690            })
691            .run_from(["toy", "--complete"], |_| Ok(Status { pending: 0 }));
692        assert_eq!(code, std::process::ExitCode::SUCCESS);
693    }
694
695    #[cfg(feature = "usage")]
696    #[test]
697    fn mounted_usage_accepts_a_custom_renderer() {
698        use std::cell::RefCell;
699
700        let observed = Rc::new(RefCell::new(None));
701        let callback_observed = Rc::clone(&observed);
702        let code = App::<Cli>::new("toy")
703            .mounted_as("q")
704            .usage_spec(move |command, bin| {
705                *callback_observed.borrow_mut() =
706                    Some((bin.to_owned(), command.get_bin_name().map(str::to_owned)));
707                format!("custom {bin}\n")
708            })
709            .run_from(["toy", "--usage-spec=mounted"], |_| {
710                Ok(Status { pending: 0 })
711            });
712        assert_eq!(code, std::process::ExitCode::SUCCESS);
713        assert_eq!(
714            observed.borrow().as_ref(),
715            Some(&("mounted".to_owned(), Some("mounted".to_owned())))
716        );
717    }
718
719    #[test]
720    fn app_width_policy_configures_every_view() {
721        let app = App::<Cli>::new("toy")
722            .automatic_width_buffer(0)
723            .automatic_width_buffer_envs(&["TOY_BUFFER", "LEGACY_BUFFER"])
724            .minimum_automatic_width(8);
725        let view = app.configured_view(View::new(OutputFormat::Pretty, ColorMode::Never));
726        assert_eq!(
727            view.render_options(),
728            RenderOptions::new(ColorMode::Never)
729                .automatic_width_buffer(0)
730                .automatic_width_buffer_envs(&["TOY_BUFFER", "LEGACY_BUFFER"])
731                .minimum_automatic_width(8)
732        );
733    }
734
735    #[test]
736    fn app_json_layout_envs_reach_every_view() {
737        let compact = |name: &str| (name == "TOY_JSON").then(|| "compact".to_owned());
738        let view = App::<Cli>::new("toy")
739            .json_layout_envs(&["TOY_JSON"])
740            .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
741        assert_eq!(view.json_layout_with(compact), JsonLayout::Compact);
742        let disabled = App::<Cli>::new("toy")
743            .json_layout_envs(&[])
744            .configured_view(View::new(OutputFormat::Json, ColorMode::Never));
745        assert_eq!(disabled.json_layout_with(compact), JsonLayout::Pretty);
746    }
747
748    #[test]
749    fn app_styles_and_fallback_reach_every_view() {
750        let app = App::<Cli>::new("toy")
751            .fallback_width(None)
752            .styles(RenderOptions::new(ColorMode::Never).record_style(RecordStyle::Boxed))
753            .json_layout(JsonLayout::Compact);
754        let options = app
755            .configured_view(View::new(OutputFormat::Pretty, ColorMode::Never))
756            .render_options();
757        assert_eq!(
758            options,
759            RenderOptions::new(ColorMode::Never)
760                .fallback_width(None)
761                .record_style(RecordStyle::Boxed)
762        );
763        assert_eq!(options.resolved_record_style(), RecordStyle::Boxed);
764        let json = app
765            .configured_view(View::new(OutputFormat::Json, ColorMode::Never))
766            .capture(&Status { pending: 1 })
767            .unwrap();
768        assert_eq!(json.text(), "{\"pending\":1}\n");
769    }
770
771    #[test]
772    fn app_theme_reaches_every_view_over_the_styles_theme() {
773        let underline = ctl_text::Style::new().effects(ctl_text::anstyle::Effects::UNDERLINE);
774        let theme = Theme::new().style(Role::Heading, underline);
775        let app = App::<Cli>::new("toy")
776            .theme(theme)
777            .styles(RenderOptions::new(ColorMode::Never).record_style(RecordStyle::Boxed));
778        let options = app
779            .configured_view(View::new(OutputFormat::Pretty, ColorMode::Always))
780            .render_options();
781        assert_eq!(
782            options,
783            RenderOptions::new(ColorMode::Always)
784                .theme(theme)
785                .record_style(RecordStyle::Boxed)
786        );
787        assert_eq!(options.resolved_record_style(), RecordStyle::Boxed);
788    }
789
790    #[test]
791    fn a_closed_pipe_exits_as_the_policy_says() {
792        use std::process::ExitCode;
793
794        let closed = || Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe));
795        let other = || Err(std::io::Error::other("disk full"));
796        let ignore = App::<Cli>::new("toy");
797        assert_eq!(
798            ignore.written(closed(), ExitCode::SUCCESS),
799            ExitCode::SUCCESS
800        );
801        assert_eq!(
802            ignore.written(closed(), ExitCode::from(2)),
803            ExitCode::from(2)
804        );
805        assert_eq!(
806            ignore.written(closed(), ExitCode::FAILURE),
807            ExitCode::FAILURE
808        );
809        assert_eq!(
810            ignore.written(other(), ExitCode::SUCCESS),
811            ExitCode::FAILURE
812        );
813        assert_eq!(ignore.written(Ok(()), ExitCode::from(2)), ExitCode::from(2));
814        let fail = App::<Cli>::new("toy").broken_pipe(BrokenPipe::Fail);
815        assert_eq!(fail.written(closed(), ExitCode::SUCCESS), ExitCode::FAILURE);
816        assert_eq!(fail.written(closed(), ExitCode::from(2)), ExitCode::from(2));
817        assert_eq!(fail.written(closed(), ExitCode::FAILURE), ExitCode::FAILURE);
818    }
819
820    #[test]
821    fn defaults_are_pretty_auto() {
822        let app = App::<Cli>::new("toy").view(|_| {
823            View::new(OutputFormat::Pretty, ColorMode::Auto)
824                .quiet(true)
825                .width(80)
826        });
827        let code = app.run_from(["toy", "status"], |_| Ok(Status { pending: 0 }));
828        assert_eq!(code, std::process::ExitCode::SUCCESS);
829    }
830
831    #[test]
832    fn clap_help_kinds_stay_on_claps_display_path() {
833        use clap::error::ErrorKind;
834
835        assert!(is_clap_display(ErrorKind::DisplayVersion));
836        assert!(is_clap_display(ErrorKind::DisplayHelp));
837        assert!(is_clap_display(
838            ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
839        ));
840        assert!(!is_clap_display(ErrorKind::UnknownArgument));
841    }
842
843    #[test]
844    fn missing_nested_subcommand_uses_claps_help_exit() {
845        let code =
846            App::<NestedCli>::new("toy").run_from(["toy", "group"], |_| Ok(Status { pending: 0 }));
847        assert_eq!(code, std::process::ExitCode::from(2));
848    }
849
850    #[test]
851    fn bare_invocation_is_usage_error() {
852        let code = App::<Cli>::new("toy").run_from(["toy"], |_| Ok(Status { pending: 0 }));
853        assert_eq!(code, std::process::ExitCode::from(2));
854    }
855
856    #[test]
857    fn bare_invocation_executes_when_the_root_accepts_it() {
858        let ran = Rc::new(Cell::new(false));
859        let observed = Rc::clone(&ran);
860        let code = App::<OptionalCli>::new("toy").run_from(["toy"], move |_| {
861            observed.set(true);
862            Ok(Status { pending: 0 })
863        });
864        assert_eq!(code, std::process::ExitCode::SUCCESS);
865        assert!(ran.get());
866    }
867
868    #[test]
869    fn explicit_help_used_as_a_domain_value_reaches_execution() {
870        let ran = Rc::new(Cell::new(false));
871        let observed = Rc::clone(&ran);
872        let code = App::<Cli>::new("toy").run_from(["toy", "status", "-m", "--help"], move |_| {
873            observed.set(true);
874            Ok(Status { pending: 0 })
875        });
876        assert_eq!(code, std::process::ExitCode::SUCCESS);
877        assert!(ran.get());
878    }
879
880    #[test]
881    fn raw_view_uses_clap_to_separate_globals_from_domain_values() {
882        let domain_value = ["toy", "status", "-m", "--format=json", "--bogus"].map(OsString::from);
883        let view = raw_view::<Cli>(&domain_value);
884        assert_eq!(view.format(), OutputFormat::Pretty);
885        assert_eq!(view.render_options(), RenderOptions::new(ColorMode::Auto));
886
887        let global_after_subcommand =
888            ["toy", "status", "-fjson", "-cnever", "--bogus"].map(OsString::from);
889        let view = raw_view::<Cli>(&global_after_subcommand);
890        assert_eq!(view.format(), OutputFormat::Json);
891        assert_eq!(view.render_options(), RenderOptions::new(ColorMode::Never));
892    }
893}