Skip to main content

ctl_core/
surface.rs

1//! A CLI's commands and flags as plain data, taken from its Clap types.
2//!
3//! Clap remains the command grammar. [`Surface`] lists what a user of the CLI
4//! sees, for generated documentation files and the tests that compare them
5//! with the CLI. It holds the binary name, the mise task name, every command
6//! and alias, every argument, the Usage KDL, and the `#USAGE mount` line (see
7//! [`usage`]).
8//!
9//! The `surface-model` feature builds a [`Surface`]. `surface-serde` adds
10//! serialization. `surface-templates` adds the shared `minijinja` fragments
11//! and the [`render`] function. A generated documentation file is then a
12//! render of the CLI's own Clap types.
13#![cfg_attr(
14    not(feature = "surface-templates"),
15    doc = "",
16    doc = "[`render`]: crate#cargo-features"
17)]
18
19use std::collections::{BTreeMap, BTreeSet};
20
21use clap::{Arg, Command};
22
23use crate::usage;
24
25#[cfg(feature = "surface-templates")]
26mod templates;
27#[cfg(feature = "surface-templates")]
28pub use templates::{
29    COMMANDS_FRAGMENT, INVOCATION_FRAGMENT, VERSION_FRAGMENT, add_fragments, environment, render,
30};
31
32/// What a user sees of one Clap command tree.
33///
34/// Build it with [`Surface::new`] or [`Surface::from_command`]. It is plain
35/// data: a test compares a render of it with a committed file, and a template
36/// reads its fields. A field can join it in a minor release.
37#[derive(Clone, Debug, Eq, PartialEq)]
38#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
39#[non_exhaustive]
40pub struct Surface {
41    /// Executable name declared by the root Clap command.
42    pub binary: String,
43    /// The mise task name that runs the CLI.
44    pub mount: String,
45    /// Root command description.
46    pub about: String,
47    /// Root package version, when Clap declares one.
48    pub version: Option<String>,
49    /// Arguments and flags declared directly on the root, in Clap order.
50    pub arguments: Vec<SurfaceArgument>,
51    /// Ancestor global arguments, always empty for the root.
52    pub inherited_arguments: Vec<SurfaceArgument>,
53    /// Root subcommands in Clap declaration order, including hidden commands.
54    pub commands: Vec<SurfaceCommand>,
55    /// Usage KDL for the mise task name.
56    pub usage_kdl: String,
57    /// The exact `#USAGE mount` line for the mise task.
58    pub mount_line: String,
59    /// Prose the CLI adds by name, for its documentation templates.
60    pub notes: BTreeMap<String, String>,
61}
62
63/// One command in a [`Surface`], which [`Surface::new`] builds. A field can
64/// join it in a minor release.
65#[derive(Clone, Debug, Eq, PartialEq)]
66#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
67#[non_exhaustive]
68pub struct SurfaceCommand {
69    /// Command name.
70    pub name: String,
71    /// Full command path below the root.
72    pub path: String,
73    /// All declared aliases, including hidden aliases.
74    pub aliases: Vec<String>,
75    /// Aliases Clap exposes in help and completion.
76    pub visible_aliases: Vec<String>,
77    /// Whether Clap hides this command.
78    pub hidden: bool,
79    /// Command description.
80    pub about: String,
81    /// Arguments and flags declared directly on this command, in Clap order.
82    ///
83    /// Ancestor globals remain on their declaring command instead of being
84    /// duplicated into every descendant.
85    pub arguments: Vec<SurfaceArgument>,
86    /// Ancestor global arguments this command also accepts, root-first.
87    pub inherited_arguments: Vec<SurfaceArgument>,
88    /// Nested subcommands in Clap declaration order.
89    pub commands: Vec<Self>,
90}
91
92/// One positional argument or flag in a [`Surface`], which [`Surface::new`]
93/// builds. A field can join it in a minor release.
94#[derive(Clone, Debug, Eq, PartialEq)]
95#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
96#[non_exhaustive]
97pub struct SurfaceArgument {
98    /// Clap argument identifier.
99    pub id: String,
100    /// Positional index, absent for options and flags.
101    pub index: Option<usize>,
102    /// Short flag name.
103    pub short: Option<char>,
104    /// Long flag name without leading dashes.
105    pub long: Option<String>,
106    /// Visible short aliases.
107    pub visible_short_aliases: Vec<char>,
108    /// All short aliases, including hidden aliases.
109    pub short_aliases: Vec<char>,
110    /// Visible long aliases.
111    pub visible_aliases: Vec<String>,
112    /// All long aliases, including hidden aliases.
113    pub aliases: Vec<String>,
114    /// Value names shown by Clap.
115    pub value_names: Vec<String>,
116    /// Argument description.
117    pub help: String,
118    /// Whether Clap requires the argument.
119    pub requirement: SurfaceRequirement,
120    /// Whether Clap propagates the argument to subcommands.
121    pub scope: SurfaceScope,
122    /// Whether Clap hides the argument.
123    pub hidden: bool,
124    /// Whether the argument action accepts values.
125    pub takes_values: bool,
126}
127
128/// Whether Clap requires an argument, as [`SurfaceArgument::requirement`]
129/// reports.
130///
131/// The set is closed, and the enum is not `#[non_exhaustive]`: Clap marks an
132/// argument required or leaves it optional.
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
135#[cfg_attr(feature = "surface-serde", serde(rename_all = "snake_case"))]
136pub enum SurfaceRequirement {
137    /// The invocation can omit this argument.
138    Optional,
139    /// The invocation must provide this argument.
140    Required,
141}
142
143/// How far Clap propagates an argument, as [`SurfaceArgument::scope`]
144/// reports.
145///
146/// The set is closed, and the enum is not `#[non_exhaustive]`: Clap marks an
147/// argument global or leaves it on its own command.
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149#[cfg_attr(feature = "surface-serde", derive(serde::Serialize))]
150#[cfg_attr(feature = "surface-serde", serde(rename_all = "snake_case"))]
151pub enum SurfaceScope {
152    /// The argument belongs only to its declaring command.
153    Local,
154    /// The argument remains available to nested commands.
155    Global,
156}
157
158impl Surface {
159    /// The surface of the Clap parser type `C`.
160    ///
161    /// `mount` is the mise task name that runs the CLI, such as `toy` in
162    /// `mise run toy status`. See [`Self::from_command`].
163    #[must_use]
164    pub fn new<C: clap::CommandFactory>(mount: impl Into<String>) -> Self {
165        Self::from_command(C::command(), mount)
166    }
167
168    /// The surface of a Clap command tree.
169    ///
170    /// `mount` is the mise task name that runs the CLI. The Usage KDL and the
171    /// `#USAGE mount` line use it. Hidden commands are listed with `hidden`
172    /// set. The Usage KDL holds the `-h/--help` flag and `help` subcommand
173    /// ctl-core declares on every command, as
174    /// [`apply_defaults`](crate::parser::apply_defaults) does for parsing. The
175    /// lists of arguments and commands leave them out.
176    #[must_use]
177    pub fn from_command(command: Command, mount: impl Into<String>) -> Self {
178        let mut declared_arguments = BTreeMap::new();
179        let mut help_settings = BTreeMap::new();
180        collect_declarations(&command, "", &mut declared_arguments, &mut help_settings);
181        let mut command = crate::parser::with_help(command);
182        command.build();
183        let command = restore_help_settings(command, "", &help_settings);
184        let mount = mount.into();
185        let binary = command.get_name().to_owned();
186        let about = command
187            .get_about()
188            .map(ToString::to_string)
189            .unwrap_or_default();
190        let version = command.get_version().map(ToOwned::to_owned);
191        let arguments = declared_arguments_for(&command, "", &declared_arguments);
192        let inherited_arguments = arguments
193            .iter()
194            .filter(|argument| argument.scope == SurfaceScope::Global)
195            .cloned()
196            .collect::<Vec<_>>();
197        let commands = command
198            .get_subcommands()
199            .filter(|child| declared_arguments.contains_key(child.get_name()))
200            .map(|child| surface_command(child, "", &declared_arguments, &inherited_arguments))
201            .collect();
202        let usage_kdl = usage::spec(command, &mount);
203        let mount_line = usage::mount_line(&mount);
204        Self {
205            binary,
206            mount,
207            about,
208            version,
209            arguments,
210            inherited_arguments: Vec::new(),
211            commands,
212            usage_kdl,
213            mount_line,
214            notes: BTreeMap::new(),
215        }
216    }
217
218    /// Add the CLI's own prose under `name`, for one template section or
219    /// reader. A template reads it as `surface.notes.<name>`. A later note of
220    /// the same name replaces an earlier one.
221    #[must_use]
222    pub fn note(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
223        self.notes.insert(name.into(), value.into());
224        self
225    }
226
227    /// Long flags that have no short, as `(command path, long name)`.
228    ///
229    /// The root path is empty. Inherited globals are not repeated on children.
230    /// Hidden flags are included so a long-only option cannot hide from the
231    /// check. [`FormatLong`](crate::flags::FormatLong) and
232    /// [`ColorLong`](crate::flags::ColorLong) leave `--format` / `--color`
233    /// without shorts on purpose; pass those names to [`Self::require_shorts`].
234    #[must_use]
235    pub fn long_options_without_short(&self) -> Vec<(String, String)> {
236        let mut missing = Vec::new();
237        collect_missing_shorts("", &self.arguments, &mut missing);
238        for command in &self.commands {
239            collect_command_missing_shorts(command, &mut missing);
240        }
241        missing
242    }
243
244    /// Fail when a long option has no visible short, except tokens in
245    /// `allow`.
246    ///
247    /// A root allowance is the displayed long option (`--format`). A nested
248    /// allowance includes its command path (`status --archived`). ctl-core's
249    /// shared flags that leave a letter free for the CLI on purpose
250    /// (`--format` for `-f`/`--file`, `--color` for `-c`) belong there.
251    ///
252    /// # Errors
253    ///
254    /// Returns a message that lists every long option without a short,
255    /// outside `allow`.
256    pub fn require_shorts<'a>(
257        &self,
258        allow: impl IntoIterator<Item = &'a str>,
259    ) -> Result<(), String> {
260        let allowed: BTreeSet<&str> = allow.into_iter().collect();
261        let missing: Vec<(String, String)> = self
262            .long_options_without_short()
263            .into_iter()
264            .filter(|(path, long)| !allowed.contains(scoped_long(path, long).as_str()))
265            .collect();
266        if missing.is_empty() {
267            return Ok(());
268        }
269        let listing = missing
270            .iter()
271            .map(|(path, long)| {
272                if path.is_empty() {
273                    format!("--{long}")
274                } else {
275                    format!("{path} --{long}")
276                }
277            })
278            .collect::<Vec<_>>()
279            .join(", ");
280        Err(format!("long option has no short: {listing}"))
281    }
282}
283
284fn scoped_long(path: &str, long: &str) -> String {
285    if path.is_empty() {
286        format!("--{long}")
287    } else {
288        format!("{path} --{long}")
289    }
290}
291
292fn collect_missing_shorts(
293    path: &str,
294    arguments: &[SurfaceArgument],
295    missing: &mut Vec<(String, String)>,
296) {
297    for argument in arguments {
298        if let Some(long) = &argument.long
299            && argument.short.is_none()
300            && argument.visible_short_aliases.is_empty()
301        {
302            missing.push((path.to_owned(), long.clone()));
303        }
304    }
305}
306
307fn collect_command_missing_shorts(command: &SurfaceCommand, missing: &mut Vec<(String, String)>) {
308    collect_missing_shorts(&command.path, &command.arguments, missing);
309    for child in &command.commands {
310        collect_command_missing_shorts(child, missing);
311    }
312}
313
314fn surface_command(
315    command: &Command,
316    parent: &str,
317    declared_arguments: &BTreeMap<String, BTreeSet<String>>,
318    inherited_arguments: &[SurfaceArgument],
319) -> SurfaceCommand {
320    let name = command.get_name().to_owned();
321    let path = if parent.is_empty() {
322        name.clone()
323    } else {
324        format!("{parent} {name}")
325    };
326    let arguments = declared_arguments_for(command, &path, declared_arguments);
327    let mut child_inherited_arguments = inherited_arguments.to_vec();
328    child_inherited_arguments.extend(
329        arguments
330            .iter()
331            .filter(|argument| argument.scope == SurfaceScope::Global)
332            .cloned(),
333    );
334    SurfaceCommand {
335        name,
336        path: path.clone(),
337        aliases: command.get_all_aliases().map(ToOwned::to_owned).collect(),
338        visible_aliases: command
339            .get_visible_aliases()
340            .map(ToOwned::to_owned)
341            .collect(),
342        hidden: command.is_hide_set(),
343        about: command
344            .get_about()
345            .map(ToString::to_string)
346            .unwrap_or_default(),
347        arguments,
348        inherited_arguments: inherited_arguments.to_vec(),
349        commands: command
350            .get_subcommands()
351            .filter(|child| {
352                declared_arguments.contains_key(&format!("{path} {}", child.get_name()))
353            })
354            .map(|child| {
355                surface_command(child, &path, declared_arguments, &child_inherited_arguments)
356            })
357            .collect(),
358    }
359}
360
361fn declared_arguments_for(
362    command: &Command,
363    path: &str,
364    declared_arguments: &BTreeMap<String, BTreeSet<String>>,
365) -> Vec<SurfaceArgument> {
366    command
367        .get_arguments()
368        .filter(|argument| declared_argument(declared_arguments, path, argument))
369        .map(argument)
370        .collect()
371}
372
373fn collect_declarations(
374    command: &Command,
375    path: &str,
376    declared_arguments: &mut BTreeMap<String, BTreeSet<String>>,
377    help_settings: &mut BTreeMap<String, HelpSettings>,
378) {
379    declared_arguments.insert(
380        path.to_owned(),
381        command
382            .get_arguments()
383            .map(|argument| argument.get_id().to_string())
384            .collect(),
385    );
386    let owns_help_flag = command
387        .get_arguments()
388        .any(|argument| argument.get_id() == "help");
389    let owns_help_subcommand = command.find_subcommand("help").is_some();
390    help_settings.insert(
391        path.to_owned(),
392        HelpSettings {
393            flag_off: command.is_disable_help_flag_set() && !owns_help_flag,
394            subcommand_off: command.is_disable_help_subcommand_set() && !owns_help_subcommand,
395        },
396    );
397    for child in command
398        .get_subcommands()
399        .filter(|child| !crate::parser::is_generated_help(child))
400    {
401        let child_path = child_path(path, child.get_name());
402        collect_declarations(child, &child_path, declared_arguments, help_settings);
403    }
404}
405
406fn child_path(path: &str, name: &str) -> String {
407    if path.is_empty() {
408        name.to_owned()
409    } else {
410        format!("{path} {name}")
411    }
412}
413
414/// Whether a command turned off its help flag and its `help` subcommand.
415///
416/// Clap's build turns both off on every command when its `help` feature is
417/// off. ctl-core turns them off where it declares its own. Only the command
418/// as declared holds what the CLI turned off. A command that already holds a
419/// `help` argument or subcommand did not turn that one off.
420#[derive(Clone, Copy, Debug)]
421struct HelpSettings {
422    flag_off: bool,
423    subcommand_off: bool,
424}
425
426/// `command` after its build, with the help settings each declared command
427/// had put back, so the Usage KDL records only what the CLI turned off. The
428/// `help` subcommands ctl-core generates were not declared, and keep the
429/// settings that turn help off, as Clap's own do.
430fn restore_help_settings(
431    command: Command,
432    path: &str,
433    help_settings: &BTreeMap<String, HelpSettings>,
434) -> Command {
435    let command = match help_settings.get(path) {
436        Some(declared) => command
437            .disable_help_flag(declared.flag_off)
438            .disable_help_subcommand(declared.subcommand_off),
439        None => command,
440    };
441    command.mut_subcommands(|child| {
442        let child_path = child_path(path, child.get_name());
443        restore_help_settings(child, &child_path, help_settings)
444    })
445}
446
447fn declared_argument(
448    declared_arguments: &BTreeMap<String, BTreeSet<String>>,
449    path: &str,
450    argument: &Arg,
451) -> bool {
452    declared_arguments
453        .get(path)
454        .is_some_and(|arguments| arguments.contains(argument.get_id().as_str()))
455}
456
457fn argument(argument: &Arg) -> SurfaceArgument {
458    SurfaceArgument {
459        id: argument.get_id().to_string(),
460        index: argument.get_index(),
461        short: argument.get_short(),
462        long: argument.get_long().map(ToOwned::to_owned),
463        visible_short_aliases: argument.get_visible_short_aliases().unwrap_or_default(),
464        short_aliases: argument.get_all_short_aliases().unwrap_or_default(),
465        visible_aliases: argument
466            .get_visible_aliases()
467            .unwrap_or_default()
468            .iter()
469            .map(|alias| (*alias).to_owned())
470            .collect(),
471        aliases: argument
472            .get_all_aliases()
473            .unwrap_or_default()
474            .iter()
475            .map(|alias| (*alias).to_owned())
476            .collect(),
477        value_names: argument
478            .get_value_names()
479            .unwrap_or_default()
480            .iter()
481            .map(ToString::to_string)
482            .collect(),
483        help: argument
484            .get_help()
485            .map(ToString::to_string)
486            .unwrap_or_default(),
487        requirement: if argument.is_required_set() {
488            SurfaceRequirement::Required
489        } else {
490            SurfaceRequirement::Optional
491        },
492        scope: if argument.is_global_set() {
493            SurfaceScope::Global
494        } else {
495            SurfaceScope::Local
496        },
497        hidden: argument.is_hide_set(),
498        takes_values: argument.get_action().takes_values(),
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use clap::{ArgAction, CommandFactory, Parser, Subcommand};
505    #[cfg(feature = "surface-templates")]
506    use indoc::indoc;
507    #[cfg(feature = "surface-templates")]
508    use serde::Serialize;
509
510    #[cfg(feature = "surface-templates")]
511    use super::render;
512    use super::{Surface, SurfaceArgument, SurfaceScope};
513
514    #[derive(Parser)]
515    #[command(name = "toy", version = "1.2.3", about = "Control toys")]
516    struct Cli {
517        #[arg(short, long, global = true, help = "Select a profile")]
518        profile: Option<String>,
519        #[command(subcommand)]
520        command: Commands,
521    }
522
523    #[derive(Subcommand)]
524    enum Commands {
525        /// Show current state.
526        #[command(alias = "state", visible_alias = "ls")]
527        Status {
528            /// Include archived records.
529            #[arg(long, action = ArgAction::SetTrue)]
530            archived: bool,
531        },
532        /// Mutate one item.
533        Item {
534            #[command(subcommand)]
535            command: ItemCommand,
536        },
537        #[command(hide = true)]
538        Internal,
539    }
540
541    #[derive(Subcommand)]
542    enum ItemCommand {
543        /// Create one item.
544        Add {
545            /// Item name.
546            name: String,
547        },
548    }
549
550    #[test]
551    fn extracts_the_complete_clap_surface() {
552        let surface = Surface::new::<Cli>("t");
553        assert_eq!(surface.binary, "toy");
554        assert_eq!(surface.mount, "t");
555        assert_eq!(surface.version.as_deref(), Some("1.2.3"));
556        assert_eq!(surface.about, "Control toys");
557        assert_eq!(
558            surface.inherited_arguments.as_slice(),
559            <&[SurfaceArgument]>::default()
560        );
561        assert!(surface.usage_kdl.contains("status"));
562        assert_eq!(
563            surface.mount_line,
564            r#"#USAGE mount "mise run --quiet t -- --usage-spec=t""#
565        );
566        let status = &surface.commands[0];
567        assert_eq!(status.visible_aliases, ["ls"]);
568        assert_eq!(status.aliases, ["state", "ls"]);
569        assert_eq!(status.about, "Show current state");
570        assert_eq!(status.arguments[0].long.as_deref(), Some("archived"));
571        assert!(!status.arguments[0].takes_values);
572        assert_eq!(status.arguments.len(), 1);
573        assert_eq!(status.inherited_arguments.len(), 1);
574        assert_eq!(
575            status.inherited_arguments[0].long.as_deref(),
576            Some("profile")
577        );
578        assert_eq!(status.inherited_arguments[0].scope, SurfaceScope::Global);
579        assert!(
580            status
581                .arguments
582                .iter()
583                .all(|argument| !matches!(argument.id.as_str(), "help" | "version" | "profile"))
584        );
585        let item = &surface.commands[1];
586        assert_eq!(item.commands[0].path, "item add");
587        assert_eq!(item.commands[0].arguments[0].index, Some(1));
588        assert_eq!(item.commands[0].inherited_arguments.len(), 1);
589        assert_eq!(
590            item.commands[0].inherited_arguments[0].long.as_deref(),
591            Some("profile")
592        );
593        assert!(surface.commands[2].hidden);
594        assert!(
595            surface
596                .arguments
597                .iter()
598                .any(|arg| arg.long.as_deref() == Some("profile"))
599        );
600        assert!(
601            surface
602                .arguments
603                .iter()
604                .all(|argument| !matches!(argument.id.as_str(), "help" | "version"))
605        );
606        let noted = surface.note("skill", "Prefer the mounted task.");
607        assert_eq!(noted.notes["skill"], "Prefer the mounted task.");
608    }
609
610    /// The `-h/--help` flags and `help` command lines in a Usage KDL.
611    fn help_lines(usage: &str) -> (usize, usize) {
612        let lines = usage.lines().map(str::trim_start);
613        let flags = lines
614            .clone()
615            .filter(|line| line.starts_with(r#"flag "-h --help""#) && line.contains("action=help"))
616            .count();
617        let commands = lines.filter(|line| line.starts_with("cmd help ")).count();
618        (flags, commands)
619    }
620
621    /// Clap's `help` feature stays off in every ctl-core build. The Usage KDL
622    /// holds the `-h/--help` flag and `help` subcommand ctl-core declares, as
623    /// a build with that feature holds Clap's own. Only the `help`
624    /// subcommands and their copies turn help off, as in that build.
625    #[test]
626    fn usage_kdl_keeps_the_declared_help() {
627        let usage = Surface::new::<Cli>("t").usage_kdl;
628        assert_eq!(help_lines(&usage), (5, 4), "{usage}");
629        assert!(
630            !usage.lines().any(|line| line.starts_with("disable_help")),
631            "{usage}"
632        );
633        for declared in ["cmd status ", "cmd item ", "cmd add ", "cmd internal "] {
634            assert!(
635                usage
636                    .lines()
637                    .map(str::trim_start)
638                    .any(|line| { line.starts_with(declared) && !line.contains("disable_help") }),
639                "{declared}in {usage}"
640            );
641        }
642        let applied = Surface::from_command(crate::parser::apply_defaults(Cli::command()), "t");
643        assert_eq!(
644            help_lines(&applied.usage_kdl),
645            (5, 4),
646            "{}",
647            applied.usage_kdl
648        );
649        assert!(
650            !applied
651                .usage_kdl
652                .lines()
653                .any(|line| line.starts_with("disable_help")),
654            "{}",
655            applied.usage_kdl
656        );
657        for setting in ["disable_help_flag=#true", "disable_help_subcommand=#true"] {
658            assert_eq!(
659                applied.usage_kdl.matches(setting).count(),
660                usage.matches(setting).count(),
661                "{setting} in {}",
662                applied.usage_kdl
663            );
664        }
665    }
666
667    #[cfg(feature = "surface-templates")]
668    #[derive(Serialize)]
669    struct Content<'a> {
670        version: &'a str,
671        invocations: [&'a str; 2],
672    }
673
674    #[cfg(feature = "surface-templates")]
675    #[test]
676    fn shared_fragments_render_committed_operator_blocks() {
677        let surface = Surface::new::<Cli>("t");
678        let template = indoc! {r#"
679            {%- from "ctl/version.md.jinja" import version_line -%}
680            {%- from "ctl/invocation.md.jinja" import mounted_invocation -%}
681            {%- from "ctl/commands.md.jinja" import command_inventory -%}
682            ---
683            {{ version_line(content.version) }}
684            ---
685
686            {{ mounted_invocation(surface, content.invocations) }}
687
688            {{ command_inventory(surface) -}}
689        "#};
690        let rendered = render(
691            "operator.md.jinja",
692            template,
693            &surface,
694            &Content {
695                version: "1.2.3",
696                invocations: ["status", "item add demo"],
697            },
698        )
699        .unwrap_or_else(|error| panic!("render operator template: {error}"));
700        let expected = indoc! {r"
701            ---
702            version: 1.2.3
703            ---
704
705            ## Invocation
706
707            ```sh
708            mise run t status
709            mise run t item add demo
710            ```
711
712            Never `mise run t --`.
713
714            The `--` in `#USAGE mount` is mise's completion bootstrap.
715
716            ## Commands
717
718            | Command | Aliases | Purpose |
719            |:--|:--|:--|
720            | `status` | `ls` | Show current state |
721            | `item` | — | Mutate one item |
722        "};
723        assert_eq!(rendered, expected);
724        assert!(!rendered.contains("internal"));
725    }
726
727    #[test]
728    fn long_only_status_flag_fails_require_shorts() {
729        let error = Surface::new::<Cli>("t")
730            .require_shorts([])
731            .expect_err("archived is long-only");
732        assert!(error.contains("--archived"), "{error}");
733    }
734
735    #[derive(Parser)]
736    struct ShortsCli {
737        #[arg(long, global = true)]
738        verbose: bool,
739        #[command(subcommand)]
740        command: ShortsCommand,
741    }
742
743    #[derive(Subcommand)]
744    enum ShortsCommand {
745        Status {
746            #[arg(long)]
747            archived: bool,
748        },
749        #[command(hide = true)]
750        Internal {
751            #[arg(long)]
752            secret: bool,
753        },
754    }
755
756    #[test]
757    fn reports_hidden_flags_and_inherited_globals_once() {
758        let surface = Surface::new::<ShortsCli>("t");
759        assert_eq!(
760            surface.long_options_without_short(),
761            [
762                (String::new(), "verbose".to_owned()),
763                ("status".to_owned(), "archived".to_owned()),
764                ("internal".to_owned(), "secret".to_owned()),
765            ]
766        );
767        let error = surface
768            .require_shorts([])
769            .expect_err("three flags are long-only");
770        assert_eq!(
771            error,
772            "long option has no short: --verbose, status --archived, internal --secret"
773        );
774    }
775
776    #[test]
777    fn require_shorts_accepts_an_allow_list() {
778        Surface::new::<ShortsCli>("t")
779            .require_shorts(["--verbose", "status --archived", "internal --secret"])
780            .unwrap_or_else(|error| panic!("{error}"));
781    }
782
783    #[test]
784    fn a_root_allowance_does_not_exempt_a_nested_flag() {
785        let error = Surface::new::<ShortsCli>("t")
786            .require_shorts(["--verbose", "--archived", "internal --secret"])
787            .expect_err("archived needs its command path");
788        assert_eq!(error, "long option has no short: status --archived");
789    }
790
791    #[derive(Parser)]
792    struct ShortAliasCli {
793        #[arg(long, visible_short_alias = 'x')]
794        expanded: bool,
795    }
796
797    #[test]
798    fn a_short_alias_satisfies_the_contract() {
799        Surface::new::<ShortAliasCli>("t")
800            .require_shorts([])
801            .unwrap_or_else(|error| panic!("{error}"));
802    }
803
804    #[derive(Parser)]
805    struct HiddenShortAliasCli {
806        #[arg(long, short_alias = 'x')]
807        expanded: bool,
808    }
809
810    #[test]
811    fn a_hidden_short_alias_is_not_an_operator_short() {
812        let error = Surface::new::<HiddenShortAliasCli>("t")
813            .require_shorts([])
814            .expect_err("the only short is hidden");
815        assert_eq!(error, "long option has no short: --expanded");
816    }
817
818    #[derive(Parser)]
819    struct OwnedFile {
820        #[command(flatten)]
821        format: crate::flags::FormatLong,
822        #[command(flatten)]
823        color: crate::flags::ColorLong,
824        #[arg(short = 'f', long)]
825        file: Option<String>,
826        #[arg(short = 'c', long)]
827        config: Option<String>,
828    }
829
830    #[test]
831    fn format_long_and_color_long_are_the_allow_list() {
832        let surface = Surface::new::<OwnedFile>("x");
833        let error = surface
834            .require_shorts([])
835            .expect_err("format/color/no-color are long-only");
836        assert!(error.contains("--format"), "{error}");
837        assert!(error.contains("--color"), "{error}");
838        assert!(error.contains("--no-color"), "{error}");
839        surface
840            .require_shorts(["--format", "--color", "--no-color"])
841            .unwrap_or_else(|error| panic!("{error}"));
842    }
843
844    #[derive(Parser)]
845    struct DefaultOutput {
846        #[command(flatten)]
847        output: crate::flags::OutputArgs,
848    }
849
850    #[test]
851    fn output_args_only_exempt_no_color() {
852        Surface::new::<DefaultOutput>("x")
853            .require_shorts(["--no-color"])
854            .unwrap_or_else(|error| panic!("{error}"));
855    }
856
857    #[cfg(feature = "surface-serde")]
858    #[test]
859    fn surface_serde_does_not_require_templates() {
860        fn assert_serializable<T: serde::Serialize>() {}
861        assert_serializable::<Surface>();
862    }
863}