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