Skip to main content

usage/spec/
cmd.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4use crate::error::UsageErr;
5use crate::kdl::{KdlDocument, KdlEntry, KdlNode};
6use crate::sh::sh;
7use crate::spec::builder::SpecCommandBuilder;
8use crate::spec::context::ParsingContext;
9use crate::spec::effect::{SpecCommandEffect, EFFECT_VALUES};
10use crate::spec::exit_code::SpecExitCode;
11use crate::spec::flagset::SpecUse;
12use crate::spec::group::SpecGroup;
13use crate::spec::helpers::{string_entry, NodeHelper};
14use crate::spec::is_false;
15use crate::spec::mount::SpecMount;
16use crate::spec::output::SpecOutput;
17use crate::spec::unknown_flags::UnknownFlags;
18use crate::{Spec, SpecArg, SpecComplete, SpecFlag};
19use indexmap::IndexMap;
20use itertools::Itertools;
21use serde::Serialize;
22
23/// A CLI command or subcommand specification.
24///
25/// Commands define the structure of a CLI, including their flags, arguments,
26/// and nested subcommands. The root command represents the main CLI entry point.
27///
28/// # Example
29///
30/// ```
31/// use usage::{SpecCommand, SpecFlag, SpecArg};
32///
33/// let cmd = SpecCommand::builder()
34///     .name("install")
35///     .help("Install a package")
36///     .alias("i")
37///     .flag(SpecFlag::builder().short('f').long("force").build())
38///     .arg(SpecArg::builder().name("package").required(true).build())
39///     .build();
40/// ```
41#[derive(Debug, Serialize, Clone)]
42pub struct SpecCommand {
43    /// Full command path from root (e.g., ["git", "remote", "add"])
44    pub full_cmd: Vec<String>,
45    /// Generated usage string
46    pub usage: String,
47    /// Nested subcommands indexed by name
48    pub subcommands: IndexMap<String, SpecCommand>,
49    /// Positional arguments for this command
50    pub args: Vec<SpecArg>,
51    /// Flags/options for this command
52    pub flags: Vec<SpecFlag>,
53    /// Flagsets this command pulls in, and where in [`Self::flags`] they belong.
54    ///
55    /// `pub(crate)` because it is always empty by the time anyone else can see it: a `use` is
56    /// resolved while the spec is read, so a consumer holding a `SpecCommand` holds the flags
57    /// the sets named. Visible to the rest of the crate only so that other modules can
58    /// destructure `SpecCommand` exhaustively.
59    #[serde(skip)]
60    pub(crate) uses: Vec<SpecUse>,
61    /// Mounted external specs
62    pub mounts: Vec<SpecMount>,
63    /// Sets of flags that relate to one another as a set.
64    ///
65    /// Pairwise [`conflicts`](SpecFlag::conflicts) can say everything a plain group says
66    /// and cannot say `required`: "one of these is needed" is a statement about the set.
67    #[serde(skip_serializing_if = "Vec::is_empty")]
68    pub groups: Vec<SpecGroup>,
69    /// Deprecation message if this command is deprecated
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub deprecated: Option<String>,
72    /// Version at which consumers should begin warning about this command.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub deprecated_warn_at: Option<String>,
75    /// Version at which consumers expect this command to be removed.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub deprecated_remove_at: Option<String>,
78    /// What running this command does to the world: read, write or destructive.
79    /// Not inherited by subcommands.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub effect: Option<SpecCommandEffect>,
82    /// What to do here with a flag-like token that names no declared flag.
83    ///
84    /// Unset means "whatever encloses this command decided" — the nearest command
85    /// above that set one, or failing that the spec, or failing that
86    /// [`UnknownFlags::Value`]. Unlike [`SpecCommandEffect`] this *is* inherited,
87    /// because it describes how a command line is read rather than what a command
88    /// does, and a CLI that forwards options generally forwards them everywhere.
89    pub unknown_flags: Option<UnknownFlags>,
90    /// Whether to hide this command from help output
91    pub hide: bool,
92    /// Help section this command appears under in its parent's command list.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub help_heading: Option<String>,
95    /// Named audience or contract surface this command belongs to. Metadata only.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub surface: Option<String>,
98    /// Descriptive conditions under which this command is available.
99    #[serde(skip_serializing_if = "Vec::is_empty")]
100    pub available_if: Vec<String>,
101    /// Explicit placement within its parent's command section.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub display_order: Option<usize>,
104    /// True when this command came from a [`SpecMount`], i.e. it describes another
105    /// program's CLI that was merged in at parse time.
106    ///
107    /// The flags of the commands *above* a mounted command belong to the mounting CLI,
108    /// not to the mounted program, so they are not offered in completions once a mounted
109    /// command has been reached. They stay recognized by the parser, since they may
110    /// legitimately appear *before* the mounted command on the command line.
111    ///
112    /// Runtime-only: it is derived from `mount` nodes and is not part of the spec syntax.
113    #[serde(skip)]
114    pub mounted: bool,
115    /// True when a [`SpecMount`] brought flags of its own onto this command. A mounted spec's
116    /// root flags are merged into the command the mount sits on, *replacing* that command's
117    /// flags (see [`SpecCommand::merge`]), so when this is set every flag here describes the
118    /// mounted program and is offered inside the mounted commands accordingly.
119    ///
120    /// Runtime-only, like [`SpecCommand::mounted`].
121    #[serde(skip)]
122    pub flags_from_mount: bool,
123    /// Whether a subcommand must be provided
124    #[serde(skip_serializing_if = "is_false")]
125    pub subcommand_required: bool,
126    /// Heading used for this command's subcommand section.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub subcommand_help_heading: Option<String>,
129    /// Placeholder used for subcommands in the synopsis.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub subcommand_value_name: Option<String>,
132    /// Put each argument, flag, and subcommand description on the following line.
133    #[serde(skip_serializing_if = "is_false")]
134    pub next_line_help: bool,
135    /// Expand each visible subcommand's summary and arguments into this command's help page.
136    #[serde(skip_serializing_if = "is_false")]
137    pub flatten_help: bool,
138    /// Fixed help width. Zero disables wrapping.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub term_width: Option<usize>,
141    /// Maximum detected terminal width when `term_width` is unset. Zero disables the cap.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub max_term_width: Option<usize>,
144    /// Whether an unmatched word is forwarded as an external command plus the rest of argv.
145    ///
146    /// clap's `allow_external_subcommands` / `#[command(external_subcommand)]`. Known
147    /// subcommands still win; a `default_subcommand` still catches first. Once the
148    /// unmatched word is taken, remaining tokens — including `--help` — are not parsed
149    /// as this command's flags.
150    #[serde(skip_serializing_if = "is_false")]
151    pub external_subcommand: bool,
152    /// Whether a bare invocation of this command shows its help.
153    #[serde(skip_serializing_if = "is_false")]
154    pub arg_required_else_help: bool,
155    #[serde(skip_serializing_if = "is_false")]
156    pub disable_help_flag: bool,
157    #[serde(skip_serializing_if = "is_false")]
158    pub disable_help_subcommand: bool,
159    #[serde(skip_serializing_if = "is_false")]
160    pub disable_version_flag: bool,
161    /// Whether delimiter splitting is disabled after `--` or for an automatic trailing arg.
162    #[serde(skip_serializing_if = "is_false")]
163    pub dont_delimit_trailing_values: bool,
164    /// Whether a later occurrence of a single-valued argument replaces the earlier one.
165    /// Permissive by default; set false to report duplicates.
166    pub args_override_self: bool,
167    /// Whether selecting a subcommand satisfies this command's required arguments.
168    #[serde(skip_serializing_if = "is_false")]
169    pub subcommand_negates_reqs: bool,
170    /// Whether binding an argument prevents selecting a later subcommand.
171    #[serde(skip_serializing_if = "is_false")]
172    pub args_conflicts_with_subcommands: bool,
173    #[serde(skip_serializing_if = "is_false")]
174    pub subcommand_precedence_over_arg: bool,
175    /// Allow required positionals after optional positionals to claim the remaining words.
176    #[serde(skip_serializing_if = "is_false")]
177    pub allow_missing_positional: bool,
178    /// Token that resets argument parsing, allowing multiple command invocations.
179    /// e.g., `mise run lint ::: test ::: check` with restart_token=":::"
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub restart_token: Option<String>,
182    /// Short help text shown in command listings
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub help: Option<String>,
185    /// Extended help text shown with --help
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub help_long: Option<String>,
188    /// Markdown-formatted help text
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub help_md: Option<String>,
191    /// Command name (e.g., "install")
192    pub name: String,
193    /// Alternative names for this command
194    pub aliases: Vec<String>,
195    /// Hidden alternative names (not shown in help)
196    pub hidden_aliases: Vec<String>,
197    /// Text displayed before the help content
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub before_help: Option<String>,
200    /// Extended text displayed before help content
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub before_help_long: Option<String>,
203    /// Markdown text displayed before help content
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub before_help_md: Option<String>,
206    /// Text displayed after the help content
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub after_help: Option<String>,
209    /// Extended text displayed after help content
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub after_help_long: Option<String>,
212    /// Markdown text displayed after help content
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub after_help_md: Option<String>,
215    /// Usage examples for this command
216    pub examples: Vec<SpecExample>,
217    /// Prose introducing this command's help sections, by heading title.
218    pub headings: Vec<SpecHeading>,
219    /// What this command writes, and how a consumer should read it.
220    ///
221    /// Folded with the spec's CLI-wide outputs on read rather than here — see
222    /// [`effective_outputs`](crate::spec::output::effective_outputs).
223    #[serde(skip_serializing_if = "Vec::is_empty")]
224    pub outputs: Vec<SpecOutput>,
225    /// The flag whose *value* picks among [`Self::outputs`], e.g. `--format`.
226    ///
227    /// The other spelling — a boolean flag picking one output — lives on the output
228    /// itself, because that is where it is scoped.
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub select: Option<String>,
231    /// What this command's exit statuses mean.
232    #[serde(skip_serializing_if = "Vec::is_empty")]
233    pub exit_codes: Vec<SpecExitCode>,
234    /// Custom completers for arguments
235    #[serde(skip_serializing_if = "IndexMap::is_empty")]
236    pub complete: IndexMap<String, SpecComplete>,
237
238    /// Cache for subcommand name lookups (including aliases).
239    ///
240    /// `pub(crate)` only so that other modules can destructure `SpecCommand`
241    /// exhaustively; it stays private to the crate.
242    #[serde(skip)]
243    pub(crate) subcommand_lookup: OnceLock<HashMap<String, String>>,
244}
245
246impl Default for SpecCommand {
247    fn default() -> Self {
248        Self {
249            full_cmd: vec![],
250            usage: "".to_string(),
251            subcommands: IndexMap::new(),
252            args: vec![],
253            flags: vec![],
254            uses: vec![],
255            mounts: vec![],
256            groups: vec![],
257            deprecated: None,
258            deprecated_warn_at: None,
259            deprecated_remove_at: None,
260            effect: None,
261            unknown_flags: None,
262            hide: false,
263            help_heading: None,
264            surface: None,
265            available_if: vec![],
266            display_order: None,
267            mounted: false,
268            flags_from_mount: false,
269            subcommand_required: false,
270            subcommand_help_heading: None,
271            subcommand_value_name: None,
272            next_line_help: false,
273            flatten_help: false,
274            term_width: None,
275            max_term_width: None,
276            external_subcommand: false,
277            arg_required_else_help: false,
278            disable_help_flag: false,
279            disable_help_subcommand: false,
280            disable_version_flag: false,
281            dont_delimit_trailing_values: false,
282            args_override_self: true,
283            subcommand_negates_reqs: false,
284            args_conflicts_with_subcommands: false,
285            subcommand_precedence_over_arg: false,
286            allow_missing_positional: false,
287            restart_token: None,
288            help: None,
289            help_long: None,
290            help_md: None,
291            name: "".to_string(),
292            aliases: vec![],
293            hidden_aliases: vec![],
294            before_help: None,
295            before_help_long: None,
296            before_help_md: None,
297            after_help: None,
298            after_help_long: None,
299            after_help_md: None,
300            examples: vec![],
301            headings: vec![],
302            outputs: vec![],
303            select: None,
304            exit_codes: vec![],
305            subcommand_lookup: OnceLock::new(),
306            complete: IndexMap::new(),
307        }
308    }
309}
310
311#[derive(Debug, Default, Serialize, Clone)]
312#[non_exhaustive]
313pub struct SpecExample {
314    pub code: String,
315    pub header: Option<String>,
316    pub help: Option<String>,
317    pub lang: String,
318}
319
320impl SpecExample {
321    /// An example invocation shown in generated docs and help.
322    pub fn new(code: impl Into<String>) -> Self {
323        Self {
324            code: code.into(),
325            ..Default::default()
326        }
327    }
328
329    /// Heading shown above the example.
330    pub fn header(mut self, header: impl Into<String>) -> Self {
331        self.header = Some(header.into());
332        self
333    }
334
335    /// Prose shown with the example.
336    pub fn help(mut self, help: impl Into<String>) -> Self {
337        self.help = Some(help.into());
338        self
339    }
340
341    /// Language used for syntax highlighting.
342    pub fn lang(mut self, lang: impl Into<String>) -> Self {
343        self.lang = lang.into();
344        self
345    }
346}
347
348/// Prose introducing one help section.
349///
350/// Keyed by the heading's title, because a section is assembled from every flag and
351/// argument that names it and the text describes the section rather than any one of them.
352#[derive(Debug, Default, Serialize, Clone)]
353#[non_exhaustive]
354pub struct SpecHeading {
355    pub title: String,
356    pub help: String,
357}
358
359impl SpecHeading {
360    /// Prose shown between a help section's heading and its entries.
361    pub fn new(title: impl Into<String>, help: impl Into<String>) -> Self {
362        Self {
363            title: title.into(),
364            help: help.into(),
365        }
366    }
367}
368
369impl From<&SpecHeading> for KdlNode {
370    fn from(heading: &SpecHeading) -> KdlNode {
371        let mut node = KdlNode::new("heading");
372        node.push(string_entry(None, &heading.title));
373        node.push(string_entry(Some("help"), &heading.help));
374        node
375    }
376}
377
378impl From<&SpecExample> for KdlNode {
379    fn from(example: &SpecExample) -> KdlNode {
380        let mut node = KdlNode::new("example");
381        node.push(string_entry(None, &example.code));
382        if let Some(header) = &example.header {
383            node.push(string_entry(Some("header"), header));
384        }
385        if let Some(help) = &example.help {
386            node.push(string_entry(Some("help"), help));
387        }
388        if !example.lang.is_empty() {
389            node.push(string_entry(Some("lang"), &example.lang));
390        }
391        node
392    }
393}
394
395impl SpecCommand {
396    /// Create a new builder for SpecCommand
397    pub fn builder() -> SpecCommandBuilder {
398        SpecCommandBuilder::new()
399    }
400
401    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
402        node.ensure_arg_len(1..=1)?;
403        let mut cmd = Self {
404            name: node.arg(0)?.ensure_string()?.to_string(),
405            ..Default::default()
406        };
407        for (k, v) in node.props() {
408            match k {
409                "help" => cmd.help = Some(v.ensure_string()?),
410                "long_help" => cmd.help_long = Some(v.ensure_string()?),
411                "help_long" => cmd.help_long = Some(v.ensure_string()?),
412                "help_md" => cmd.help_md = Some(v.ensure_string()?),
413                "before_help" => cmd.before_help = Some(v.ensure_string()?),
414                "before_long_help" => cmd.before_help_long = Some(v.ensure_string()?),
415                "before_help_long" => cmd.before_help_long = Some(v.ensure_string()?),
416                "before_help_md" => cmd.before_help_md = Some(v.ensure_string()?),
417                "after_help" => cmd.after_help = Some(v.ensure_string()?),
418                "after_long_help" => {
419                    cmd.after_help_long = Some(v.ensure_string()?);
420                }
421                "after_help_long" => {
422                    cmd.after_help_long = Some(v.ensure_string()?);
423                }
424                "after_help_md" => cmd.after_help_md = Some(v.ensure_string()?),
425                "subcommand_required" => cmd.subcommand_required = v.ensure_bool()?,
426                "subcommand_help_heading" => cmd.subcommand_help_heading = Some(v.ensure_string()?),
427                "subcommand_value_name" => cmd.subcommand_value_name = Some(v.ensure_string()?),
428                "next_line_help" => cmd.next_line_help = v.ensure_bool()?,
429                "flatten_help" => cmd.flatten_help = v.ensure_bool()?,
430                "term_width" => cmd.term_width = Some(v.ensure_usize()?),
431                "max_term_width" => cmd.max_term_width = Some(v.ensure_usize()?),
432                "external_subcommand" => cmd.external_subcommand = v.ensure_bool()?,
433                "arg_required_else_help" => cmd.arg_required_else_help = v.ensure_bool()?,
434                "disable_help_flag" => cmd.disable_help_flag = v.ensure_bool()?,
435                "disable_help_subcommand" => cmd.disable_help_subcommand = v.ensure_bool()?,
436                "disable_version_flag" => cmd.disable_version_flag = v.ensure_bool()?,
437                "dont_delimit_trailing_values" => {
438                    cmd.dont_delimit_trailing_values = v.ensure_bool()?
439                }
440                "args_override_self" => cmd.args_override_self = v.ensure_bool()?,
441                "subcommand_negates_reqs" => cmd.subcommand_negates_reqs = v.ensure_bool()?,
442                "args_conflicts_with_subcommands" => {
443                    cmd.args_conflicts_with_subcommands = v.ensure_bool()?
444                }
445                "subcommand_precedence_over_arg" => {
446                    cmd.subcommand_precedence_over_arg = v.ensure_bool()?
447                }
448                "allow_missing_positional" => cmd.allow_missing_positional = v.ensure_bool()?,
449                "hide" => cmd.hide = v.ensure_bool()?,
450                "help_heading" => cmd.help_heading = Some(v.ensure_string()?),
451                "surface" => cmd.surface = Some(v.ensure_string()?),
452                "available_if" => cmd.available_if = vec![v.ensure_string()?],
453                "display_order" => cmd.display_order = Some(v.ensure_usize()?),
454                "unknown_flags" => {
455                    let raw = v.ensure_string()?;
456                    match raw.parse() {
457                        Ok(mode) => cmd.unknown_flags = Some(mode),
458                        Err(_) => bail_parse!(
459                            ctx,
460                            v.entry.span(),
461                            "unsupported unknown_flags {raw}, expected one of: {}",
462                            crate::spec::unknown_flags::UNKNOWN_FLAGS_VALUES
463                        ),
464                    }
465                }
466                "effect" => {
467                    let raw = v.ensure_string()?;
468                    match raw.parse() {
469                        Ok(effect) => cmd.effect = Some(effect),
470                        Err(_) => bail_parse!(
471                            ctx,
472                            v.entry.span(),
473                            "unsupported effect {raw}, expected one of: {EFFECT_VALUES}"
474                        ),
475                    }
476                }
477                "restart_token" => cmd.restart_token = Some(v.ensure_string()?),
478                "deprecated" => {
479                    cmd.deprecated = match v.value.as_bool() {
480                        Some(true) => Some("deprecated".to_string()),
481                        Some(false) => None,
482                        None => Some(v.ensure_string()?),
483                    }
484                }
485                "deprecated_warn_at" => cmd.deprecated_warn_at = Some(v.ensure_string()?),
486                "deprecated_remove_at" => cmd.deprecated_remove_at = Some(v.ensure_string()?),
487                k => bail_parse!(ctx, v.entry.span(), "unsupported cmd prop {k}"),
488            }
489        }
490        for child in node.children() {
491            match child.name() {
492                "flag" => cmd.flags.push(SpecFlag::parse(ctx, &child)?),
493                "use" => {
494                    let at = cmd.flags.len();
495                    cmd.uses.push(SpecUse::parse(ctx, &child, at)?);
496                }
497                "arg" => {
498                    let arg = SpecArg::parse(ctx, &child)?;
499                    // As on a flag: splitting a word that has room for one value would
500                    // drop everything after the first separator.
501                    if arg.delimiter.is_some() && !arg.var {
502                        bail_parse!(
503                            ctx,
504                            child.node.name().span(),
505                            "argument <{}> has a delimiter and holds one value; add \
506                             `var=#true` for the values it splits into",
507                            arg.name
508                        );
509                    }
510                    cmd.args.push(arg);
511                }
512                "mount" => cmd.mounts.push(SpecMount::parse(ctx, &child)?),
513                "group" => cmd.groups.push(SpecGroup::parse(ctx, &child)?),
514                "cmd" => {
515                    let node = SpecCommand::parse(ctx, &child)?;
516                    cmd.subcommands.insert(node.name.to_string(), node);
517                }
518                "alias" => {
519                    let alias = child
520                        .ensure_arg_len(1..)?
521                        .args()
522                        .map(|e| e.ensure_string())
523                        .collect::<Result<Vec<_>, _>>()?;
524                    let hide = child
525                        .get("hide")
526                        .map(|n| n.ensure_bool())
527                        .unwrap_or(Ok(false))?;
528                    if hide {
529                        cmd.hidden_aliases.extend(alias);
530                    } else {
531                        cmd.aliases.extend(alias);
532                    }
533                }
534                "example" => {
535                    let code = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
536                    let mut example = SpecExample::new(code.trim().to_string());
537                    for (k, v) in child.props() {
538                        match k {
539                            "header" => example.header = Some(v.ensure_string()?),
540                            "help" => example.help = Some(v.ensure_string()?),
541                            "lang" => example.lang = v.ensure_string()?,
542                            k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
543                        }
544                    }
545                    cmd.examples.push(example);
546                }
547                "heading" => {
548                    let title = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
549                    let mut help = None;
550                    for (k, v) in child.props() {
551                        match k {
552                            "help" => help = Some(v.ensure_string()?),
553                            k => bail_parse!(ctx, v.entry.span(), "unsupported heading key {k}"),
554                        }
555                    }
556                    let Some(help) = help else {
557                        bail_parse!(ctx, child.node.span(), "heading {title} needs help text");
558                    };
559                    cmd.headings.push(SpecHeading::new(title, help));
560                }
561                "output" => cmd.outputs.push(SpecOutput::parse(ctx, &child)?),
562                "exit_code" => cmd.exit_codes.push(SpecExitCode::parse(ctx, &child)?),
563                "select" => {
564                    cmd.select = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
565                }
566                "help" => {
567                    cmd.help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
568                }
569                "long_help" => {
570                    cmd.help_long = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
571                }
572                "help_md" => {
573                    cmd.help_md = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
574                }
575                "before_help" => {
576                    cmd.before_help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
577                }
578                "before_long_help" => {
579                    cmd.before_help_long =
580                        Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
581                }
582                "before_help_md" => {
583                    cmd.before_help_md =
584                        Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
585                }
586                "after_help" => {
587                    cmd.after_help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
588                }
589                "after_long_help" => {
590                    cmd.after_help_long =
591                        Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
592                }
593                "after_help_md" => {
594                    cmd.after_help_md = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
595                }
596                "subcommand_required" => {
597                    cmd.subcommand_required = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
598                }
599                "help_heading" => {
600                    cmd.help_heading = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?)
601                }
602                "surface" => {
603                    cmd.surface = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?)
604                }
605                "available_if" => {
606                    cmd.available_if = child
607                        .ensure_arg_len(1..)?
608                        .args()
609                        .map(|entry| entry.ensure_string())
610                        .collect::<Result<Vec<_>, _>>()?;
611                }
612                "subcommand_help_heading" => {
613                    cmd.subcommand_help_heading =
614                        Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?)
615                }
616                "subcommand_value_name" => {
617                    cmd.subcommand_value_name =
618                        Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?)
619                }
620                "next_line_help" => {
621                    cmd.next_line_help = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
622                }
623                "flatten_help" => {
624                    cmd.flatten_help = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
625                }
626                "term_width" => {
627                    cmd.term_width = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_usize()?)
628                }
629                "max_term_width" => {
630                    cmd.max_term_width = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_usize()?)
631                }
632                "external_subcommand" => {
633                    cmd.external_subcommand = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
634                }
635                "arg_required_else_help" => {
636                    cmd.arg_required_else_help =
637                        child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
638                }
639                "disable_help_flag" => {
640                    cmd.disable_help_flag = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
641                }
642                "disable_help_subcommand" => {
643                    cmd.disable_help_subcommand =
644                        child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
645                }
646                "disable_version_flag" => {
647                    cmd.disable_version_flag = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
648                }
649                "dont_delimit_trailing_values" => {
650                    cmd.dont_delimit_trailing_values =
651                        child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
652                }
653                "args_override_self" => {
654                    cmd.args_override_self = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
655                }
656                "subcommand_negates_reqs" => {
657                    cmd.subcommand_negates_reqs =
658                        child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
659                }
660                "args_conflicts_with_subcommands" => {
661                    cmd.args_conflicts_with_subcommands =
662                        child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
663                }
664                "subcommand_precedence_over_arg" => {
665                    cmd.subcommand_precedence_over_arg =
666                        child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
667                }
668                "allow_missing_positional" => {
669                    cmd.allow_missing_positional =
670                        child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
671                }
672                "hide" => cmd.hide = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?,
673                "effect" => {
674                    let arg = child.ensure_arg_len(1..=1)?.arg(0)?;
675                    let raw = arg.ensure_string()?;
676                    match raw.parse() {
677                        Ok(effect) => cmd.effect = Some(effect),
678                        Err(_) => bail_parse!(
679                            ctx,
680                            arg.entry.span(),
681                            "unsupported effect {raw}, expected one of: {EFFECT_VALUES}"
682                        ),
683                    }
684                }
685                "restart_token" => {
686                    cmd.restart_token = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?)
687                }
688                "deprecated" => {
689                    cmd.deprecated = match child.arg(0)?.value.as_bool() {
690                        Some(true) => Some("deprecated".to_string()),
691                        Some(false) => None,
692                        None => Some(child.arg(0)?.ensure_string()?),
693                    }
694                }
695                "deprecated_warn_at" => {
696                    cmd.deprecated_warn_at = Some(child.arg(0)?.ensure_string()?)
697                }
698                "deprecated_remove_at" => {
699                    cmd.deprecated_remove_at = Some(child.arg(0)?.ensure_string()?)
700                }
701                "complete" => {
702                    let complete = SpecComplete::parse(ctx, &child)?;
703                    cmd.complete.insert(complete.name.clone(), complete);
704                }
705                k => bail_parse!(ctx, child.node.name().span(), "unsupported cmd key {k}"),
706            }
707        }
708        Ok(cmd)
709    }
710    pub(crate) fn is_empty(&self) -> bool {
711        self.args.is_empty()
712            && self.flags.is_empty()
713            && self.mounts.is_empty()
714            && self.subcommands.is_empty()
715    }
716    pub fn usage(&self) -> String {
717        self.usage_with_subcommands(true)
718    }
719
720    // `cli-help` only, like `SpecChoices::for_help`: the usage line without the subcommand
721    // placeholder is a help-page shape, and nothing else asks for it.
722    #[cfg(feature = "cli-help")]
723    pub(crate) fn usage_without_subcommands(&self) -> String {
724        self.usage_with_subcommands(false)
725    }
726
727    fn usage_with_subcommands(&self, include_subcommands: bool) -> String {
728        let mut usage = self.full_cmd.join(" ");
729        let flags = self
730            .flags
731            .iter()
732            .filter(|f| !f.hide && !f.builtin)
733            .collect_vec();
734        let args = self.args.iter().filter(|a| !a.hide).collect_vec();
735        if !flags.is_empty() {
736            if flags.len() <= 2 {
737                let inlines = flags
738                    .iter()
739                    .map(|f| {
740                        if f.required {
741                            format!("<{}>", f.usage())
742                        } else {
743                            format!("[{}]", f.usage())
744                        }
745                    })
746                    .join(" ");
747                usage = format!("{usage} {inlines}").trim().to_string();
748            } else if flags.iter().any(|f| f.required) {
749                usage = format!("{usage} <FLAGS>");
750            } else {
751                usage = format!("{usage} [FLAGS]");
752            }
753        }
754        if !args.is_empty() {
755            if args.len() <= 2 {
756                let inlines = args.iter().map(|a| a.usage()).join(" ");
757                usage = format!("{usage} {inlines}").trim().to_string();
758            } else if args.iter().any(|a| a.required) {
759                usage = format!("{usage} <ARGS>…");
760            } else {
761                usage = format!("{usage} [ARGS]…");
762            }
763        }
764        // TODO: mounts?
765        // if !self.mounts.is_empty() {
766        //     name = format!("{name} [mounts]");
767        // }
768        if include_subcommands && !self.subcommands.is_empty() {
769            let name = self
770                .subcommand_value_name
771                .as_deref()
772                .unwrap_or("SUBCOMMAND");
773            usage = format!("{usage} <{name}>");
774        }
775        usage.trim().to_string()
776    }
777    /// Forget which subcommands this command was asked for.
778    ///
779    /// `find_subcommand` memoizes names and aliases into a `OnceLock`, so anything that adds or
780    /// removes a subcommand has to say so or the lookup keeps answering for the old set.
781    pub(crate) fn reset_subcommand_lookup(&mut self) {
782        self.subcommand_lookup = OnceLock::new();
783    }
784
785    pub(crate) fn merge(&mut self, other: Self) {
786        // Merging can add subcommands and aliases, and `find_subcommand` memoizes
787        // its lookup into a OnceLock — so the cache has to go, or a name that
788        // arrived here would not be findable. This worked before only because
789        // mounting happened to precede the first lookup on a given command.
790        self.subcommand_lookup = OnceLock::new();
791        // Destructured exhaustively (no `..`) so that adding a field to
792        // SpecCommand fails to compile until this decides what merging it means.
793        // Runtime-derived fields are explicitly ignored rather than skipped.
794        let Self {
795            name,
796            help,
797            help_long,
798            help_md,
799            before_help,
800            before_help_long,
801            before_help_md,
802            after_help,
803            after_help_long,
804            after_help_md,
805            args,
806            flags,
807            uses,
808            mounts,
809            groups,
810            aliases,
811            hidden_aliases,
812            examples,
813            headings,
814            outputs,
815            select,
816            exit_codes,
817            hide,
818            help_heading,
819            surface,
820            available_if,
821            display_order,
822            subcommand_required,
823            subcommand_help_heading,
824            subcommand_value_name,
825            next_line_help,
826            flatten_help,
827            term_width,
828            max_term_width,
829            external_subcommand,
830            arg_required_else_help,
831            disable_help_flag,
832            disable_help_subcommand,
833            disable_version_flag,
834            dont_delimit_trailing_values,
835            args_override_self,
836            subcommand_negates_reqs,
837            args_conflicts_with_subcommands,
838            subcommand_precedence_over_arg,
839            allow_missing_positional,
840            restart_token,
841            subcommands,
842            complete,
843            deprecated,
844            deprecated_warn_at,
845            deprecated_remove_at,
846            effect,
847            unknown_flags,
848            // Recomputed from the merged command, never carried over.
849            full_cmd: _,
850            usage: _,
851            mounted: _,
852            flags_from_mount: _,
853            subcommand_lookup: _,
854        } = other;
855        if !name.is_empty() {
856            self.name = name;
857        }
858        if help.is_some() {
859            self.help = help;
860        }
861        if help_long.is_some() {
862            self.help_long = help_long;
863        }
864        if help_md.is_some() {
865            self.help_md = help_md;
866        }
867        if before_help.is_some() {
868            self.before_help = before_help;
869        }
870        if before_help_long.is_some() {
871            self.before_help_long = before_help_long;
872        }
873        if before_help_md.is_some() {
874            self.before_help_md = before_help_md;
875        }
876        if after_help.is_some() {
877            self.after_help = after_help;
878        }
879        if after_help_long.is_some() {
880            self.after_help_long = after_help_long;
881        }
882        if after_help_md.is_some() {
883            self.after_help_md = after_help_md;
884        }
885        if !args.is_empty() {
886            self.args = args;
887        }
888        let flags_replaced = !flags.is_empty();
889        if flags_replaced {
890            self.flags = flags;
891        }
892        // Unresolved `use` nodes travel with the flags they were written among, for the same
893        // reason groups do — including when that means going with none. A `use` is a
894        // declaration of flags, so whoever owns the flag list owns it: an included file that
895        // replaces this command's flags replaces what it says about them, and a `use` left
896        // behind would splice a set into the incoming list at a position from the old one.
897        //
898        // `other.uses` is normally empty either way: a spec resolves its own sets before it
899        // can be merged into another, and what arrives here has been through that already.
900        if flags_replaced || !uses.is_empty() {
901            self.uses = uses;
902        }
903        if !mounts.is_empty() {
904            self.mounts = mounts;
905        }
906        // Groups travel with the flags they name. A mounted spec that replaces this
907        // command's flags replaces its groups too — including with none, which is the
908        // case that matters: keeping the old set would enforce exclusivity between flags
909        // that are no longer here, and a required group whose members nothing answers to
910        // would reject every invocation.
911        if flags_replaced || !groups.is_empty() {
912            self.groups = groups;
913        }
914        if !aliases.is_empty() {
915            self.aliases = aliases;
916        }
917        if !hidden_aliases.is_empty() {
918            self.hidden_aliases = hidden_aliases;
919        }
920        if !examples.is_empty() {
921            self.examples = examples;
922        }
923        if !headings.is_empty() {
924            self.headings = headings;
925        }
926        // Outputs describe what the *mounted* program writes, so they move with the flags
927        // rather than being folded into what was here — the same reason groups follow the
928        // flags they name.
929        if flags_replaced || !outputs.is_empty() {
930            self.outputs = outputs;
931        }
932        if flags_replaced || select.is_some() {
933            self.select = select;
934        }
935        if !exit_codes.is_empty() {
936            self.exit_codes = exit_codes;
937        }
938        self.hide = hide;
939        if help_heading.is_some() {
940            self.help_heading = help_heading;
941        }
942        if surface.is_some() {
943            self.surface = surface;
944        }
945        if !available_if.is_empty() {
946            self.available_if = available_if;
947        }
948        if display_order.is_some() {
949            self.display_order = display_order;
950        }
951        self.subcommand_required = subcommand_required;
952        if subcommand_help_heading.is_some() {
953            self.subcommand_help_heading = subcommand_help_heading;
954        }
955        if subcommand_value_name.is_some() {
956            self.subcommand_value_name = subcommand_value_name;
957        }
958        self.next_line_help = next_line_help;
959        self.flatten_help = flatten_help;
960        if term_width.is_some() {
961            self.term_width = term_width;
962        }
963        if max_term_width.is_some() {
964            self.max_term_width = max_term_width;
965        }
966        self.external_subcommand = external_subcommand;
967        self.arg_required_else_help = arg_required_else_help;
968        self.disable_help_flag = disable_help_flag;
969        self.disable_help_subcommand = disable_help_subcommand;
970        self.disable_version_flag = disable_version_flag;
971        self.dont_delimit_trailing_values = dont_delimit_trailing_values;
972        self.args_override_self = args_override_self;
973        self.subcommand_negates_reqs = subcommand_negates_reqs;
974        self.args_conflicts_with_subcommands = args_conflicts_with_subcommands;
975        self.subcommand_precedence_over_arg = subcommand_precedence_over_arg;
976        self.allow_missing_positional = allow_missing_positional;
977        if effect.is_some() {
978            self.effect = effect;
979        }
980        if unknown_flags.is_some() {
981            self.unknown_flags = unknown_flags;
982        }
983        if deprecated.is_some() {
984            self.deprecated = deprecated;
985        }
986        if deprecated_warn_at.is_some() {
987            self.deprecated_warn_at = deprecated_warn_at;
988        }
989        if deprecated_remove_at.is_some() {
990            self.deprecated_remove_at = deprecated_remove_at;
991        }
992        if restart_token.is_some() {
993            self.restart_token = restart_token;
994        }
995        for (name, cmd) in subcommands {
996            self.subcommands.insert(name, cmd);
997        }
998        for (name, complete) in complete {
999            self.complete.insert(name, complete);
1000        }
1001    }
1002
1003    pub fn all_subcommands(&self) -> Vec<&SpecCommand> {
1004        let mut cmds = vec![];
1005        for cmd in self.subcommands.values() {
1006            cmds.push(cmd);
1007            cmds.extend(cmd.all_subcommands());
1008        }
1009        cmds
1010    }
1011
1012    pub fn find_subcommand(&self, name: &str) -> Option<&SpecCommand> {
1013        let sl = self.subcommand_lookup.get_or_init(|| {
1014            let mut map = HashMap::new();
1015            // Names first, then aliases only where nothing answers already: a
1016            // command's own name outranks another command's alias, so reordering
1017            // `cmd` blocks cannot change which command a word selects.
1018            //
1019            // Inserting both in one pass instead let the *last* declaration win,
1020            // which was the opposite of what usage-argv did with the same spec —
1021            // it takes the first. Neither was a rule anyone had chosen.
1022            for name in self.subcommands.keys() {
1023                map.insert(name.clone(), name.clone());
1024            }
1025            for (name, cmd) in &self.subcommands {
1026                for alias in cmd.aliases.iter().chain(&cmd.hidden_aliases) {
1027                    map.entry(alias.clone()).or_insert_with(|| name.clone());
1028                }
1029            }
1030            map
1031        });
1032        let name = sl.get(name)?;
1033        self.subcommands.get(name)
1034    }
1035
1036    pub(crate) fn mount(
1037        &mut self,
1038        global_flag_args: &[String],
1039        injected: Option<&HashMap<String, String>>,
1040    ) -> Result<(), UsageErr> {
1041        for mount in self.mounts.iter().cloned().collect_vec() {
1042            let cmd = if global_flag_args.is_empty() {
1043                mount.run.clone()
1044            } else {
1045                // Parse the mount command into tokens, insert global flags after the first token
1046                // e.g., "mise tasks ls" becomes "mise --cd dir2 tasks ls"
1047                // Handles quoted arguments correctly: "cmd 'arg with spaces'" stays correct
1048                let mut tokens = shell_words::split(&mount.run)
1049                    .expect("mount command should be valid shell syntax");
1050                if !tokens.is_empty() {
1051                    // Insert global flags after the first token (the command name)
1052                    tokens.splice(1..1, global_flag_args.iter().cloned());
1053                }
1054                // Join tokens back into a properly quoted command string
1055                shell_words::join(tokens)
1056            };
1057            let output = match injected {
1058                Some(outputs) => outputs
1059                    .get(&mount.run)
1060                    .cloned()
1061                    .ok_or_else(|| UsageErr::MissingMountOutput(mount.run.clone()))?,
1062                None => sh(&cmd)?,
1063            };
1064            let mut spec: Spec = output.parse()?;
1065            if let Some(outputs) = injected {
1066                // A mounted spec's root is merged into this command, so its root-only
1067                // default-subcommand precedence does not apply while composing mounts.
1068                spec.resolve_mount_outputs_at_root(outputs, false)?;
1069            }
1070            // The subcommands emitted by a mount describe another program, so mark them (and
1071            // everything below them) as mounted. See `SpecCommand::mounted`.
1072            for cmd in spec.cmd.subcommands.values_mut() {
1073                cmd.mark_mounted();
1074            }
1075            // `merge` folds the mounted spec's root flags into this command; remember that they
1076            // came from the mount. See `SpecCommand::flags_from_mount`.
1077            self.flags_from_mount |= !spec.cmd.flags.is_empty();
1078            self.merge(spec.cmd);
1079        }
1080        Ok(())
1081    }
1082
1083    /// Mark this command and all of its subcommands as coming from a mount.
1084    pub(crate) fn mark_mounted(&mut self) {
1085        self.mounted = true;
1086        for cmd in self.subcommands.values_mut() {
1087            cmd.mark_mounted();
1088        }
1089    }
1090}
1091
1092impl From<&SpecCommand> for KdlNode {
1093    fn from(cmd: &SpecCommand) -> Self {
1094        // Destructured exhaustively (no `..`) so that adding a field to
1095        // SpecCommand fails to compile until this decides how to serialize it.
1096        let SpecCommand {
1097            name,
1098            hide,
1099            help_heading,
1100            surface,
1101            available_if,
1102            display_order,
1103            subcommand_required,
1104            subcommand_help_heading,
1105            subcommand_value_name,
1106            next_line_help,
1107            flatten_help,
1108            term_width,
1109            max_term_width,
1110            external_subcommand,
1111            arg_required_else_help,
1112            disable_help_flag,
1113            disable_help_subcommand,
1114            disable_version_flag,
1115            dont_delimit_trailing_values,
1116            args_override_self,
1117            subcommand_negates_reqs,
1118            args_conflicts_with_subcommands,
1119            subcommand_precedence_over_arg,
1120            allow_missing_positional,
1121            restart_token,
1122            unknown_flags,
1123            aliases,
1124            hidden_aliases,
1125            help,
1126            help_long,
1127            help_md,
1128            before_help,
1129            before_help_long,
1130            before_help_md,
1131            after_help,
1132            after_help_long,
1133            after_help_md,
1134            deprecated,
1135            deprecated_warn_at,
1136            deprecated_remove_at,
1137            effect,
1138            flags,
1139            args,
1140            mounts,
1141            groups,
1142            subcommands,
1143            complete,
1144            examples,
1145            headings,
1146            outputs,
1147            select,
1148            exit_codes,
1149            // Resolved while the spec was read: whatever a `use` named is among `flags`
1150            // by now, so emitting the request too would declare those flags twice.
1151            uses: _,
1152            // Derived from the spec rather than written by it.
1153            full_cmd: _,
1154            usage: _,
1155            mounted: _,
1156            flags_from_mount: _,
1157            subcommand_lookup: _,
1158        } = cmd;
1159        let mut node = Self::new("cmd");
1160        node.entries_mut().push(name.clone().into());
1161        if *hide {
1162            node.entries_mut().push(KdlEntry::new_prop("hide", true));
1163        }
1164        if let Some(heading) = help_heading {
1165            node.entries_mut()
1166                .push(KdlEntry::new_prop("help_heading", heading.clone()));
1167        }
1168        if let Some(surface) = surface {
1169            node.push(KdlEntry::new_prop("surface", surface.clone()));
1170        }
1171        if !available_if.is_empty() {
1172            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1173            let mut condition = KdlNode::new("available_if");
1174            for value in available_if {
1175                condition.push(string_entry(None, value));
1176            }
1177            children.nodes_mut().push(condition);
1178        }
1179        if let Some(order) = display_order {
1180            node.entries_mut()
1181                .push(KdlEntry::new_prop("display_order", *order as i128));
1182        }
1183        if *subcommand_required {
1184            node.entries_mut()
1185                .push(KdlEntry::new_prop("subcommand_required", true));
1186        }
1187        if let Some(heading) = subcommand_help_heading {
1188            node.push(KdlEntry::new_prop(
1189                "subcommand_help_heading",
1190                heading.clone(),
1191            ));
1192        }
1193        if let Some(name) = subcommand_value_name {
1194            node.push(KdlEntry::new_prop("subcommand_value_name", name.clone()));
1195        }
1196        if *next_line_help {
1197            node.push(KdlEntry::new_prop("next_line_help", true));
1198        }
1199        if *flatten_help {
1200            node.push(KdlEntry::new_prop("flatten_help", true));
1201        }
1202        if let Some(width) = term_width {
1203            node.push(KdlEntry::new_prop("term_width", *width as i128));
1204        }
1205        if let Some(width) = max_term_width {
1206            node.push(KdlEntry::new_prop("max_term_width", *width as i128));
1207        }
1208        if *external_subcommand {
1209            node.entries_mut()
1210                .push(KdlEntry::new_prop("external_subcommand", true));
1211        }
1212        if *arg_required_else_help {
1213            node.entries_mut()
1214                .push(KdlEntry::new_prop("arg_required_else_help", true));
1215        }
1216        if *disable_help_flag {
1217            node.push(KdlEntry::new_prop("disable_help_flag", true));
1218        }
1219        if *disable_help_subcommand {
1220            node.push(KdlEntry::new_prop("disable_help_subcommand", true));
1221        }
1222        if *disable_version_flag {
1223            node.push(KdlEntry::new_prop("disable_version_flag", true));
1224        }
1225        if *dont_delimit_trailing_values {
1226            node.entries_mut()
1227                .push(KdlEntry::new_prop("dont_delimit_trailing_values", true));
1228        }
1229        if !*args_override_self {
1230            node.push(KdlEntry::new_prop("args_override_self", false));
1231        }
1232        if *subcommand_negates_reqs {
1233            node.push(KdlEntry::new_prop("subcommand_negates_reqs", true));
1234        }
1235        if *args_conflicts_with_subcommands {
1236            node.push(KdlEntry::new_prop("args_conflicts_with_subcommands", true));
1237        }
1238        if *subcommand_precedence_over_arg {
1239            node.push(KdlEntry::new_prop("subcommand_precedence_over_arg", true));
1240        }
1241        if *allow_missing_positional {
1242            node.push(KdlEntry::new_prop("allow_missing_positional", true));
1243        }
1244        if let Some(restart_token) = &restart_token {
1245            node.entries_mut()
1246                .push(KdlEntry::new_prop("restart_token", restart_token.clone()));
1247        }
1248        if !aliases.is_empty() {
1249            let mut alias_node = KdlNode::new("alias");
1250            for alias in aliases {
1251                alias_node.entries_mut().push(alias.clone().into());
1252            }
1253            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1254            children.nodes_mut().push(alias_node);
1255        }
1256        if !hidden_aliases.is_empty() {
1257            let mut alias_node = KdlNode::new("alias");
1258            for alias in hidden_aliases {
1259                alias_node.entries_mut().push(alias.clone().into());
1260            }
1261            alias_node
1262                .entries_mut()
1263                .push(KdlEntry::new_prop("hide", true));
1264            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1265            children.nodes_mut().push(alias_node);
1266        }
1267        if let Some(help) = &help {
1268            node.entries_mut().push(string_entry(Some("help"), help));
1269        }
1270        if let Some(help) = &help_long {
1271            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1272            let mut node = KdlNode::new("long_help");
1273            node.push(string_entry(None, help));
1274            children.nodes_mut().push(node);
1275        }
1276        if let Some(help) = &help_md {
1277            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1278            let mut node = KdlNode::new("help_md");
1279            node.push(string_entry(None, help));
1280            children.nodes_mut().push(node);
1281        }
1282        if let Some(help) = &before_help {
1283            node.entries_mut()
1284                .push(string_entry(Some("before_help"), help));
1285        }
1286        if let Some(help) = &before_help_long {
1287            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1288            let mut node = KdlNode::new("before_long_help");
1289            node.push(string_entry(None, help));
1290            children.nodes_mut().push(node);
1291        }
1292        if let Some(help) = &before_help_md {
1293            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1294            let mut node = KdlNode::new("before_help_md");
1295            node.push(string_entry(None, help));
1296            children.nodes_mut().push(node);
1297        }
1298        if let Some(help) = &after_help {
1299            node.entries_mut()
1300                .push(string_entry(Some("after_help"), help));
1301        }
1302        if let Some(help) = &after_help_long {
1303            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1304            let mut node = KdlNode::new("after_long_help");
1305            node.push(string_entry(None, help));
1306            children.nodes_mut().push(node);
1307        }
1308        if let Some(help) = &after_help_md {
1309            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1310            let mut node = KdlNode::new("after_help_md");
1311            node.push(string_entry(None, help));
1312            children.nodes_mut().push(node);
1313        }
1314        if let Some(deprecated) = &deprecated {
1315            node.entries_mut()
1316                .push(string_entry(Some("deprecated"), deprecated));
1317        }
1318        if let Some(at) = deprecated_warn_at {
1319            node.push(string_entry(Some("deprecated_warn_at"), at));
1320        }
1321        if let Some(at) = deprecated_remove_at {
1322            node.push(string_entry(Some("deprecated_remove_at"), at));
1323        }
1324        if let Some(effect) = effect {
1325            node.entries_mut()
1326                .push(string_entry(Some("effect"), effect.as_str()));
1327        }
1328        if let Some(unknown_flags) = unknown_flags {
1329            node.entries_mut()
1330                .push(string_entry(Some("unknown_flags"), unknown_flags.as_str()));
1331        }
1332        for flag in flags {
1333            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1334            children.nodes_mut().push(flag.into());
1335        }
1336        for arg in args {
1337            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1338            children.nodes_mut().push(arg.into());
1339        }
1340        for mount in mounts {
1341            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1342            children.nodes_mut().push(mount.into());
1343        }
1344        // After the flags they name, so a reader meets the members before the rule
1345        // about them.
1346        for group in groups {
1347            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1348            children.nodes_mut().push(group.into());
1349        }
1350        for cmd in subcommands.values() {
1351            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1352            children.nodes_mut().push(cmd.into());
1353        }
1354        for example in examples {
1355            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1356            children.nodes_mut().push(example.into());
1357        }
1358        for heading in headings {
1359            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1360            children.nodes_mut().push(heading.into());
1361        }
1362        // Outputs before the flag that picks among them, so a reader meets the things
1363        // being chosen before the rule for choosing — the same order groups follow.
1364        for output in outputs {
1365            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1366            children.nodes_mut().push(output.into());
1367        }
1368        if let Some(select) = select {
1369            let mut select_node = KdlNode::new("select");
1370            select_node.push(string_entry(None, select));
1371            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1372            children.nodes_mut().push(select_node);
1373        }
1374        for exit_code in exit_codes {
1375            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1376            children.nodes_mut().push(exit_code.into());
1377        }
1378        for complete in complete.values() {
1379            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
1380            children.nodes_mut().push(complete.into());
1381        }
1382        node
1383    }
1384}
1385
1386#[cfg(feature = "clap")]
1387impl From<&clap::Command> for SpecCommand {
1388    fn from(cmd: &clap::Command) -> Self {
1389        let mut spec = Self {
1390            name: cmd.get_name().to_string(),
1391            hide: cmd.is_hide_set(),
1392            help: cmd.get_about().map(|s| s.to_string()),
1393            help_long: cmd.get_long_about().map(|s| s.to_string()),
1394            before_help: cmd.get_before_help().map(|s| s.to_string()),
1395            before_help_long: cmd.get_before_long_help().map(|s| s.to_string()),
1396            after_help: cmd.get_after_help().map(|s| s.to_string()),
1397            after_help_long: cmd.get_after_long_help().map(|s| s.to_string()),
1398            ..Default::default()
1399        };
1400        // What clap would do with a dash-word it does not recognize, said out loud.
1401        //
1402        // clap rejects one; this spec's default is to offer it to the positionals, because a
1403        // spec also describes wrappers — a script run through `usage exec`, a task's arguments —
1404        // where a dash-word is data in transit rather than a mistake. A CLI generated *from
1405        // clap*, though, is not one of those: clap already decided, and saying nothing here
1406        // silently loosened every command it described. mise's spec has 211 commands and not one
1407        // of them said `unknown_flags`, so `mise use --globa` became a tool named `--globa`
1408        // rather than the error clap gives.
1409        //
1410        // Which commands forward unknown *flags* is clap's own knowledge: an argument that
1411        // accepts hyphen values, or a trailing var arg. In mise that is five commands —
1412        // `run`, `watch`, `asdf`, `tool-stub` and the root's implicit task arguments — and
1413        // the other two hundred get the stricter reading back.
1414        //
1415        // An external subcommand is a different shape: an unmatched *word* is forwarded with
1416        // the rest of argv. clap still rejects an unknown flag on such a command (`x --wat`),
1417        // so mapping `allow_external_subcommands` onto `unknown_flags=value` silently loosened
1418        // every clap CLI that allowed one.
1419        spec.external_subcommand = cmd.is_allow_external_subcommands_set();
1420        let forwards = cmd
1421            .get_arguments()
1422            .any(|arg| arg.is_allow_hyphen_values_set() || arg.is_trailing_var_arg_set());
1423        spec.unknown_flags = Some(if forwards {
1424            UnknownFlags::Value
1425        } else {
1426            UnknownFlags::Error
1427        });
1428
1429        for alias in cmd.get_visible_aliases() {
1430            spec.aliases.push(alias.to_string());
1431        }
1432        for alias in cmd.get_all_aliases() {
1433            if spec.aliases.contains(&alias.to_string()) {
1434                continue;
1435            }
1436            spec.hidden_aliases.push(alias.to_string());
1437        }
1438        for arg in cmd.get_arguments() {
1439            let complete_type = crate::spec::arg::value_hint_type(arg.get_value_hint());
1440            let conflicts: Vec<String> = cmd
1441                .get_arg_conflicts_with(arg)
1442                .iter()
1443                .filter_map(|other| match (other.get_long(), other.get_short()) {
1444                    (Some(long), _) => Some(format!("--{long}")),
1445                    (None, Some(short)) => Some(format!("-{short}")),
1446                    (None, None) if other.is_positional() => Some(SpecArg::from(*other).name),
1447                    (None, None) => None,
1448                })
1449                .collect();
1450            if arg.is_positional() {
1451                let mut positional: SpecArg = arg.into();
1452                positional.allow_negative_numbers |= cmd.is_allow_negative_numbers_set();
1453                positional.conflicts = conflicts;
1454                if let Some(type_) = complete_type {
1455                    let name = positional.name.to_lowercase();
1456                    spec.complete.insert(
1457                        name.clone(),
1458                        SpecComplete {
1459                            name,
1460                            type_: Some(type_.to_string()),
1461                            ..Default::default()
1462                        },
1463                    );
1464                }
1465                spec.args.push(positional)
1466            } else {
1467                let mut flag: SpecFlag = arg.into();
1468                if let Some(value) = &mut flag.arg {
1469                    value.allow_negative_numbers |= cmd.is_allow_negative_numbers_set();
1470                }
1471                // clap keeps conflicts on the command rather than on the argument, so
1472                // this is the only place both are in view. Written with dashes,
1473                // matching how the spec refers to a flag everywhere else.
1474                //
1475                // A short-only flag is named `-s`, which selectors accept as readily as
1476                // `--long`: taking only the long form would have dropped the conflict
1477                // and let the spec accept a combination clap rejects.
1478                flag.conflicts = conflicts;
1479                if let (Some(type_), Some(value)) = (complete_type, flag.arg.as_ref()) {
1480                    let name = value.name.to_lowercase();
1481                    spec.complete.insert(
1482                        name.clone(),
1483                        SpecComplete {
1484                            name,
1485                            type_: Some(type_.to_string()),
1486                            ..Default::default()
1487                        },
1488                    );
1489                }
1490                spec.flags.push(flag)
1491            }
1492        }
1493        // clap assigns an implicit monotonically increasing order to arguments. Emitting
1494        // that number for every ordinary declaration makes generated specs noisy without
1495        // changing presentation, since usage already retains declaration order. Keep the
1496        // values only when they actually reorder a section.
1497        if spec
1498            .args
1499            .windows(2)
1500            .all(|pair| pair[0].display_order <= pair[1].display_order)
1501        {
1502            for arg in &mut spec.args {
1503                arg.display_order = None;
1504            }
1505        }
1506        if spec
1507            .flags
1508            .windows(2)
1509            .all(|pair| pair[0].display_order <= pair[1].display_order)
1510        {
1511            for flag in &mut spec.flags {
1512                flag.display_order = None;
1513            }
1514        }
1515        // Groups, which clap does expose — `get_groups`, and `get_args` on each. A group
1516        // names its members by clap's internal id, so each is resolved back to the flag it
1517        // points at and written as a selector, the way conflicts are just above.
1518        //
1519        // clap's own `--help` groups (`ArgGroup` ids it creates for its built-in flags)
1520        // have no members of ours in them, so the two-member floor drops them naturally
1521        // rather than needing a name check.
1522        for group in cmd.get_groups() {
1523            let members: Vec<String> = group
1524                .get_args()
1525                .filter_map(|id| cmd.get_arguments().find(|arg| arg.get_id() == id))
1526                .filter_map(|arg| match (arg.get_long(), arg.get_short()) {
1527                    (Some(long), _) => Some(format!("--{long}")),
1528                    (None, Some(short)) => Some(format!("-{short}")),
1529                    (None, None) if arg.is_positional() => Some(SpecArg::from(arg).name),
1530                    (None, None) => None,
1531                })
1532                .collect();
1533            // Below two members there is no rule left to enforce: whatever the group said
1534            // about "at most one" or "at least one" is either vacuous or is plain
1535            // required-ness on the single flag, which the flag already carries.
1536            if members.len() < 2 {
1537                continue;
1538            }
1539            // `multiple` without `required` enforces nothing at all — any number of
1540            // members, none of them needed — so there is nothing to carry across.
1541            //
1542            // This is not a corner case. clap's *derive* emits exactly that group for
1543            // every `#[derive(Args)]` struct, named after the struct and holding all its
1544            // fields, to make `flatten` work: `clap_derive`'s `args.rs` builds
1545            // `ArgGroup::new(id).multiple(true)`. Carrying them would put a `group Lint
1546            // …` in the spec of every clap-derived CLI, including this repository's own,
1547            // describing bookkeeping rather than a rule anyone declared.
1548            let required = group.is_required_set();
1549            // `is_multiple` takes `&mut self` in clap, and a `&ArgGroup` is all a
1550            // `Command` hands out — so the group is cloned to ask. Once per group at
1551            // spec-generation time, which is a build step rather than a parse.
1552            let multiple = group.clone().is_multiple();
1553            if multiple && !required {
1554                continue;
1555            }
1556            let mut spec_group = SpecGroup::new(group.get_id().as_str(), members);
1557            spec_group.required = required;
1558            spec_group.multiple = multiple;
1559            spec.groups.push(spec_group);
1560        }
1561        spec.subcommand_required = cmd.is_subcommand_required_set();
1562        spec.subcommand_help_heading = cmd.get_subcommand_help_heading().map(str::to_string);
1563        spec.subcommand_value_name = cmd.get_subcommand_value_name().map(str::to_string);
1564        spec.next_line_help = cmd.is_next_line_help_set();
1565        spec.flatten_help = cmd.is_flatten_help_set();
1566        spec.arg_required_else_help = cmd.is_arg_required_else_help_set();
1567        spec.disable_help_flag = cmd.is_disable_help_flag_set();
1568        spec.disable_help_subcommand =
1569            cmd.get_subcommands().next().is_some() && cmd.is_disable_help_subcommand_set();
1570        spec.disable_version_flag = (cmd.get_version().is_some()
1571            || cmd.get_long_version().is_some())
1572            && cmd.is_disable_version_flag_set();
1573        spec.dont_delimit_trailing_values = cmd.is_dont_delimit_trailing_values_set();
1574        spec.args_override_self = cmd.is_args_override_self();
1575        spec.subcommand_negates_reqs = cmd.is_subcommand_negates_reqs_set();
1576        spec.args_conflicts_with_subcommands = cmd.is_args_conflicts_with_subcommands_set();
1577        spec.subcommand_precedence_over_arg = cmd.is_subcommand_precedence_over_arg_set();
1578        spec.allow_missing_positional = cmd.is_allow_missing_positional_set();
1579        for subcmd in cmd.get_subcommands() {
1580            let mut scmd: SpecCommand = subcmd.into();
1581            scmd.name = subcmd.get_name().to_string();
1582            scmd.display_order = Some(subcmd.get_display_order());
1583            spec.subcommands.insert(scmd.name.clone(), scmd);
1584        }
1585        // 999 is clap's ordinary subcommand order. Leaving every command at that value lets
1586        // usage's existing alphabetical tie-breaker produce the same page without serializing
1587        // redundant metadata.
1588        if spec
1589            .subcommands
1590            .iter()
1591            .all(|(_, subcommand)| subcommand.display_order == Some(999))
1592        {
1593            for (_, subcommand) in &mut spec.subcommands {
1594                subcommand.display_order = None;
1595            }
1596        }
1597        spec
1598    }
1599}
1600
1601#[cfg(feature = "clap")]
1602impl From<clap::Command> for Spec {
1603    fn from(cmd: clap::Command) -> Self {
1604        (&cmd).into()
1605    }
1606}
1607
1608#[cfg(test)]
1609mod tests {
1610    use crate::spec::effect::SpecCommandEffect;
1611    use crate::Spec;
1612    use insta::assert_snapshot;
1613
1614    #[test]
1615    fn test_effect_prop_and_child_node() {
1616        let spec = Spec::parse(
1617            &Default::default(),
1618            r#"
1619bin "mise"
1620cmd "ls" effect="read"
1621cmd "use" effect="write"
1622cmd "uninstall" {
1623    effect "destructive"
1624}
1625cmd "version"
1626            "#,
1627        )
1628        .unwrap();
1629
1630        let cmds = &spec.cmd.subcommands;
1631        assert_eq!(cmds["ls"].effect, Some(SpecCommandEffect::Read));
1632        assert_eq!(cmds["use"].effect, Some(SpecCommandEffect::Write));
1633        assert_eq!(
1634            cmds["uninstall"].effect,
1635            Some(SpecCommandEffect::Destructive)
1636        );
1637        // Unspecified stays unknown rather than defaulting to anything.
1638        assert_eq!(cmds["version"].effect, None);
1639    }
1640
1641    #[test]
1642    fn test_effect_is_not_inherited_by_subcommands() {
1643        let spec = Spec::parse(
1644            &Default::default(),
1645            r#"
1646bin "git"
1647cmd "remote" effect="read" {
1648    cmd "add" effect="write"
1649    cmd "show"
1650}
1651            "#,
1652        )
1653        .unwrap();
1654
1655        let remote = &spec.cmd.subcommands["remote"];
1656        assert_eq!(remote.effect, Some(SpecCommandEffect::Read));
1657        assert_eq!(
1658            remote.subcommands["add"].effect,
1659            Some(SpecCommandEffect::Write)
1660        );
1661        assert_eq!(remote.subcommands["show"].effect, None);
1662    }
1663
1664    #[test]
1665    fn test_effect_roundtrips_through_kdl() {
1666        let spec = Spec::parse(
1667            &Default::default(),
1668            r#"
1669bin "mise"
1670cmd "ls" effect="read"
1671cmd "uninstall" effect="destructive"
1672            "#,
1673        )
1674        .unwrap();
1675
1676        assert_snapshot!(spec, @r#"
1677        name mise
1678        bin mise
1679        cmd ls effect=read
1680        cmd uninstall effect=destructive
1681        "#);
1682    }
1683
1684    /// `merge` is how included and mounted specs are composed onto a command.
1685    /// It has to treat `effect` the way it treats every other optional field:
1686    /// an overlay that says nothing must not erase what is already declared.
1687    #[test]
1688    fn test_effect_survives_merge() {
1689        let cmd_with = |src: &str| {
1690            Spec::parse(&Default::default(), src)
1691                .unwrap()
1692                .cmd
1693                .subcommands["uninstall"]
1694                .clone()
1695        };
1696
1697        let declared = cmd_with(r#"cmd "uninstall" effect="destructive""#);
1698        let silent = cmd_with(r#"cmd "uninstall" help="Remove a tool""#);
1699        let contradicting = cmd_with(r#"cmd "uninstall" effect="write""#);
1700
1701        let mut cmd = declared.clone();
1702        cmd.merge(silent);
1703        assert_eq!(cmd.effect, Some(SpecCommandEffect::Destructive));
1704
1705        let mut cmd = declared;
1706        cmd.merge(contradicting);
1707        assert_eq!(cmd.effect, Some(SpecCommandEffect::Write));
1708    }
1709
1710    #[test]
1711    fn test_unknown_effect_is_an_error() {
1712        let err = Spec::parse(
1713            &Default::default(),
1714            r#"
1715bin "mise"
1716cmd "ls" effect="readonly"
1717            "#,
1718        )
1719        .unwrap_err();
1720        assert!(
1721            err.to_string().contains("Invalid usage config"),
1722            "unexpected error: {err}"
1723        );
1724    }
1725}
1726
1727#[cfg(test)]
1728mod merge_tests {
1729    use crate::Spec;
1730
1731    fn uninstall(src: &str) -> crate::SpecCommand {
1732        Spec::parse(&Default::default(), src)
1733            .unwrap()
1734            .cmd
1735            .subcommands["uninstall"]
1736            .clone()
1737    }
1738
1739    /// An overlay that says nothing about deprecation must not un-deprecate a
1740    /// command that already declared it.
1741    #[test]
1742    fn test_deprecated_survives_merge() {
1743        let declared = uninstall(r#"cmd "uninstall" deprecated="use `remove`""#);
1744        let silent = uninstall(r#"cmd "uninstall" help="Remove a tool""#);
1745        let contradicting = uninstall(r#"cmd "uninstall" deprecated="gone in v3""#);
1746
1747        let mut cmd = declared.clone();
1748        cmd.merge(silent);
1749        assert_eq!(cmd.deprecated.as_deref(), Some("use `remove`"));
1750
1751        let mut cmd = declared;
1752        cmd.merge(contradicting);
1753        assert_eq!(cmd.deprecated.as_deref(), Some("gone in v3"));
1754    }
1755
1756    #[test]
1757    fn mounted_flags_replace_outputs_and_their_selector() {
1758        let mut mounting = uninstall(
1759            r#"cmd "uninstall" { flag "--format <FORMAT>"; output "json" framing="json"; select "--format" }"#,
1760        );
1761        let mounted = uninstall(r#"cmd "uninstall" { flag "--quiet" }"#);
1762
1763        mounting.merge(mounted);
1764
1765        assert!(mounting.outputs.is_empty());
1766        assert!(mounting.select.is_none());
1767    }
1768}
1769
1770#[cfg(test)]
1771mod roundtrip_tests {
1772    use crate::kdl;
1773    use crate::Spec;
1774
1775    /// Serializing a spec back to KDL and reparsing it must not lose anything.
1776    ///
1777    /// The parser is a match on node names, so exhaustive destructuring can't
1778    /// catch a field the serializer knows about but the parser doesn't, or the
1779    /// reverse. Comparing the serde representation covers every field without
1780    /// this test having to enumerate them, so a new field is covered the day it
1781    /// is added.
1782    #[test]
1783    fn test_spec_survives_a_kdl_roundtrip() {
1784        let src = r#"
1785name "My CLI"
1786bin "mycli"
1787about "does things"
1788version "1.0.0"
1789author "nobody"
1790license "MIT"
1791
1792flag "-v --verbose" help="Verbose logging" global=#true count=#true
1793arg "<dir>" help="Directory to use"
1794
1795cmd "install" help="Install a package" subcommand_required=#false {
1796    alias "i"
1797    alias "add" hide=#true
1798    long_help "The long help for install"
1799    help_md "The **markdown** help for install"
1800    before_help "before"
1801    before_long_help "The long before-help for install"
1802    before_help_md "The **markdown** before-help for install"
1803    after_help "after"
1804    after_long_help "The long after-help for install"
1805    after_help_md "The **markdown** after-help for install"
1806    arg "<pkg>" help="Package to install"
1807    arg "[dest]" effect="write"
1808    flag "-f --force" help="Overwrite"
1809    flag "--purge" effect="destructive" overrides="-f" required_unless="--keep"
1810    flag "--format <FMT>" help="Output format"
1811    complete "pkg" run="mycli list --available" descriptions=#true
1812    example "mycli install foo" header="Install foo" help="Installs foo" lang="sh"
1813    example "mycli install bar"
1814    heading "Output" help="Formats are stable across releases."
1815    output "human" default=#true help="A progress log"
1816    output "json" framing="json" help="One report object" {
1817        schema "{\n  \"type\": \"object\"\n}"
1818    }
1819    select "--format"
1820    exit_code 0 "installed"
1821    exit_code 1 "the package was not found"
1822    cmd "from" help="Install from a source" {
1823        arg "<src>"
1824    }
1825}
1826cmd "wrapped" help="Wraps another CLI" {
1827    mount run="mycli plugin usage-spec"
1828}
1829cmd "remove" help="Remove a package" deprecated="use `uninstall`" effect="destructive"
1830cmd "run" restart_token=":::" help="Run tasks"
1831cmd "exec" external_subcommand=#true help="Run an external command"
1832cmd "hidden" hide=#true
1833        "#;
1834
1835        let original = Spec::parse(&Default::default(), src).unwrap();
1836        let reparsed = Spec::parse(&Default::default(), &original.to_string()).unwrap();
1837
1838        let original = serde_json::to_value(&original).unwrap();
1839        let reparsed = serde_json::to_value(&reparsed).unwrap();
1840        pretty_assertions::assert_eq!(original, reparsed);
1841
1842        // Equality is only meaningful if the fixture actually populated the
1843        // fields, so guard against a future edit quietly emptying it out.
1844        let install = &original["cmd"]["subcommands"]["install"];
1845        let purge = install["flags"]
1846            .as_array()
1847            .unwrap()
1848            .iter()
1849            .find(|flag| flag["name"] == "purge")
1850            .unwrap();
1851        assert_eq!(purge["overrides"], serde_json::json!(["-f"]));
1852        assert_eq!(purge["required_unless"], serde_json::json!(["--keep"]));
1853        for key in [
1854            "help_long",
1855            "help_md",
1856            "before_help",
1857            "before_help_long",
1858            "before_help_md",
1859            "after_help",
1860            "after_help_long",
1861            "after_help_md",
1862            "deprecated",
1863            "effect",
1864            "restart_token",
1865            "examples",
1866            "headings",
1867            "complete",
1868            "mounts",
1869            "aliases",
1870            "hidden_aliases",
1871            "outputs",
1872            "select",
1873            "exit_codes",
1874        ] {
1875            let populated = match key {
1876                // These sit on other commands in the fixture.
1877                "deprecated" | "effect" => {
1878                    original["cmd"]["subcommands"]["remove"].get(key).is_some()
1879                }
1880                "restart_token" => original["cmd"]["subcommands"]["run"].get(key).is_some(),
1881                "mounts" => !original["cmd"]["subcommands"]["wrapped"]["mounts"]
1882                    .as_array()
1883                    .unwrap()
1884                    .is_empty(),
1885                _ => match install.get(key) {
1886                    Some(serde_json::Value::Array(a)) => !a.is_empty(),
1887                    Some(serde_json::Value::Object(o)) => !o.is_empty(),
1888                    Some(_) => true,
1889                    None => false,
1890                },
1891            };
1892            assert!(populated, "fixture does not exercise `{key}`");
1893        }
1894
1895        // Flag- and arg-level effects live one level down, so check them
1896        // explicitly rather than by name against the command object.
1897        assert!(
1898            install["flags"]
1899                .as_array()
1900                .unwrap()
1901                .iter()
1902                .any(|f| f.get("effect").is_some()),
1903            "fixture does not exercise a flag-level `effect`"
1904        );
1905        assert!(
1906            install["args"]
1907                .as_array()
1908                .unwrap()
1909                .iter()
1910                .any(|a| a.get("effect").is_some()),
1911            "fixture does not exercise an arg-level `effect`"
1912        );
1913    }
1914    #[cfg(feature = "clap")]
1915    #[test]
1916    fn a_clap_command_says_what_clap_would_do_with_an_unknown_flag() {
1917        use super::{SpecCommand, UnknownFlags};
1918
1919        // clap rejects a dash-word it does not know; this spec's default is to offer it to the
1920        // positionals. Saying nothing therefore loosened every command generated from clap —
1921        // which is how `mise use --globa` became a tool named `--globa` rather than an error.
1922        let plain = clap::Command::new("build")
1923            .arg(clap::Arg::new("target").required(false))
1924            .arg(clap::Arg::new("force").long("force").num_args(0));
1925        let spec: SpecCommand = (&plain).into();
1926        assert_eq!(spec.unknown_flags, Some(UnknownFlags::Error));
1927
1928        // A command that forwards says so, and clap is the one that knows: an argument taking
1929        // hyphen values is what a wrapper looks like.
1930        let wrapper = clap::Command::new("run").arg(
1931            clap::Arg::new("args")
1932                .num_args(0..)
1933                .allow_hyphen_values(true),
1934        );
1935        let spec: SpecCommand = (&wrapper).into();
1936        assert_eq!(spec.unknown_flags, Some(UnknownFlags::Value));
1937
1938        // As does one whose trailing argument swallows the rest.
1939        let trailing = clap::Command::new("exec").arg(
1940            clap::Arg::new("cmd")
1941                .num_args(0..)
1942                .trailing_var_arg(true)
1943                .allow_hyphen_values(true),
1944        );
1945        let spec: SpecCommand = (&trailing).into();
1946        assert_eq!(spec.unknown_flags, Some(UnknownFlags::Value));
1947
1948        // And a command that takes whatever subcommand it is given, which is a different
1949        // shape from forwarding unknown flags: clap still rejects `x --wat`.
1950        let external = clap::Command::new("x").allow_external_subcommands(true);
1951        let spec: SpecCommand = (&external).into();
1952        assert_eq!(spec.unknown_flags, Some(UnknownFlags::Error));
1953        assert!(spec.external_subcommand);
1954    }
1955
1956    #[cfg(feature = "clap")]
1957    #[test]
1958    fn the_decision_survives_being_written_and_read_back() {
1959        use super::SpecCommand;
1960
1961        // The point of setting it is what a *parser* does with the spec afterwards, so the round
1962        // trip is what makes it true rather than the field.
1963        let plain = clap::Command::new("build").arg(clap::Arg::new("target").required(false));
1964        let spec: SpecCommand = (&plain).into();
1965        let node: kdl::KdlNode = (&spec).into();
1966        let kdl = node.to_string();
1967        assert!(kdl.contains("unknown_flags=error"), "{kdl}");
1968    }
1969
1970    #[cfg(feature = "clap")]
1971    #[test]
1972    fn the_clap_bridge_preserves_args_override_self() {
1973        use super::SpecCommand;
1974
1975        let strict: SpecCommand = (&clap::Command::new("strict")).into();
1976        assert!(!strict.args_override_self, "clap is strict by default");
1977
1978        let permissive: SpecCommand =
1979            (&clap::Command::new("permissive").args_override_self(true)).into();
1980        assert!(permissive.args_override_self);
1981
1982        let node: kdl::KdlNode = (&strict).into();
1983        assert!(node.to_string().contains("args_override_self=#false"));
1984    }
1985
1986    #[cfg(feature = "clap")]
1987    #[test]
1988    fn the_clap_bridge_preserves_subcommand_presentation() {
1989        use super::SpecCommand;
1990
1991        let spec: SpecCommand = (&clap::Command::new("ex")
1992            .subcommand(clap::Command::new("run"))
1993            .subcommand_help_heading("Actions")
1994            .subcommand_value_name("ACTION"))
1995            .into();
1996        assert_eq!(spec.subcommand_help_heading.as_deref(), Some("Actions"));
1997        assert_eq!(spec.subcommand_value_name.as_deref(), Some("ACTION"));
1998        let node: kdl::KdlNode = (&spec).into();
1999        let kdl = node.to_string();
2000        assert!(kdl.contains("subcommand_help_heading=Actions"), "{kdl}");
2001        assert!(kdl.contains("subcommand_value_name=ACTION"), "{kdl}");
2002    }
2003
2004    #[cfg(feature = "clap")]
2005    #[test]
2006    fn the_clap_bridge_preserves_subcommand_negates_requirements() {
2007        use super::SpecCommand;
2008
2009        let spec: SpecCommand = (&clap::Command::new("ex").subcommand_negates_reqs(true)).into();
2010        assert!(spec.subcommand_negates_reqs);
2011        let node: kdl::KdlNode = (&spec).into();
2012        assert!(node.to_string().contains("subcommand_negates_reqs=#true"));
2013    }
2014
2015    #[cfg(feature = "clap")]
2016    #[test]
2017    fn the_clap_bridge_preserves_argument_subcommand_conflicts() {
2018        use super::SpecCommand;
2019
2020        let spec: SpecCommand =
2021            (&clap::Command::new("ex").args_conflicts_with_subcommands(true)).into();
2022        assert!(spec.args_conflicts_with_subcommands);
2023        let node: kdl::KdlNode = (&spec).into();
2024        assert!(node
2025            .to_string()
2026            .contains("args_conflicts_with_subcommands=#true"));
2027    }
2028
2029    #[cfg(feature = "clap")]
2030    #[test]
2031    fn the_clap_bridge_preserves_allow_missing_positional() {
2032        use super::SpecCommand;
2033
2034        let spec: SpecCommand = (&clap::Command::new("ex").allow_missing_positional(true)).into();
2035        assert!(spec.allow_missing_positional);
2036        let node: kdl::KdlNode = (&spec).into();
2037        assert!(node.to_string().contains("allow_missing_positional=#true"));
2038    }
2039}