Skip to main content

usage/spec/
mod.rs

1pub mod admonition;
2pub mod arg;
3pub mod builder;
4pub mod choices;
5pub mod clause;
6pub mod cmd;
7pub mod complete;
8pub mod config;
9pub mod config_type;
10mod context;
11pub mod data_types;
12pub mod effect;
13pub mod exit_code;
14pub mod flag;
15pub mod flagset;
16pub mod group;
17pub mod helpers;
18pub mod mount;
19pub mod output;
20pub mod unknown_flags;
21pub mod view;
22
23use crate::kdl;
24use indexmap::IndexMap;
25use kdl::{KdlDocument, KdlEntry, KdlNode};
26use log::{debug, warn};
27use regex::Regex;
28use serde::Serialize;
29use std::collections::HashMap;
30use std::fmt::{Display, Formatter};
31use std::iter::once;
32use std::path::{Path, PathBuf};
33use std::str::FromStr;
34use std::sync::LazyLock;
35
36use crate::error::UsageErr;
37use crate::spec::cmd::{SpecCommand, SpecExample, SpecHeading};
38use crate::spec::config::SpecConfig;
39use crate::spec::context::ParsingContext;
40use crate::spec::exit_code::SpecExitCode;
41use crate::spec::flagset::{SpecFlagSet, SpecUse};
42use crate::spec::helpers::{string_entry, NodeHelper};
43use crate::spec::output::SpecOutput;
44use crate::{SpecArg, SpecComplete, SpecFlag};
45use view::SpecView;
46
47#[derive(Debug, Default, Clone, Serialize)]
48#[non_exhaustive]
49pub struct Spec {
50    pub name: String,
51    pub bin: String,
52    pub cmd: SpecCommand,
53    pub config: SpecConfig,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub version: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub long_version: Option<String>,
58    pub usage: String,
59    pub complete: IndexMap<String, SpecComplete>,
60    /// Named executable surfaces promoted from commands in this canonical spec.
61    #[serde(skip_serializing_if = "IndexMap::is_empty")]
62    pub views: IndexMap<String, SpecView>,
63    /// Reusable flag declarations, by name.
64    ///
65    /// Not serialized, and not re-emitted: a `use` is resolved while the file is read, so by
66    /// the time anything reads this spec the flags are on the commands that use them and these
67    /// entries only record where they came from.
68    #[serde(skip)]
69    pub flagsets: IndexMap<String, SpecFlagSet>,
70    /// Every file this spec was read from: its own path, each `include`, and external output
71    /// schema files, recursively.
72    ///
73    /// What a build script has to watch. A generator that watches only the file it was pointed at
74    /// rebuilds nothing when an included KDL or JSON schema changes.
75    ///
76    /// Not serialized: it is where the spec came from rather than part of what it says, and `usage g
77    /// json` describes the latter.
78    #[serde(skip)]
79    pub sources: Vec<PathBuf>,
80
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub source_code_link_template: Option<String>,
83    /// Where the CLI's source lives, e.g. `https://github.com/jdx/mise`.
84    ///
85    /// Distinct from [`Self::source_code_link_template`], which is a per-command
86    /// deep link with a `{{path}}` placeholder and is only usable for building
87    /// "view source" links in generated docs. Scraping a repository out of it
88    /// works for one forge and one URL layout and fails everywhere else.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub repository: Option<String>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub author: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub about: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub about_long: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub about_md: Option<String>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub license: Option<String>,
101    /// Art printed on the program's own help page, as the author wrote it.
102    ///
103    /// Where it lands is decided by how wide the terminal is, not by the spec: beside the page
104    /// when there is room for it there, above the page when there is not, and not at all on a
105    /// terminal too narrow for even that. See `crate::docs::logo`.
106    ///
107    /// The root page only. A logo is what a program is, and reprinting it on forty subcommand
108    /// pages would make it furniture.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub logo: Option<String>,
111    /// How [`Self::logo`] is coloured, from the `help_template` style vocabulary.
112    ///
113    /// One specification for the whole logo, `+`-combined as everywhere else —
114    /// `style="cyan+bold"`. Art that wants more colours than that carries its own escapes,
115    /// which a plain page strips like any other authored escape.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub logo_style: Option<String>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub before_help: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub after_help: Option<String>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub before_help_long: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub after_help_long: Option<String>,
126    /// How every page in this CLI is laid out, as named sections.
127    ///
128    /// One template for the whole tree, holding the pre-rendered sections an author may reorder,
129    /// omit, wrap or colour. Nothing else is substituted: the closed section and style
130    /// vocabularies let an interpreter, a compiled parser and generated Go agree without
131    /// exposing the metadata behind a section.
132    ///
133    /// A placeholder naming no section is refused when the spec is read, so a page is never
134    /// rendered from a template one of whose sections cannot be filled.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub help_template: Option<String>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub disable_help: Option<bool>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub min_usage_version: Option<String>,
141    #[serde(skip_serializing_if = "Vec::is_empty")]
142    pub examples: Vec<SpecExample>,
143    /// CLI-wide outputs, inherited by every command that does not say otherwise.
144    #[serde(skip_serializing_if = "Vec::is_empty")]
145    pub outputs: Vec<SpecOutput>,
146    /// The CLI-wide flag whose value picks among [`Self::outputs`].
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub select: Option<String>,
149    /// CLI-wide exit codes, refined per command rather than replaced.
150    #[serde(skip_serializing_if = "Vec::is_empty")]
151    pub exit_codes: Vec<SpecExitCode>,
152    /// Default subcommand to use when first non-flag argument is not a known subcommand.
153    /// This enables "naked" command syntax like `mise foo` instead of `mise run foo`.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub default_subcommand: Option<String>,
156    /// Opt in to routing flags of the default command before its first positional.
157    #[serde(skip_serializing_if = "is_false")]
158    pub default_subcommand_flags: bool,
159    #[serde(skip)]
160    pub default_subcommand_flags_set: bool,
161    /// Opt in to selecting the default subcommand for empty successful input.
162    #[serde(skip_serializing_if = "is_false")]
163    pub default_subcommand_on_empty: bool,
164    #[serde(skip)]
165    pub default_subcommand_on_empty_set: bool,
166    /// Opt in to appending the default command's help page after the root page.
167    #[serde(skip_serializing_if = "is_false")]
168    pub default_subcommand_help: bool,
169    #[serde(skip)]
170    pub default_subcommand_help_set: bool,
171    /// Whether argv[0]'s basename selects a subcommand (busybox-style applets).
172    ///
173    /// clap's `multicall`. The dispatcher names ([`Self::name`] and [`Self::bin`])
174    /// are skipped; any other basename is parsed as the first word, so a symlink
175    /// `ls -> busybox` runs the `ls` applet. Path components and a trailing `.exe`
176    /// are stripped.
177    #[serde(default, skip_serializing_if = "is_false")]
178    pub multicall: bool,
179    /// Whether the source explicitly declared [`Self::multicall`].
180    ///
181    /// This distinguishes an omitted node from `multicall #false` while includes
182    /// are merged. It is parsing bookkeeping rather than part of the JSON model.
183    #[doc(hidden)]
184    #[serde(skip)]
185    pub multicall_set: bool,
186    /// What to do with a flag-like token that names no declared flag, for the whole
187    /// CLI. A command may override it; see [`SpecCommand::unknown_flags`].
188    pub unknown_flags: Option<crate::spec::unknown_flags::UnknownFlags>,
189}
190
191impl Spec {
192    /// Recompute everything a command's position in this tree decides, after building one by
193    /// hand: each command's path, its usage line, and the memoized subcommand lookup.
194    ///
195    /// A `SpecCommand` inserted into `subcommands` carries the path it had wherever it came
196    /// from — a spec of its own, most likely, where it was the root and its path was empty. Its
197    /// help page would say so. So would its parent's, which gains a `<SUBCOMMAND>` placeholder
198    /// the first time it acquires a child. Call this after grafting, and the tree is
199    /// indistinguishable from one that declared the same commands in KDL.
200    ///
201    /// The alternative — writing the tree out and parsing it back, which is how this was reached
202    /// before — costs a KDL round trip of the whole spec. For a large one that is a quarter of a
203    /// second to fix up strings.
204    pub fn restamp(&mut self) {
205        restamp_paths(&mut self.cmd, &[], true);
206    }
207
208    /// Resolve every mount from supplied command outputs without spawning processes.
209    ///
210    /// This is intended for deterministic generators and conformance harnesses. The
211    /// map is keyed by each mount's exact `run` declaration. Missing entries are an
212    /// error, so injecting a partial view cannot silently execute the remainder.
213    pub fn resolve_mount_outputs(
214        &mut self,
215        outputs: &HashMap<String, String>,
216    ) -> Result<(), UsageErr> {
217        self.resolve_mount_outputs_at_root(outputs, true)
218    }
219
220    pub(crate) fn resolve_mount_outputs_at_root(
221        &mut self,
222        outputs: &HashMap<String, String>,
223        apply_default_subcommand: bool,
224    ) -> Result<(), UsageErr> {
225        fn resolve(
226            cmd: &mut SpecCommand,
227            outputs: &HashMap<String, String>,
228            skip_mounts: bool,
229        ) -> Result<(), UsageErr> {
230            if !skip_mounts && !cmd.mounts.is_empty() {
231                cmd.mount(&[], Some(outputs))?;
232                cmd.mounts.clear();
233            }
234            for subcommand in cmd.subcommands.values_mut() {
235                resolve(subcommand, outputs, false)?;
236            }
237            Ok(())
238        }
239
240        let default_outranks_root_mounts = apply_default_subcommand
241            && self.default_subcommand.is_some()
242            && !self.cmd.mounts.iter().any(|mount| mount.overrides_default);
243        resolve(&mut self.cmd, outputs, default_outranks_root_mounts)?;
244        self.restamp();
245        self.cmd
246            .validate_clause_flag_spellings()
247            .map_err(UsageErr::InvalidSpec)
248    }
249
250    /// Parse a spec from a file.
251    ///
252    /// Automatically detects whether the file is:
253    /// - A `.kdl` or `.usage.kdl` file containing a raw spec
254    /// - A script file with embedded `#USAGE` comments
255    ///
256    /// If `bin` is not specified in the spec, it defaults to the filename.
257    #[must_use = "parsing result should be used"]
258    pub fn parse_file(file: &Path) -> Result<Spec, UsageErr> {
259        Self::parse_file_with_metadata_inference(file, true, true)
260    }
261
262    fn parse_file_with_metadata_inference(
263        file: &Path,
264        infer_metadata_from_filename: bool,
265        resolve_outputs: bool,
266    ) -> Result<Spec, UsageErr> {
267        Self::parse_file_with_metadata_inference_and_env(
268            file,
269            infer_metadata_from_filename,
270            resolve_outputs,
271            None,
272        )
273    }
274
275    fn parse_file_with_metadata_inference_and_env(
276        file: &Path,
277        infer_metadata_from_filename: bool,
278        resolve_outputs: bool,
279        include_env: Option<&HashMap<String, String>>,
280    ) -> Result<Spec, UsageErr> {
281        let spec = split_script(file)?;
282        let mut ctx = ParsingContext::new(file, &spec);
283        if let Some(env) = include_env {
284            ctx = ctx.with_include_env(env);
285        }
286        let mut schema = Self::parse_with_output_resolution(&ctx, &spec, resolve_outputs)?;
287        if infer_metadata_from_filename && schema.bin.is_empty() {
288            schema.bin = file
289                .file_name()
290                .and_then(|n| n.to_str())
291                .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
292                .to_string();
293        }
294        if schema.name.is_empty() {
295            schema.name.clone_from(&schema.bin);
296        }
297        Ok(schema)
298    }
299    /// Parse a spec from a script file's embedded USAGE comments.
300    ///
301    /// Extracts the spec from comment lines marked with `#USAGE`, `//USAGE`,
302    /// `::USAGE`, or their `[USAGE]` variants.
303    /// If `bin` is not specified in the spec, it defaults to the filename.
304    #[must_use = "parsing result should be used"]
305    pub fn parse_script(file: &Path) -> Result<Spec, UsageErr> {
306        let mut spec = Self::parse_script_with_path(&read_to_string(file)?, file)?;
307        if spec.bin.is_empty() {
308            spec.bin = file
309                .file_name()
310                .and_then(|n| n.to_str())
311                .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
312                .to_string();
313        }
314        if spec.name.is_empty() {
315            spec.name.clone_from(&spec.bin);
316        }
317        Ok(spec)
318    }
319
320    /// Parse a spec from a script string's embedded USAGE comments.
321    ///
322    /// Extracts the spec from comment lines marked with `#USAGE`, `//USAGE`,
323    /// `::USAGE`, or their `[USAGE]` variants. Unlike [`Self::parse_script`],
324    /// this function cannot infer `bin` or `name` from a filename. Relative
325    /// `include` paths are rejected because there is no source path to resolve
326    /// them against; absolute `include` paths remain supported.
327    #[must_use = "parsing result should be used"]
328    pub fn parse_script_str(input: &str) -> Result<Spec, UsageErr> {
329        Self::parse_script_with_path(input, Path::new(""))
330    }
331
332    /// Parse spec text that came from `file`, without reading the file itself.
333    ///
334    /// For a caller that already holds the spec — because it extracted the spec lines from a
335    /// larger file, or rendered them from a template — but knows which file they came from.
336    /// The path is what relative `include` paths resolve against, and what diagnostics name,
337    /// so the same text parsed through [`FromStr`] differs from this only in that its
338    /// includes have nothing to be relative to.
339    #[must_use = "parsing result should be used"]
340    pub fn parse_str_with_path(input: &str, file: &Path) -> Result<Spec, UsageErr> {
341        Self::parse(&ParsingContext::new(file, input), input)
342    }
343
344    /// Parse spec text from `file`, expanding environment variables in `include` paths.
345    ///
346    /// Expansion is deliberately limited to an `include` node's `file` property. It accepts
347    /// `$NAME`, `${NAME}`, and `$$`; an undefined variable is an error. Other spec strings are
348    /// left byte-for-byte unchanged. Included files inherit the same environment for nested
349    /// includes.
350    #[must_use = "parsing result should be used"]
351    pub fn parse_str_with_path_and_env(
352        input: &str,
353        file: &Path,
354        env: &HashMap<String, String>,
355    ) -> Result<Spec, UsageErr> {
356        let ctx = ParsingContext::new(file, input).with_include_env(env);
357        Self::parse(&ctx, input)
358    }
359
360    fn parse_script_with_path(input: &str, file: &Path) -> Result<Spec, UsageErr> {
361        let raw = extract_usage_from_comments(input);
362        let ctx = ParsingContext::new(file, &raw);
363        Self::parse(&ctx, &raw)
364    }
365
366    #[deprecated]
367    pub fn parse_spec(input: &str) -> Result<Spec, UsageErr> {
368        Self::parse(&Default::default(), input)
369    }
370
371    pub fn is_empty(&self) -> bool {
372        self.name.is_empty()
373            && self.bin.is_empty()
374            && self.usage.is_empty()
375            && self.cmd.is_empty()
376            && self.config.is_empty()
377            && self.complete.is_empty()
378            && self.views.is_empty()
379            && self.examples.is_empty()
380    }
381
382    /// Materialize one declared executable view.
383    ///
384    /// This is a cold-path operation for documentation and completion generation. The canonical
385    /// spec remains unchanged; the returned spec promotes the view's command to the root and
386    /// carries only the root globals the view declares.
387    pub fn for_view(&self, id: &str) -> Result<Spec, UsageErr> {
388        let view = self
389            .views
390            .get(id)
391            .ok_or_else(|| UsageErr::InvalidView(format!("spec declares no view named `{id}`")))?;
392        let mut command = &self.cmd;
393        for segment in view.root.split_whitespace() {
394            command = command.subcommands.get(segment).ok_or_else(|| {
395                UsageErr::InvalidView(format!(
396                    "view `{id}` promotes `{}`, but `{segment}` is not a command on that path",
397                    view.root
398                ))
399            })?;
400        }
401        let mut promoted = command.clone();
402        let matches_selector = |flag: &SpecFlag, selector: &str| {
403            selector
404                .strip_prefix("--")
405                .is_some_and(|name| flag.long.iter().any(|long| long == name))
406                || selector
407                    .strip_prefix('-')
408                    .filter(|short| short.len() == 1)
409                    .and_then(|short| short.chars().next())
410                    .is_some_and(|short| flag.short.contains(&short))
411        };
412        let carries = |flag: &SpecFlag| {
413            if !flag.global {
414                return false;
415            }
416            view.all_globals
417                || view
418                    .globals
419                    .iter()
420                    .any(|selector| matches_selector(flag, selector))
421        };
422        for selector in &view.globals {
423            if !self
424                .cmd
425                .flags
426                .iter()
427                .any(|flag| flag.global && matches_selector(flag, selector))
428            {
429                return Err(UsageErr::InvalidView(format!(
430                    "view `{id}` carries `{selector}`, but it is not a root global flag"
431                )));
432            }
433        }
434        let globals: Vec<SpecFlag> = self
435            .cmd
436            .flags
437            .iter()
438            // A view is another executable surface of this package. Keep the host's
439            // version actions in addition to the globals explicitly carried by the view.
440            .filter(|flag| carries(flag) || flag.action == crate::SpecFlagAction::Version)
441            .cloned()
442            .collect();
443        // A promoted command may redeclare a global spelling. The nearer declaration owns it,
444        // matching ordinary parsing, so do not create a duplicate root flag.
445        let mut globals: Vec<SpecFlag> = globals
446            .into_iter()
447            .filter(|global| {
448                !promoted
449                    .flags
450                    .iter()
451                    .any(|local| spec_flag_forms_overlap(global, local))
452            })
453            .collect();
454        // Root completers belong to the host's fields, not to every executable view. Carry the
455        // ones for selected globals, then let the promoted command's own completers win on a
456        // shared name. A promoted command is the new root, so its command-scoped entries become
457        // the materialized spec's root entries rather than remaining in both places.
458        let mut complete = IndexMap::new();
459        for flag in &globals {
460            if let Some(arg) = &flag.arg {
461                let name = arg.name.to_lowercase();
462                if let Some(completer) = self.complete.get(&name) {
463                    complete.insert(name, completer.clone());
464                }
465            }
466        }
467        complete.extend(std::mem::take(&mut promoted.complete));
468        // Root groups are relationships between the root fields, so project them along with
469        // the carried globals. A group reduced to one required member is ordinary requiredness;
470        // keeping it as a one-member group would emit KDL the spec reader deliberately refuses.
471        let mut carried_groups = Vec::new();
472        for group in &self.cmd.groups {
473            let members: Vec<String> = group
474                .members
475                .iter()
476                .filter(|selector| {
477                    globals
478                        .iter()
479                        .any(|flag| flag_matches_selector(flag, selector))
480                })
481                .cloned()
482                .collect();
483            match members.as_slice() {
484                [only] if group.required => {
485                    if let Some(flag) = globals
486                        .iter_mut()
487                        .find(|flag| flag_matches_selector(flag, only))
488                    {
489                        flag.required = true;
490                    }
491                }
492                [_, _, ..] => {
493                    let mut projected = group.clone();
494                    projected.members = members;
495                    carried_groups.push(projected);
496                }
497                _ => {}
498            }
499        }
500        promoted.flags.splice(0..0, globals);
501        promoted.groups.splice(0..0, carried_groups);
502        promoted.name.clone_from(&view.bin);
503        promoted.full_cmd.clear();
504        promoted.aliases.clear();
505        promoted.hidden_aliases.clear();
506        // A view is another executable surface of the host package. Keep the host policy that
507        // governs its synthesized version entry along with the host version strings retained on
508        // `spec`; otherwise materializing a promoted command silently re-enables `--version`.
509        promoted.disable_version_flag = self.cmd.disable_version_flag;
510        set_subcommand_ancestors(&mut promoted, &[]);
511        promoted.usage = promoted.usage();
512
513        let mut spec = self.clone();
514        spec.name.clone_from(&view.name);
515        spec.bin.clone_from(&view.bin);
516        spec.about = promoted.help.clone();
517        spec.about_long = promoted.help_long.clone();
518        spec.about_md = promoted.help_md.clone();
519        spec.before_help = promoted.before_help.clone();
520        spec.before_help_long = promoted.before_help_long.clone();
521        spec.after_help = promoted.after_help.clone();
522        spec.after_help_long = promoted.after_help_long.clone();
523        spec.examples.clone_from(&promoted.examples);
524        spec.usage = promoted.usage.clone();
525        spec.complete = complete;
526        spec.cmd = promoted;
527        spec.default_subcommand = None;
528        spec.default_subcommand_flags = false;
529        spec.default_subcommand_flags_set = false;
530        spec.default_subcommand_on_empty = false;
531        spec.default_subcommand_on_empty_set = false;
532        spec.default_subcommand_help = false;
533        spec.default_subcommand_help_set = false;
534        spec.multicall = false;
535        spec.multicall_set = false;
536        spec.views.clear();
537        Ok(spec)
538    }
539
540    /// The stable identifier of the executable view selected by a program name.
541    pub fn view_for_program(&self, program: &str) -> Option<&str> {
542        let basename = crate::parse::multicall_basename(program);
543        if basename == crate::parse::multicall_basename(&self.name)
544            || (!self.bin.is_empty() && basename == crate::parse::multicall_basename(&self.bin))
545        {
546            return None;
547        }
548        self.views.values().find_map(|view| {
549            (basename == crate::parse::multicall_basename(&view.bin)
550                || basename == crate::parse::multicall_basename(&view.id))
551            .then_some(view.id.as_str())
552        })
553    }
554
555    pub(crate) fn parse(ctx: &ParsingContext, input: &str) -> Result<Spec, UsageErr> {
556        Self::parse_with_output_resolution(ctx, input, true)
557    }
558
559    fn parse_with_output_resolution(
560        ctx: &ParsingContext,
561        input: &str,
562        resolve_outputs: bool,
563    ) -> Result<Spec, UsageErr> {
564        let kdl: KdlDocument = input.parse().map_err(|err: kdl::KdlError| {
565            UsageErr::KdlError(err.with_source_name(ctx.file.to_string_lossy()))
566        })?;
567        let mut schema = Self {
568            ..Default::default()
569        };
570        // The file being read, before anything in it can fail: a build script that watches this list
571        // should watch a spec that does not parse too, or the next build is a stale success.
572        if !ctx.file.as_os_str().is_empty() {
573            schema.sources.push(ctx.file.clone());
574        }
575        for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) {
576            match node.name() {
577                "name" => schema.name = node.arg(0)?.ensure_string()?,
578                "bin" => {
579                    schema.bin = node.arg(0)?.ensure_string()?;
580                    if schema.name.is_empty() {
581                        schema.name.clone_from(&schema.bin);
582                    }
583                }
584                "version" => schema.version = Some(node.arg(0)?.ensure_string()?),
585                "long_version" => schema.long_version = Some(node.arg(0)?.ensure_string()?),
586                "author" => schema.author = Some(node.arg(0)?.ensure_string()?),
587                "source_code_link_template" => {
588                    schema.source_code_link_template = Some(node.arg(0)?.ensure_string()?)
589                }
590                "repository" => schema.repository = Some(node.arg(0)?.ensure_string()?),
591                "about" => schema.about = Some(node.arg(0)?.ensure_string()?),
592                "long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
593                "about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
594                "about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?),
595                "surface" => schema.cmd.surface = Some(node.arg(0)?.ensure_string()?),
596                "available_if" => {
597                    schema.cmd.available_if = node
598                        .ensure_arg_len(1..)?
599                        .args()
600                        .map(|entry| entry.ensure_string())
601                        .collect::<Result<Vec<_>, _>>()?;
602                }
603                "license" => schema.license = Some(node.arg(0)?.ensure_string()?),
604                "logo" => {
605                    schema.logo = Some(node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
606                    for (key, value) in node.props() {
607                        match key {
608                            // Checked here rather than at render time, for the reason the
609                            // template's styles are: an unknown name would otherwise reach a
610                            // reader as an unstyled logo and nothing to say why.
611                            "style" => {
612                                let style = value.ensure_string()?;
613                                if let Err(problem) = crate::help_template::check_style(&style) {
614                                    bail_parse!(ctx, value.entry.span(), "{problem}");
615                                }
616                                schema.logo_style = Some(style);
617                            }
618                            key => {
619                                bail_parse!(ctx, value.entry.span(), "unsupported logo key {key}")
620                            }
621                        }
622                    }
623                }
624                "before_help" => schema.before_help = Some(node.arg(0)?.ensure_string()?),
625                "after_help" => schema.after_help = Some(node.arg(0)?.ensure_string()?),
626                "before_long_help" | "before_help_long" => {
627                    schema.before_help_long = Some(node.arg(0)?.ensure_string()?)
628                }
629                "after_long_help" | "after_help_long" => {
630                    schema.after_help_long = Some(node.arg(0)?.ensure_string()?)
631                }
632                "usage" => schema.usage = node.arg(0)?.ensure_string()?,
633                // Refused here rather than at render time, and for the reason every other
634                // unsupported word is: a page laid out by a template is read by people, and a
635                // placeholder naming no section would reach them as the braces somebody typed.
636                "help_template" => {
637                    let template = node.arg(0)?.ensure_string()?;
638                    if let Err(problem) = crate::help_template::check(&template) {
639                        bail_parse!(ctx, node.span(), "{problem}");
640                    }
641                    // Whitespace-only is no layout: store it as unset so a round trip does
642                    // not emit a node that would then render three different empty pages.
643                    schema.help_template =
644                        crate::help_template::is_set(&template).then_some(template);
645                }
646                "arg" => {
647                    let arg = SpecArg::parse(ctx, &node)?;
648                    // The same rule the `cmd` block applies: a delimiter with nowhere to
649                    // put what it splits drops everything after the first separator.
650                    if arg.delimiter.is_some() && !arg.var {
651                        bail_parse!(
652                            ctx,
653                            node.node.name().span(),
654                            "argument <{}> has a delimiter and holds one value; add \
655                             `var=#true` for the values it splits into",
656                            arg.name
657                        );
658                    }
659                    schema.cmd.args.push(arg);
660                }
661                "clause" => {
662                    if schema.cmd.clause.is_some() {
663                        bail_parse!(ctx, node.span(), "a command may declare at most one clause");
664                    }
665                    schema.cmd.clause = Some(crate::SpecClause::parse(ctx, &node)?);
666                }
667                "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?),
668                // The root command's groups, as its flags and arguments are: a spec
669                // whose top level declares flags can group them there too.
670                "group" => schema.cmd.groups.push(crate::SpecGroup::parse(ctx, &node)?),
671                // The root is a command like any other, so it can discover its own
672                // subcommands by running something. A CLI whose top-level commands
673                // come from plugins has no other way to say so.
674                "mount" => schema.cmd.mounts.push(crate::SpecMount::parse(ctx, &node)?),
675                "cmd" => {
676                    let node: SpecCommand = SpecCommand::parse(ctx, &node)?;
677                    schema.cmd.subcommands.insert(node.name.to_string(), node);
678                }
679                "flagset" => {
680                    let set = SpecFlagSet::parse(ctx, &node)?;
681                    if schema.flagsets.insert(set.name.clone(), set).is_some() {
682                        bail_parse!(ctx, node.span(), "a flagset may be declared only once");
683                    }
684                }
685                // The root is a command like any other: if its own flags repeat a set, it
686                // says so the same way a subcommand does.
687                "use" => {
688                    let at = schema.cmd.flags.len();
689                    schema.cmd.uses.push(SpecUse::parse(ctx, &node, at)?);
690                }
691                "config" => schema.config = SpecConfig::parse(ctx, &node)?,
692                "complete" => {
693                    let complete = SpecComplete::parse(ctx, &node)?;
694                    schema.complete.insert(complete.name.clone(), complete);
695                }
696                "view" => {
697                    let view = SpecView::parse(ctx, &node)?;
698                    if schema.views.insert(view.id.clone(), view).is_some() {
699                        bail_parse!(
700                            ctx,
701                            node.span(),
702                            "a view identifier may be declared only once"
703                        );
704                    }
705                }
706                "disable_help" => schema.disable_help = Some(node.arg(0)?.ensure_bool()?),
707                "min_usage_version" => {
708                    let v = node.arg(0)?.ensure_string()?;
709                    check_usage_version(&v);
710                    schema.min_usage_version = Some(v);
711                }
712                "unknown_flags" => {
713                    let raw = node.arg(0)?.ensure_string()?;
714                    match raw.parse() {
715                        Ok(mode) => schema.unknown_flags = Some(mode),
716                        Err(_) => bail_parse!(
717                            ctx,
718                            node.span(),
719                            "unsupported unknown_flags {raw}, expected one of: {}",
720                            crate::spec::unknown_flags::UNKNOWN_FLAGS_VALUES
721                        ),
722                    }
723                }
724                "default_subcommand" => {
725                    schema.default_subcommand = Some(node.arg(0)?.ensure_string()?)
726                }
727                "default_subcommand_flags" => {
728                    schema.default_subcommand_flags = node.arg(0)?.ensure_bool()?;
729                    schema.default_subcommand_flags_set = true;
730                }
731                "default_subcommand_on_empty" => {
732                    schema.default_subcommand_on_empty = node.arg(0)?.ensure_bool()?;
733                    schema.default_subcommand_on_empty_set = true;
734                }
735                "default_subcommand_help" => {
736                    schema.default_subcommand_help = node.arg(0)?.ensure_bool()?;
737                    schema.default_subcommand_help_set = true;
738                }
739                "multicall" => {
740                    schema.multicall = node.arg(0)?.ensure_bool()?;
741                    schema.multicall_set = true;
742                }
743                "external_subcommand" => {
744                    schema.cmd.external_subcommand = node.arg(0)?.ensure_bool()?;
745                }
746                "arg_required_else_help" => {
747                    schema.cmd.arg_required_else_help = node.arg(0)?.ensure_bool()?;
748                }
749                "disable_help_flag" => {
750                    schema.cmd.disable_help_flag = node.arg(0)?.ensure_bool()?;
751                }
752                "disable_help_subcommand" => {
753                    schema.cmd.disable_help_subcommand = node.arg(0)?.ensure_bool()?;
754                }
755                "disable_version_flag" => {
756                    schema.cmd.disable_version_flag = node.arg(0)?.ensure_bool()?;
757                }
758                "dont_delimit_trailing_values" => {
759                    schema.cmd.dont_delimit_trailing_values = node.arg(0)?.ensure_bool()?;
760                }
761                "args_override_self" => {
762                    schema.cmd.args_override_self = node.arg(0)?.ensure_bool()?;
763                }
764                "subcommand_negates_reqs" => {
765                    schema.cmd.subcommand_negates_reqs = node.arg(0)?.ensure_bool()?;
766                }
767                "args_conflicts_with_subcommands" => {
768                    schema.cmd.args_conflicts_with_subcommands = node.arg(0)?.ensure_bool()?;
769                }
770                "subcommand_precedence_over_arg" => {
771                    schema.cmd.subcommand_precedence_over_arg = node.arg(0)?.ensure_bool()?;
772                }
773                "allow_missing_positional" => {
774                    schema.cmd.allow_missing_positional = node.arg(0)?.ensure_bool()?;
775                }
776                "deprecated" => schema.cmd.deprecated = Some(node.arg(0)?.ensure_string()?),
777                "deprecated_warn_at" => {
778                    schema.cmd.deprecated_warn_at = Some(node.arg(0)?.ensure_string()?);
779                }
780                "deprecated_remove_at" => {
781                    schema.cmd.deprecated_remove_at = Some(node.arg(0)?.ensure_string()?);
782                }
783                "subcommand_required" => {
784                    schema.cmd.subcommand_required = node.arg(0)?.ensure_bool()?;
785                }
786                "subcommand_help_heading" => {
787                    schema.cmd.subcommand_help_heading = Some(node.arg(0)?.ensure_string()?);
788                }
789                "subcommand_value_name" => {
790                    schema.cmd.subcommand_value_name = Some(node.arg(0)?.ensure_string()?);
791                }
792                "next_line_help" => {
793                    schema.cmd.next_line_help = node.arg(0)?.ensure_bool()?;
794                }
795                "flatten_help" => {
796                    schema.cmd.flatten_help = node.arg(0)?.ensure_bool()?;
797                }
798                "term_width" => {
799                    schema.cmd.term_width = Some(node.arg(0)?.ensure_usize()?);
800                }
801                "max_term_width" => {
802                    schema.cmd.max_term_width = Some(node.arg(0)?.ensure_usize()?);
803                }
804                "example" => {
805                    let code = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
806                    let mut example = SpecExample::new(code.trim().to_string());
807                    for (k, v) in node.props() {
808                        match k {
809                            "header" => example.header = Some(v.ensure_string()?),
810                            "help" => example.help = Some(v.ensure_string()?),
811                            "lang" => example.lang = v.ensure_string()?,
812                            k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
813                        }
814                    }
815                    schema.examples.push(example);
816                }
817                "heading" => {
818                    let title = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
819                    let mut help = None;
820                    for (k, v) in node.props() {
821                        match k {
822                            "help" => help = Some(v.ensure_string()?),
823                            k => bail_parse!(ctx, v.entry.span(), "unsupported heading key {k}"),
824                        }
825                    }
826                    let Some(help) = help else {
827                        bail_parse!(ctx, node.node.span(), "heading {title} needs help text");
828                    };
829                    schema.cmd.headings.push(SpecHeading::new(title, help));
830                }
831                "output" => schema.outputs.push(SpecOutput::parse(ctx, &node)?),
832                "exit_code" => schema.exit_codes.push(SpecExitCode::parse(ctx, &node)?),
833                "select" => {
834                    schema.select = Some(node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
835                }
836                "include" => {
837                    let file = node
838                        .props()
839                        .get("file")
840                        .map(|v| v.ensure_string())
841                        .transpose()?
842                        .ok_or_else(|| ctx.build_err("missing file".into(), node.span()))?;
843                    let file = match &ctx.include_env {
844                        Some(env) => expand_include_env(&file, env)
845                            .map_err(|msg| ctx.build_err(msg, node.span()))?,
846                        None => file,
847                    };
848                    let file = Path::new(&file);
849                    let file = match file.is_relative() {
850                        true => ctx
851                            .file
852                            .parent()
853                            .ok_or_else(|| {
854                                let msg = if ctx.file.as_os_str().is_empty() {
855                                    "relative includes require a source file".to_string()
856                                } else {
857                                    format!("cannot get parent of {}", ctx.file.display())
858                                };
859                                ctx.build_err(msg, node.span())
860                            })?
861                            .join(file),
862                        false => file.to_path_buf(),
863                    };
864                    // Library bookkeeping, not something the user asked to see: an embedder
865                    // whose logger shows info by default printed this line on every run, once
866                    // per include, for a spec it parses on behalf of a command that never
867                    // mentioned includes. The rest of the parser logs at trace for this reason.
868                    debug!("include: {}", file.display());
869                    let other = Self::parse_file_with_metadata_inference_and_env(
870                        &file,
871                        false,
872                        false,
873                        ctx.include_env.as_ref(),
874                    )?;
875                    // Two *declarations* of one name are refused, the same as two in a single
876                    // file. Letting the incoming set win would make which declaration a
877                    // `use` gets depend on whether the `include` stands above or below it —
878                    // and only in that direction, since a `flagset` written after an
879                    // `include` already fails here.
880                    //
881                    // Which declaration, not which name: a file of shared sets is included
882                    // by every file whose `use` nodes name them, since each file resolves
883                    // its own. A spec that includes two of those files sees the shared set
884                    // arrive twice, and that is one declaration by two routes.
885                    let clash = other.flagsets.values().find(|incoming| {
886                        schema
887                            .flagsets
888                            .get(&incoming.name)
889                            .is_some_and(|own| own.declared_in != incoming.declared_in)
890                    });
891                    if let Some(incoming) = clash {
892                        let name = &incoming.name;
893                        let owner = schema.flagsets[name].declared_in.clone();
894                        let owner = match owner.as_os_str().is_empty() {
895                            true => "this spec".to_string(),
896                            false => owner.display().to_string(),
897                        };
898                        bail_parse!(
899                            ctx,
900                            node.span(),
901                            "a flagset may be declared only once: \"{name}\" is declared in \
902                             {} and in {owner}",
903                            incoming.declared_in.display()
904                        );
905                    }
906                    schema.merge(other);
907                }
908                k => bail_parse!(ctx, node.node.name().span(), "unsupported spec key {k}"),
909            }
910        }
911        schema.cmd.name = if schema.bin.is_empty() {
912            schema.name.clone()
913        } else {
914            schema.bin.clone()
915        };
916        if let Some(clause) = &schema.cmd.clause {
917            if !schema.cmd.args.is_empty() {
918                bail_parse!(
919                    ctx,
920                    kdl.span(),
921                    "a command cannot declare both top-level arguments and a clause"
922                );
923            }
924            if schema.cmd.restart_token.is_some() {
925                bail_parse!(
926                    ctx,
927                    kdl.span(),
928                    "a command cannot declare both restart_token and a clause"
929                );
930            }
931            if clause.args.iter().any(|arg| arg.sigil.is_some()) {
932                bail_parse!(
933                    ctx,
934                    kdl.span(),
935                    "sigil arguments are not supported inside clauses"
936                );
937            }
938            if let Some(spelling) = clause.conflicting_flag_spelling(&schema.cmd.flags) {
939                bail_parse!(
940                    ctx,
941                    kdl.span(),
942                    "clause flag spelling {spelling:?} conflicts with another flag on this command"
943                );
944            }
945        }
946        // Before ancestors are stamped, because expanding a flagset or narrowing a selector can
947        // add a flag to a command and the usage strings are computed from the flag list.
948        flagset::expand(ctx, &mut schema.cmd, &mut schema.flagsets)?;
949        schema
950            .cmd
951            .validate_clause_flag_spellings()
952            .map_err(|message| ctx.build_err(message, (0, ctx.spec.len()).into()))?;
953        if resolve_outputs {
954            output::resolve_selectors(&mut schema)?;
955        }
956        schema
957            .cmd
958            .validate_sigil_prefixes()
959            .map_err(|message| ctx.build_err(message, (0, ctx.spec.len()).into()))?;
960        schema.sources.extend(ctx.sources());
961        set_subcommand_ancestors(&mut schema.cmd, &[]);
962        Ok(schema)
963    }
964
965    pub fn merge(&mut self, other: Spec) {
966        macro_rules! merge_str {
967            ($field:ident) => {
968                if !other.$field.is_empty() {
969                    self.$field = other.$field;
970                }
971            };
972        }
973        macro_rules! merge_opt {
974            ($field:ident) => {
975                if other.$field.is_some() {
976                    self.$field = other.$field;
977                }
978            };
979        }
980        macro_rules! merge_extend {
981            ($field:ident) => {
982                if !other.$field.is_empty() {
983                    self.$field.extend(other.$field);
984                }
985            };
986        }
987
988        merge_str!(name);
989        merge_str!(bin);
990        merge_str!(usage);
991        merge_opt!(about);
992        merge_opt!(source_code_link_template);
993        merge_opt!(repository);
994        merge_opt!(version);
995        merge_opt!(long_version);
996        merge_opt!(author);
997        merge_opt!(about_long);
998        merge_opt!(about_md);
999        merge_opt!(license);
1000        merge_opt!(logo);
1001        merge_opt!(logo_style);
1002        merge_opt!(before_help);
1003        merge_opt!(after_help);
1004        merge_opt!(before_help_long);
1005        merge_opt!(after_help_long);
1006        merge_opt!(help_template);
1007        merge_opt!(disable_help);
1008        merge_opt!(min_usage_version);
1009        merge_opt!(default_subcommand);
1010        if other.default_subcommand_flags_set {
1011            self.default_subcommand_flags = other.default_subcommand_flags;
1012            self.default_subcommand_flags_set = true;
1013        }
1014        if other.default_subcommand_on_empty_set {
1015            self.default_subcommand_on_empty = other.default_subcommand_on_empty;
1016            self.default_subcommand_on_empty_set = true;
1017        }
1018        if other.default_subcommand_help_set {
1019            self.default_subcommand_help = other.default_subcommand_help;
1020            self.default_subcommand_help_set = true;
1021        }
1022        if other.multicall_set {
1023            self.multicall = other.multicall;
1024            self.multicall_set = true;
1025        }
1026        merge_opt!(unknown_flags);
1027        merge_extend!(complete);
1028        merge_extend!(views);
1029        // An included file's sets are visible to the file that includes it, which is how a
1030        // spec keeps its shared declarations in a file of their own. Its own `use` nodes are
1031        // already resolved by the time it gets here, so nothing is expanded twice. Two files
1032        // declaring one name never reach this extend: the `include` refuses them, rather
1033        // than one silently taking the other's name. What does reach it is the same shared
1034        // file arriving by two routes, which overwrites an entry with itself.
1035        merge_extend!(flagsets);
1036        merge_extend!(examples);
1037        merge_extend!(outputs);
1038        merge_extend!(exit_codes);
1039        merge_opt!(select);
1040        // An included spec brings the files *it* read, which is how a nested include is watched.
1041        merge_extend!(sources);
1042
1043        if !other.config.is_empty() {
1044            self.config.merge(&other.config);
1045        }
1046        self.cmd.merge(other.cmd);
1047    }
1048}
1049
1050pub(crate) fn spec_flag_forms_overlap(a: &SpecFlag, b: &SpecFlag) -> bool {
1051    fn long_forms(flag: &SpecFlag) -> impl Iterator<Item = &str> {
1052        flag.long
1053            .iter()
1054            .chain(&flag.hidden_aliases)
1055            .map(String::as_str)
1056            .chain(
1057                flag.negate
1058                    .as_deref()
1059                    .map(|name| name.strip_prefix("--").unwrap_or(name)),
1060            )
1061    }
1062    fn short_forms(flag: &SpecFlag) -> impl Iterator<Item = &char> {
1063        flag.short.iter().chain(&flag.hidden_short_aliases)
1064    }
1065
1066    long_forms(a).any(|name| long_forms(b).any(|other| other == name))
1067        || short_forms(a).any(|name| short_forms(b).any(|other| other == name))
1068}
1069
1070fn flag_matches_selector(flag: &SpecFlag, selector: &str) -> bool {
1071    selector.strip_prefix("--").is_some_and(|name| {
1072        flag.long
1073            .iter()
1074            .chain(&flag.hidden_aliases)
1075            .any(|long| long == name)
1076            || flag
1077                .negate
1078                .as_deref()
1079                .is_some_and(|negate| negate.strip_prefix("--").unwrap_or(negate) == name)
1080    }) || selector
1081        .strip_prefix('-')
1082        .filter(|short| short.len() == 1)
1083        .and_then(|short| short.chars().next())
1084        .is_some_and(|short| {
1085            flag.short
1086                .iter()
1087                .chain(&flag.hidden_short_aliases)
1088                .any(|candidate| *candidate == short)
1089        })
1090}
1091
1092fn check_usage_version(version: &str) {
1093    let cur = semver::Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
1094    match parse_usage_version(version) {
1095        Ok(v) => {
1096            if cur < v {
1097                warn!(
1098                    "This usage spec requires at least version {version}, but you are using version {cur} of usage"
1099                );
1100            }
1101        }
1102        Err(_) => warn!("Invalid version: {version}"),
1103    }
1104}
1105
1106/// Parse the relaxed SemVer spelling accepted by `min_usage_version`.
1107///
1108/// Specs have historically used versions such as `4.0`, while the SemVer crate correctly
1109/// requires all three numeric components. Fill in omitted components before parsing without
1110/// accepting any other non-SemVer syntax.
1111fn parse_usage_version(version: &str) -> Result<semver::Version, semver::Error> {
1112    let core_end = version.find(['-', '+']).unwrap_or(version.len());
1113    let (core, suffix) = version.split_at(core_end);
1114    let missing = match core.matches('.').count() {
1115        0 => ".0.0",
1116        1 => ".0",
1117        _ => "",
1118    };
1119
1120    semver::Version::parse(&format!("{core}{missing}{suffix}"))
1121}
1122
1123/// Read a file, keeping its path in the error.
1124///
1125/// `std::fs::read_to_string` reports "No such file or directory" and nothing about which
1126/// file, and these paths come from a command line.
1127fn read_to_string(file: &Path) -> Result<String, UsageErr> {
1128    std::fs::read_to_string(file).map_err(|err| UsageErr::FileError(err, file.to_path_buf()))
1129}
1130
1131/// A comment line that opens or continues an embedded spec: `#USAGE`, `//USAGE`, `::USAGE`,
1132/// or their `[USAGE]` spellings.
1133static USAGE_COMMENT: LazyLock<Regex> =
1134    LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])(.*)$").unwrap());
1135/// The same, without capturing the rest of the line: used only to answer whether a script
1136/// carries an embedded spec at all.
1137static HAS_USAGE_COMMENT: LazyLock<Regex> =
1138    LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])").unwrap());
1139/// A comment line with nothing on it, which continues a spec rather than ending it.
1140static BLANK_COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(?:#|//|::)\s*$").unwrap());
1141
1142fn split_script(file: &Path) -> Result<String, UsageErr> {
1143    let full = read_to_string(file)?;
1144    // If file has a shebang and USAGE comments, extract the spec from comments
1145    if full.starts_with("#!") && full.lines().any(|l| HAS_USAGE_COMMENT.is_match(l)) {
1146        return Ok(extract_usage_from_comments(&full));
1147    }
1148    // Otherwise treat the whole file as a KDL spec (e.g., .usage.kdl files)
1149    Ok(full)
1150}
1151
1152fn extract_usage_from_comments(full: &str) -> String {
1153    let mut usage = vec![];
1154    let mut found = false;
1155    for line in full.lines() {
1156        if let Some(captures) = USAGE_COMMENT.captures(line) {
1157            found = true;
1158            let content = captures.get(1).map_or("", |m| m.as_str());
1159            usage.push(content.trim());
1160        } else if found {
1161            // Allow blank comment lines to continue parsing
1162            if BLANK_COMMENT.is_match(line) {
1163                continue;
1164            }
1165            // if there is a non-blank non-USAGE line, stop reading
1166            break;
1167        }
1168    }
1169    usage.join("\n")
1170}
1171
1172fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
1173    restamp_paths(cmd, ancestors, false);
1174}
1175
1176/// Recompute what a command's position in the tree decides: its path, its usage line, and the
1177/// lookup that answers to its subcommands' names and aliases.
1178///
1179/// A command does not know where it sits — `full_cmd` is stamped onto it by whoever assembled
1180/// the tree, `usage` is rendered from that path, and `find_subcommand` memoizes what it found.
1181/// Move a command, or graft one in, and all three describe where it used to be. This is the pass
1182/// that fixes them, and the only one: a tree built by hand is otherwise as stale as one built by
1183/// hand always was, which is why building one used to mean writing it out as KDL and parsing it
1184/// back.
1185///
1186/// `force` says whether a usage line that is already there is trusted. Parsing writes them as it
1187/// goes and only wants the blanks filled; a graft brings a subtree whose lines are all correct
1188/// for the spec it came from and all wrong here.
1189fn restamp_paths(cmd: &mut SpecCommand, ancestors: &[String], force: bool) {
1190    for subcmd in cmd.subcommands.values_mut() {
1191        subcmd.full_cmd = ancestors
1192            .iter()
1193            .cloned()
1194            .chain(once(subcmd.name.clone()))
1195            .collect();
1196        let child_ancestors = subcmd.full_cmd.clone();
1197        restamp_paths(subcmd, &child_ancestors, force);
1198    }
1199    if force {
1200        // The lookup memoizes names *and* aliases, so a grafted sibling is missing from it and a
1201        // removed one is still in it. Dropping it costs the next lookup and nothing else.
1202        cmd.reset_subcommand_lookup();
1203        cmd.usage = cmd.usage();
1204    } else if cmd.usage.is_empty() {
1205        cmd.usage = cmd.usage();
1206    }
1207}
1208
1209impl Display for Spec {
1210    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1211        let mut doc = KdlDocument::new();
1212        let nodes = &mut doc.nodes_mut();
1213        if !self.name.is_empty() {
1214            let mut node = KdlNode::new("name");
1215            node.push(string_entry(None, &self.name));
1216            nodes.push(node);
1217        }
1218        if !self.bin.is_empty() {
1219            let mut node = KdlNode::new("bin");
1220            node.push(string_entry(None, &self.bin));
1221            nodes.push(node);
1222        }
1223        if let Some(version) = &self.version {
1224            let mut node = KdlNode::new("version");
1225            node.push(string_entry(None, version));
1226            nodes.push(node);
1227        }
1228        if let Some(version) = &self.long_version {
1229            let mut node = KdlNode::new("long_version");
1230            node.push(string_entry(None, version));
1231            nodes.push(node);
1232        }
1233        if let Some(author) = &self.author {
1234            let mut node = KdlNode::new("author");
1235            node.push(string_entry(None, author));
1236            nodes.push(node);
1237        }
1238        if let Some(about) = &self.about {
1239            let mut node = KdlNode::new("about");
1240            node.push(string_entry(None, about));
1241            nodes.push(node);
1242        }
1243        if let Some(source_code_link_template) = &self.source_code_link_template {
1244            let mut node = KdlNode::new("source_code_link_template");
1245            node.push(string_entry(None, source_code_link_template));
1246            nodes.push(node);
1247        }
1248        if let Some(repository) = &self.repository {
1249            let mut node = KdlNode::new("repository");
1250            node.push(string_entry(None, repository));
1251            nodes.push(node);
1252        }
1253        if let Some(about_md) = &self.about_md {
1254            let mut node = KdlNode::new("about_md");
1255            node.push(string_entry(None, about_md));
1256            nodes.push(node);
1257        }
1258        if let Some(long_about) = &self.about_long {
1259            let mut node = KdlNode::new("long_about");
1260            node.push(string_entry(None, long_about));
1261            nodes.push(node);
1262        }
1263        if let Some(license) = &self.license {
1264            let mut node = KdlNode::new("license");
1265            node.push(string_entry(None, license));
1266            nodes.push(node);
1267        }
1268        if let Some(logo) = &self.logo {
1269            let mut node = KdlNode::new("logo");
1270            node.push(string_entry(None, logo));
1271            if let Some(style) = &self.logo_style {
1272                node.push(string_entry(Some("style"), style));
1273            }
1274            nodes.push(node);
1275        }
1276        if let Some(before_help) = &self.before_help {
1277            let mut node = KdlNode::new("before_help");
1278            node.push(string_entry(None, before_help));
1279            nodes.push(node);
1280        }
1281        if let Some(after_help) = &self.after_help {
1282            let mut node = KdlNode::new("after_help");
1283            node.push(string_entry(None, after_help));
1284            nodes.push(node);
1285        }
1286        if let Some(before_help_long) = &self.before_help_long {
1287            let mut node = KdlNode::new("before_long_help");
1288            node.push(string_entry(None, before_help_long));
1289            nodes.push(node);
1290        }
1291        if let Some(after_help_long) = &self.after_help_long {
1292            let mut node = KdlNode::new("after_long_help");
1293            node.push(string_entry(None, after_help_long));
1294            nodes.push(node);
1295        }
1296        if let Some(help_template) = &self.help_template {
1297            let mut node = KdlNode::new("help_template");
1298            node.push(string_entry(None, help_template));
1299            nodes.push(node);
1300        }
1301        if let Some(disable_help) = self.disable_help {
1302            let mut node = KdlNode::new("disable_help");
1303            node.push(KdlEntry::new(disable_help));
1304            nodes.push(node);
1305        }
1306        if let Some(min_usage_version) = &self.min_usage_version {
1307            let mut node = KdlNode::new("min_usage_version");
1308            node.push(string_entry(None, min_usage_version));
1309            nodes.push(node);
1310        }
1311        if let Some(unknown_flags) = &self.unknown_flags {
1312            let mut node = KdlNode::new("unknown_flags");
1313            node.push(string_entry(None, unknown_flags.as_str()));
1314            nodes.push(node);
1315        }
1316        if let Some(default_subcommand) = &self.default_subcommand {
1317            let mut node = KdlNode::new("default_subcommand");
1318            node.push(string_entry(None, default_subcommand));
1319            nodes.push(node);
1320        }
1321        if self.default_subcommand_flags_set || self.default_subcommand_flags {
1322            let mut node = KdlNode::new("default_subcommand_flags");
1323            node.push(KdlEntry::new(self.default_subcommand_flags));
1324            nodes.push(node);
1325        }
1326        if self.default_subcommand_on_empty_set || self.default_subcommand_on_empty {
1327            let mut node = KdlNode::new("default_subcommand_on_empty");
1328            node.push(KdlEntry::new(self.default_subcommand_on_empty));
1329            nodes.push(node);
1330        }
1331        if self.default_subcommand_help_set || self.default_subcommand_help {
1332            let mut node = KdlNode::new("default_subcommand_help");
1333            node.push(KdlEntry::new(self.default_subcommand_help));
1334            nodes.push(node);
1335        }
1336        if self.multicall_set {
1337            let mut node = KdlNode::new("multicall");
1338            node.push(KdlEntry::new(self.multicall));
1339            nodes.push(node);
1340        }
1341        if self.cmd.external_subcommand {
1342            let mut node = KdlNode::new("external_subcommand");
1343            node.push(KdlEntry::new(true));
1344            nodes.push(node);
1345        }
1346        if self.cmd.arg_required_else_help {
1347            let mut node = KdlNode::new("arg_required_else_help");
1348            node.push(KdlEntry::new(true));
1349            nodes.push(node);
1350        }
1351        if self.cmd.disable_help_flag {
1352            let mut node = KdlNode::new("disable_help_flag");
1353            node.push(KdlEntry::new(true));
1354            nodes.push(node);
1355        }
1356        if self.cmd.disable_help_subcommand {
1357            let mut node = KdlNode::new("disable_help_subcommand");
1358            node.push(KdlEntry::new(true));
1359            nodes.push(node);
1360        }
1361        if self.cmd.disable_version_flag {
1362            let mut node = KdlNode::new("disable_version_flag");
1363            node.push(KdlEntry::new(true));
1364            nodes.push(node);
1365        }
1366        if self.cmd.dont_delimit_trailing_values {
1367            let mut node = KdlNode::new("dont_delimit_trailing_values");
1368            node.push(true);
1369            nodes.push(node);
1370        }
1371        if !self.cmd.args_override_self {
1372            let mut node = KdlNode::new("args_override_self");
1373            node.push(false);
1374            nodes.push(node);
1375        }
1376        if self.cmd.subcommand_negates_reqs {
1377            let mut node = KdlNode::new("subcommand_negates_reqs");
1378            node.push(true);
1379            nodes.push(node);
1380        }
1381        if self.cmd.args_conflicts_with_subcommands {
1382            let mut node = KdlNode::new("args_conflicts_with_subcommands");
1383            node.push(true);
1384            nodes.push(node);
1385        }
1386        if self.cmd.subcommand_precedence_over_arg {
1387            let mut node = KdlNode::new("subcommand_precedence_over_arg");
1388            node.push(true);
1389            nodes.push(node);
1390        }
1391        if self.cmd.allow_missing_positional {
1392            let mut node = KdlNode::new("allow_missing_positional");
1393            node.push(true);
1394            nodes.push(node);
1395        }
1396        if let Some(message) = &self.cmd.deprecated {
1397            let mut node = KdlNode::new("deprecated");
1398            node.push(string_entry(None, message));
1399            nodes.push(node);
1400        }
1401        if let Some(surface) = &self.cmd.surface {
1402            let mut node = KdlNode::new("surface");
1403            node.push(string_entry(None, surface));
1404            nodes.push(node);
1405        }
1406        if !self.cmd.available_if.is_empty() {
1407            let mut node = KdlNode::new("available_if");
1408            for condition in &self.cmd.available_if {
1409                node.push(string_entry(None, condition));
1410            }
1411            nodes.push(node);
1412        }
1413        if let Some(at) = &self.cmd.deprecated_warn_at {
1414            let mut node = KdlNode::new("deprecated_warn_at");
1415            node.push(string_entry(None, at));
1416            nodes.push(node);
1417        }
1418        if let Some(at) = &self.cmd.deprecated_remove_at {
1419            let mut node = KdlNode::new("deprecated_remove_at");
1420            node.push(string_entry(None, at));
1421            nodes.push(node);
1422        }
1423        if self.cmd.subcommand_required && !self.cmd.subcommands.is_empty() {
1424            let mut node = KdlNode::new("subcommand_required");
1425            node.push(true);
1426            nodes.push(node);
1427        }
1428        if let Some(heading) = &self.cmd.subcommand_help_heading {
1429            let mut node = KdlNode::new("subcommand_help_heading");
1430            node.push(string_entry(None, heading));
1431            nodes.push(node);
1432        }
1433        if let Some(name) = &self.cmd.subcommand_value_name {
1434            let mut node = KdlNode::new("subcommand_value_name");
1435            node.push(string_entry(None, name));
1436            nodes.push(node);
1437        }
1438        if self.cmd.next_line_help {
1439            let mut node = KdlNode::new("next_line_help");
1440            node.push(true);
1441            nodes.push(node);
1442        }
1443        if self.cmd.flatten_help {
1444            let mut node = KdlNode::new("flatten_help");
1445            node.push(true);
1446            nodes.push(node);
1447        }
1448        if let Some(width) = self.cmd.term_width {
1449            let mut node = KdlNode::new("term_width");
1450            node.push(width as i128);
1451            nodes.push(node);
1452        }
1453        if let Some(width) = self.cmd.max_term_width {
1454            let mut node = KdlNode::new("max_term_width");
1455            node.push(width as i128);
1456            nodes.push(node);
1457        }
1458        if !self.usage.is_empty() {
1459            let mut node = KdlNode::new("usage");
1460            node.push(string_entry(None, &self.usage));
1461            nodes.push(node);
1462        }
1463        for flag in self.cmd.flags.iter() {
1464            nodes.push(flag.into());
1465        }
1466        for arg in self.cmd.args.iter() {
1467            nodes.push(arg.into());
1468        }
1469        if let Some(clause) = &self.cmd.clause {
1470            nodes.push(clause.into());
1471        }
1472        // Written here rather than by SpecCommand, because the root's own nodes
1473        // live at the top level of the document instead of inside a `cmd` block.
1474        for mount in self.cmd.mounts.iter() {
1475            nodes.push(mount.into());
1476        }
1477        for group in self.cmd.groups.iter() {
1478            nodes.push(group.into());
1479        }
1480        for example in self.examples.iter() {
1481            nodes.push(example.into());
1482        }
1483        for heading in self.cmd.headings.iter() {
1484            nodes.push(heading.into());
1485        }
1486        for output in self.outputs.iter() {
1487            nodes.push(output.into());
1488        }
1489        if let Some(select) = &self.select {
1490            let mut node = KdlNode::new("select");
1491            node.push(string_entry(None, select));
1492            nodes.push(node);
1493        }
1494        for exit_code in self.exit_codes.iter() {
1495            nodes.push(exit_code.into());
1496        }
1497        for complete in self.complete.values() {
1498            nodes.push(complete.into());
1499        }
1500        for complete in self.cmd.complete.values() {
1501            nodes.push(complete.into());
1502        }
1503        for view in self.views.values() {
1504            let rendered: KdlDocument = view
1505                .to_string()
1506                .parse()
1507                .expect("a view always renders valid KDL");
1508            nodes.extend(rendered.nodes().iter().cloned());
1509        }
1510        for cmd in self.cmd.subcommands.values() {
1511            nodes.push(cmd.into())
1512        }
1513        if !self.config.is_empty() {
1514            nodes.push((&self.config).into());
1515        }
1516        doc.autoformat_config(&kdl::FormatConfig::default());
1517        write!(f, "{doc}")
1518    }
1519}
1520
1521fn expand_include_env(input: &str, env: &HashMap<String, String>) -> Result<String, String> {
1522    let mut output = String::with_capacity(input.len());
1523    let mut chars = input.chars().peekable();
1524    while let Some(ch) = chars.next() {
1525        if ch != '$' {
1526            output.push(ch);
1527            continue;
1528        }
1529        if chars.next_if_eq(&'$').is_some() {
1530            output.push('$');
1531            continue;
1532        }
1533
1534        let (name, braced) = if chars.next_if_eq(&'{').is_some() {
1535            let mut name = String::new();
1536            let mut closed = false;
1537            for ch in chars.by_ref() {
1538                if ch == '}' {
1539                    closed = true;
1540                    break;
1541                }
1542                name.push(ch);
1543            }
1544            if !closed {
1545                return Err("include path contains an unterminated environment reference".into());
1546            }
1547            (name, true)
1548        } else {
1549            let mut name = String::new();
1550            while chars
1551                .peek()
1552                .is_some_and(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
1553            {
1554                name.push(chars.next().expect("peeked character exists"));
1555            }
1556            (name, false)
1557        };
1558        if name.is_empty() {
1559            output.push_str(if braced { "${}" } else { "$" });
1560            continue;
1561        }
1562        let value = env.get(&name).ok_or_else(|| {
1563            format!("include path references undefined environment variable {name}")
1564        })?;
1565        output.push_str(value);
1566    }
1567    Ok(output)
1568}
1569
1570impl FromStr for Spec {
1571    type Err = UsageErr;
1572
1573    fn from_str(s: &str) -> Result<Self, Self::Err> {
1574        // The input is handed to the context, not just to the parser: a diagnostic carries a
1575        // span into the spec text and the text to render it against, and a default context
1576        // has no text. Without this a spec parsed from a string reported *that* something was
1577        // wrong and pointed at a blank line, which is what every embedder parsing a spec it
1578        // built itself — a task runner, a template renderer — shows its users.
1579        Self::parse(&ParsingContext::new(Path::new(""), s), s)
1580    }
1581}
1582
1583#[cfg(feature = "clap")]
1584impl From<&clap::Command> for Spec {
1585    fn from(cmd: &clap::Command) -> Self {
1586        let mut spec = Spec {
1587            name: cmd.get_name().to_string(),
1588            bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
1589            cmd: cmd.into(),
1590            version: cmd.get_version().map(|v| v.to_string()),
1591            long_version: cmd.get_long_version().map(|v| v.to_string()),
1592            about: cmd.get_about().map(|a| a.to_string()),
1593            about_long: cmd.get_long_about().map(|a| a.to_string()),
1594            usage: cmd.clone().render_usage().to_string(),
1595            // The root is a command too, and its own answer has nowhere else to go: a spec says
1596            // this at the top level, which is the field a reader puts it back into.
1597            unknown_flags: crate::spec::cmd::SpecCommand::from(cmd).unknown_flags,
1598            multicall: cmd.is_multicall_set(),
1599            multicall_set: cmd.is_multicall_set(),
1600            ..Default::default()
1601        };
1602        // The same pass the KDL parser makes, and for the same reason: a command has to know
1603        // where it sits. Without it every subcommand of a clap-derived spec had `full_cmd`
1604        // empty — and `SpecCommand::usage()` joins `full_cmd`, so their usage lines came out
1605        // blank. Now they say what a user would type.
1606        set_subcommand_ancestors(&mut spec.cmd, &[]);
1607        spec
1608    }
1609}
1610
1611/// A spec wrapping one command, for command trees built in Rust rather than parsed from KDL.
1612///
1613/// The command's own `name` becomes the spec's `name` and `bin`, and its `help` becomes the
1614/// spec's `about` — the same correspondence `usage-dynamic` applies in the other direction when
1615/// it grafts a spec into a host as a command.
1616impl From<SpecCommand> for Spec {
1617    fn from(cmd: SpecCommand) -> Self {
1618        let mut spec = Self {
1619            name: cmd.name.clone(),
1620            bin: cmd.name.clone(),
1621            about: cmd.help.clone(),
1622            about_long: cmd.help_long.clone(),
1623            cmd,
1624            ..Self::default()
1625        };
1626        // A built tree has never been stamped: nested commands do not know their paths, so
1627        // their usage lines are missing the words a user would type.
1628        spec.restamp();
1629        spec
1630    }
1631}
1632
1633#[inline]
1634pub fn is_true(b: &bool) -> bool {
1635    *b
1636}
1637
1638#[inline]
1639pub fn is_false(b: &bool) -> bool {
1640    !is_true(b)
1641}
1642
1643#[cfg(test)]
1644mod tests {
1645    use super::*;
1646    use insta::assert_snapshot;
1647
1648    #[test]
1649    fn parses_relaxed_usage_versions_as_semver() {
1650        assert_eq!(
1651            parse_usage_version("6").unwrap(),
1652            semver::Version::new(6, 0, 0)
1653        );
1654        assert_eq!(
1655            parse_usage_version("6.1").unwrap(),
1656            semver::Version::new(6, 1, 0)
1657        );
1658        assert_eq!(
1659            parse_usage_version("6.1.2-beta.1+build").unwrap(),
1660            semver::Version::parse("6.1.2-beta.1+build").unwrap()
1661        );
1662        assert!(parse_usage_version("not-a-version").is_err());
1663        assert!(parse_usage_version("6.1.2.3").is_err());
1664    }
1665
1666    #[test]
1667    fn test_display() {
1668        let spec = Spec::parse(
1669            &Default::default(),
1670            r#"
1671name "Usage CLI"
1672bin "usage"
1673arg "arg1"
1674flag "-f --force" global=#true
1675cmd "config" {
1676  cmd "set" {
1677    arg "key" help="Key to set"
1678    arg "value"
1679  }
1680}
1681complete "file" run="ls" descriptions=#true
1682        "#,
1683        )
1684        .unwrap();
1685        assert_snapshot!(spec, @r#"
1686        name "Usage CLI"
1687        bin usage
1688        flag "-f --force" global=#true
1689        arg <arg1>
1690        complete file run=ls descriptions=#true
1691        cmd config {
1692            cmd set {
1693                arg <key> help="Key to set"
1694                arg <value>
1695            }
1696        }
1697        "#);
1698    }
1699
1700    #[test]
1701    fn test_repository_round_trips() {
1702        let spec = Spec::parse(
1703            &Default::default(),
1704            r#"
1705bin "mise"
1706repository "https://github.com/jdx/mise"
1707source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1708        "#,
1709        )
1710        .unwrap();
1711        assert_eq!(
1712            spec.repository.as_deref(),
1713            Some("https://github.com/jdx/mise")
1714        );
1715        // A spec that is parsed and re-emitted must not lose it, which is the
1716        // failure mode for every field added to this struct.
1717        assert_snapshot!(spec, @r#"
1718        name mise
1719        bin mise
1720        source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1721        repository "https://github.com/jdx/mise"
1722        "#);
1723    }
1724
1725    #[test]
1726    fn test_repository_merges_like_the_other_optionals() {
1727        // Extra specs are merged over a generated one, which is how a clap CLI
1728        // declares anything clap has no concept of.
1729        let mut generated = Spec::parse(&Default::default(), r#"bin "mise""#).unwrap();
1730        let extra = Spec::parse(
1731            &Default::default(),
1732            r#"repository "https://github.com/jdx/mise""#,
1733        )
1734        .unwrap();
1735        generated.merge(extra);
1736        assert_eq!(
1737            generated.repository.as_deref(),
1738            Some("https://github.com/jdx/mise")
1739        );
1740    }
1741
1742    #[test]
1743    #[cfg(feature = "clap")]
1744    fn test_clap() {
1745        let cmd = clap::Command::new("test");
1746        assert_snapshot!(Spec::from(&cmd), @r#"
1747        name test
1748        bin test
1749        unknown_flags error
1750        args_override_self #false
1751        usage "Usage: test"
1752        "#);
1753    }
1754
1755    #[test]
1756    #[cfg(feature = "clap")]
1757    fn a_clap_subcommand_knows_where_it_sits() {
1758        // The KDL parser makes this pass; the clap conversion did not, so every subcommand of
1759        // a clap-derived spec had an empty `full_cmd`. Two things read it: `usage()`, which
1760        // joins it and so produced a usage line with no command in it, and help rendering,
1761        // which uses it to tell a subcommand's page from the program's.
1762        let cmd = clap::Command::new("ex").subcommand(
1763            clap::Command::new("go")
1764                .about("Go somewhere")
1765                .subcommand(clap::Command::new("fast").about("Quickly")),
1766        );
1767        let spec = Spec::from(&cmd);
1768
1769        let go = spec.cmd.subcommands.get("go").expect("go");
1770        assert_eq!(go.full_cmd, ["go"]);
1771        // `usage()` names the command and then what it takes — `go` has a subcommand, so it
1772        // says so. The point is that the command's own name is in there at all.
1773        assert_eq!(go.usage, "go [SUBCOMMAND]");
1774
1775        // And all the way down, which is what makes it a walk rather than one level.
1776        let fast = go.subcommands.get("fast").expect("fast");
1777        assert_eq!(fast.full_cmd, ["go", "fast"]);
1778        assert_eq!(fast.usage, "go fast");
1779    }
1780
1781    #[test]
1782    #[cfg(feature = "clap")]
1783    fn a_delimited_default_becomes_the_values_clap_would_split_it_into() {
1784        // clap splits by the delimiter before anyone sees a value, defaults included, so the
1785        // joined string is not something the CLI ever holds. The spec has no delimiter — it has
1786        // a list, which says the same thing.
1787        //
1788        // mise's `--fs-events` is why: `default_value = "create,remove,rename,modify,metadata"`
1789        // beside `value_parser` listing those as its choices, so the recorded default was a
1790        // single value its own spec forbade.
1791        let cmd = clap::Command::new("test").arg(
1792            clap::Arg::new("events")
1793                .long("events")
1794                .value_delimiter(',')
1795                .action(clap::ArgAction::Append)
1796                .value_parser(["a", "b", "c"])
1797                .default_value("a,b"),
1798        );
1799        let spec = Spec::from(&cmd);
1800        let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1801        assert_eq!(flag.default, ["a", "b"]);
1802
1803        // And without a delimiter the value is whatever was written, commas and all: a path list
1804        // is not every CLI's idea of a separator, so splitting on speculation would be worse.
1805        let cmd = clap::Command::new("test")
1806            .arg(clap::Arg::new("events").long("events").default_value("a,b"));
1807        let spec = Spec::from(&cmd);
1808        let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1809        assert_eq!(flag.default, ["a,b"]);
1810    }
1811
1812    #[test]
1813    fn multicall_round_trips() {
1814        let spec = Spec::parse(
1815            &Default::default(),
1816            r#"
1817name "busybox"
1818bin "busybox"
1819multicall #true
1820cmd "ls"
1821cmd "cat"
1822        "#,
1823        )
1824        .unwrap();
1825        assert!(spec.multicall);
1826        let emitted = spec.to_string();
1827        assert!(
1828            emitted.contains("multicall #true"),
1829            "lost on the way out: {emitted}"
1830        );
1831        let again: Spec = emitted.parse().unwrap();
1832        assert!(again.multicall);
1833    }
1834
1835    #[test]
1836    fn a_declared_view_promotes_a_command_and_selected_globals() {
1837        let spec: Spec = r#"
1838name "aube"
1839bin "aube"
1840about_md "Host **markdown**"
1841before_help "host before"
1842before_long_help "host long before"
1843after_help "host after"
1844after_long_help "host long after"
1845example "aube host" header="host example"
1846flag "-v --verbose" global=#true
1847flag "--config <FILE>" global=#true
1848view "aubr" root="run" {
1849  global "--verbose"
1850}
1851cmd "run" help="Run a package script" {
1852  before_help "run before"
1853  before_long_help "run long before"
1854  after_help "run after"
1855  after_long_help "run long after"
1856  example "aubr task" header="run example"
1857  flag "--if-present"
1858  arg "[SCRIPT]"
1859  cmd "nested"
1860}
1861"#
1862        .parse()
1863        .unwrap();
1864
1865        let rendered = spec.to_string();
1866        assert!(rendered.contains("view aubr root=run"), "{rendered}");
1867        let reparsed: Spec = rendered.parse().unwrap();
1868        let applet = reparsed.for_view("aubr").unwrap();
1869        assert_eq!(applet.name, "aubr");
1870        assert_eq!(applet.bin, "aubr");
1871        assert_eq!(applet.about.as_deref(), Some("Run a package script"));
1872        assert_eq!(applet.about_md, None);
1873        assert_eq!(applet.before_help.as_deref(), Some("run before"));
1874        assert_eq!(applet.before_help_long.as_deref(), Some("run long before"));
1875        assert_eq!(applet.after_help.as_deref(), Some("run after"));
1876        assert_eq!(applet.after_help_long.as_deref(), Some("run long after"));
1877        assert_eq!(applet.examples.len(), 1);
1878        assert_eq!(applet.examples[0].header.as_deref(), Some("run example"));
1879        assert!(applet.cmd.flags.iter().any(|flag| flag.name == "verbose"));
1880        assert!(applet
1881            .cmd
1882            .flags
1883            .iter()
1884            .any(|flag| flag.name == "if-present"));
1885        assert!(!applet.cmd.flags.iter().any(|flag| flag.name == "config"));
1886        assert!(applet.cmd.subcommands.contains_key("nested"));
1887        assert!(applet.views.is_empty());
1888        assert!(applet.to_string().contains("bin aubr"));
1889    }
1890
1891    #[test]
1892    fn a_view_preserves_the_host_version_entry_policy() {
1893        let spec: Spec = r#"
1894bin "host"
1895version "1.2.3"
1896disable_version_flag #true
1897view "runner" root=run
1898cmd "run"
1899"#
1900        .parse()
1901        .unwrap();
1902
1903        let view = spec.for_view("runner").unwrap();
1904        assert!(view.cmd.disable_version_flag);
1905        let emitted = view.to_string();
1906        assert!(emitted.contains("disable_version_flag #true"), "{emitted}");
1907        let reparsed: Spec = emitted.parse().unwrap();
1908        assert!(reparsed.cmd.disable_version_flag);
1909    }
1910
1911    #[test]
1912    fn a_promoted_flag_shadows_every_carried_global_spelling() {
1913        let spec: Spec = r#"
1914bin "host"
1915flag "--color" global=#true negate="--no-color"
1916view "runner" root=run {
1917  global "--color"
1918}
1919cmd "run" {
1920  flag "--no-color"
1921}
1922"#
1923        .parse()
1924        .unwrap();
1925
1926        let view = spec.for_view("runner").unwrap();
1927        assert_eq!(view.cmd.flags.len(), 1);
1928        assert_eq!(view.cmd.flags[0].long, ["no-color"]);
1929    }
1930
1931    #[test]
1932    fn a_view_keeps_only_its_own_and_carried_global_completers() {
1933        let spec: Spec = r#"
1934bin "host"
1935flag "--host <HOST>" global=#true
1936flag "--carried <CARRIED>" global=#true
1937complete "host" run="host candidates"
1938complete "carried" run="carried candidates"
1939view "runner" root=run {
1940  global "--carried"
1941}
1942cmd "run" {
1943  arg "<HOST>"
1944  complete "host" run="view candidates"
1945}
1946"#
1947        .parse()
1948        .unwrap();
1949
1950        let view = spec.for_view("runner").unwrap();
1951        assert_eq!(
1952            view.complete.get("host").unwrap().run.as_deref(),
1953            Some("view candidates")
1954        );
1955        assert_eq!(
1956            view.complete.get("carried").unwrap().run.as_deref(),
1957            Some("carried candidates")
1958        );
1959        assert!(view.cmd.complete.is_empty());
1960        assert_eq!(view.complete.len(), 2);
1961        assert_eq!(view.to_string().matches("complete ").count(), 2);
1962    }
1963
1964    #[test]
1965    fn a_view_projects_groups_of_carried_globals() {
1966        let spec: Spec = r#"
1967bin "host"
1968flag "--json" global=#true
1969flag "--yaml" global=#true
1970flag "--toml" global=#true
1971group "format" "--json" "--yaml" "--toml" required=#true
1972view "all" root=run globals=#true
1973view "json" root=run {
1974  global "--json"
1975}
1976cmd "run"
1977"#
1978        .parse()
1979        .unwrap();
1980
1981        let all = spec.for_view("all").unwrap();
1982        assert_eq!(all.cmd.groups.len(), 1);
1983        assert_eq!(all.cmd.groups[0].members, ["--json", "--yaml", "--toml"]);
1984        assert!(all.to_string().parse::<Spec>().is_ok());
1985
1986        let json = spec.for_view("json").unwrap();
1987        assert!(json.cmd.groups.is_empty());
1988        assert!(
1989            json.cmd
1990                .flags
1991                .iter()
1992                .find(|flag| flag.name == "json")
1993                .unwrap()
1994                .required
1995        );
1996        assert!(json.to_string().parse::<Spec>().is_ok());
1997    }
1998
1999    #[test]
2000    fn a_view_refuses_unknown_commands_and_non_global_carryovers() {
2001        let missing: Spec = "bin \"ex\"\nview \"x\" root=missing\n".parse().unwrap();
2002        assert!(missing
2003            .for_view("x")
2004            .unwrap_err()
2005            .to_string()
2006            .contains("missing"));
2007
2008        let local: Spec =
2009            "bin \"ex\"\nflag \"--local\"\nview \"x\" root=go { global \"--local\" }\ncmd go\n"
2010                .parse()
2011                .unwrap();
2012        assert!(local
2013            .for_view("x")
2014            .unwrap_err()
2015            .to_string()
2016            .contains("not a root global"));
2017    }
2018
2019    #[test]
2020    fn a_string_parsed_spec_reports_the_offending_line() {
2021        let err = "flag \"--verbose\"\nflag \"--json\" bogus_key=\"nope\"\n"
2022            .parse::<Spec>()
2023            .unwrap_err();
2024        let rendered = format!("{:?}", crate::miette::Error::from(err));
2025        assert!(
2026            rendered.contains("unsupported flag key bogus_key"),
2027            "{rendered}"
2028        );
2029        // The source text reaches the diagnostic, so the snippet shows the line the span
2030        // points at rather than a blank one.
2031        assert!(rendered.contains("flag \"--json\""), "{rendered}");
2032    }
2033
2034    #[test]
2035    fn parse_str_with_path_resolves_relative_includes() {
2036        let dir = tempfile::tempdir().unwrap();
2037        let included = dir.path().join("shared.usage.kdl");
2038        std::fs::write(&included, "flagset \"common\" {\n  flag \"--shared\"\n}\n").unwrap();
2039
2040        let spec_text = "include file=\"./shared.usage.kdl\"\nuse \"common\"\nflag \"--own\"\n";
2041
2042        // Without a source path there is nothing for the include to be relative to.
2043        assert!(spec_text.parse::<Spec>().is_err());
2044
2045        let spec =
2046            Spec::parse_str_with_path(spec_text, &dir.path().join("root.usage.kdl")).unwrap();
2047        let flags: Vec<_> = spec.cmd.flags.iter().map(|f| f.name.clone()).collect();
2048        assert_eq!(flags, vec!["shared".to_string(), "own".to_string()]);
2049    }
2050
2051    #[test]
2052    fn parse_str_with_path_and_env_expands_nested_include_paths() {
2053        let dir = tempfile::tempdir().unwrap();
2054        let shared = dir.path().join("shared");
2055        std::fs::create_dir(&shared).unwrap();
2056        std::fs::write(
2057            shared.join("common.usage.kdl"),
2058            "include file=\"${SHARED_ROOT}/flags.usage.kdl\"\nuse \"common\"\n",
2059        )
2060        .unwrap();
2061        std::fs::write(
2062            shared.join("flags.usage.kdl"),
2063            "flagset \"common\" {\n  flag \"--shared\"\n}\n",
2064        )
2065        .unwrap();
2066        let input = "include file=\"$CONFIG_ROOT/shared/common.usage.kdl\"\n";
2067        let env = HashMap::from([
2068            (
2069                "CONFIG_ROOT".to_string(),
2070                dir.path().to_string_lossy().into_owned(),
2071            ),
2072            (
2073                "SHARED_ROOT".to_string(),
2074                shared.to_string_lossy().into_owned(),
2075            ),
2076        ]);
2077
2078        let spec =
2079            Spec::parse_str_with_path_and_env(input, &dir.path().join("mise-tasks/build"), &env)
2080                .unwrap();
2081
2082        assert_eq!(spec.cmd.flags.len(), 1);
2083        assert_eq!(spec.cmd.flags[0].long, ["shared"]);
2084    }
2085
2086    #[test]
2087    fn parse_str_with_path_and_env_rejects_an_undefined_include_variable() {
2088        let err = Spec::parse_str_with_path_and_env(
2089            "include file=\"$MISSING/shared.usage.kdl\"\n",
2090            Path::new("mise-tasks/build"),
2091            &HashMap::new(),
2092        )
2093        .unwrap_err();
2094        let rendered = format!("{:?}", crate::miette::Error::from(err));
2095
2096        assert!(
2097            rendered.contains("undefined environment variable MISSING"),
2098            "{rendered}"
2099        );
2100    }
2101
2102    #[test]
2103    fn include_environment_expansion_supports_braces_and_literal_dollars() {
2104        let env = HashMap::from([("ROOT".to_string(), "/project".to_string())]);
2105
2106        assert_eq!(
2107            expand_include_env("$ROOT/${ROOT}/$$ROOT/assets/${}", &env).unwrap(),
2108            "/project//project/$ROOT/assets/${}"
2109        );
2110        assert!(expand_include_env("${ROOT", &env).is_err());
2111    }
2112
2113    #[test]
2114    fn an_included_spec_can_enable_or_disable_multicall() {
2115        let dir = tempfile::tempdir().unwrap();
2116        let included = dir.path().join("included.usage.kdl");
2117        let root = dir.path().join("root.usage.kdl");
2118
2119        std::fs::write(&included, "multicall #false\n").unwrap();
2120        std::fs::write(
2121            &root,
2122            "multicall #true\ninclude file=\"./included.usage.kdl\"\n",
2123        )
2124        .unwrap();
2125        let spec = Spec::parse_file(&root).unwrap();
2126        assert!(!spec.multicall);
2127        assert!(spec.to_string().contains("multicall #false"));
2128
2129        std::fs::write(&included, "multicall #true\n").unwrap();
2130        std::fs::write(
2131            &root,
2132            "multicall #false\ninclude file=\"./included.usage.kdl\"\n",
2133        )
2134        .unwrap();
2135        let spec = Spec::parse_file(&root).unwrap();
2136        assert!(spec.multicall);
2137        assert!(spec.to_string().contains("multicall #true"));
2138    }
2139
2140    #[test]
2141    #[cfg(feature = "clap")]
2142    fn multicall_comes_across_from_clap() {
2143        let cmd = clap::Command::new("busybox")
2144            .multicall(true)
2145            .subcommand(clap::Command::new("ls"))
2146            .subcommand(clap::Command::new("cat"));
2147        let spec = Spec::from(&cmd);
2148        assert!(spec.multicall);
2149        assert!(
2150            spec.to_string().contains("multicall #true"),
2151            "{}",
2152            spec.to_string()
2153        );
2154
2155        let plain = clap::Command::new("ex").subcommand(clap::Command::new("ls"));
2156        assert!(!Spec::from(&plain).multicall);
2157    }
2158
2159    macro_rules! extract_usage_tests {
2160        ($($name:ident: $input:expr, $expected:expr,)*) => {
2161        $(
2162            #[test]
2163            fn $name() {
2164                let result = extract_usage_from_comments($input);
2165                let expected = $expected.trim_start_matches('\n').trim_end();
2166                assert_eq!(result, expected);
2167            }
2168        )*
2169        }
2170    }
2171
2172    extract_usage_tests! {
2173        test_extract_usage_from_comments_original_hash:
2174            r#"
2175#!/bin/bash
2176#USAGE bin "test"
2177#USAGE flag "--foo" help="test"
2178echo "hello"
2179            "#,
2180            r#"
2181bin "test"
2182flag "--foo" help="test"
2183            "#,
2184
2185        test_extract_usage_from_comments_original_double_slash:
2186            r#"
2187#!/usr/bin/env node
2188//USAGE bin "test"
2189//USAGE flag "--foo" help="test"
2190console.log("hello");
2191            "#,
2192            r#"
2193bin "test"
2194flag "--foo" help="test"
2195            "#,
2196
2197        test_extract_usage_from_comments_bracket_with_space:
2198            r#"
2199#!/bin/bash
2200# [USAGE] bin "test"
2201# [USAGE] flag "--foo" help="test"
2202echo "hello"
2203            "#,
2204            r#"
2205bin "test"
2206flag "--foo" help="test"
2207            "#,
2208
2209        test_extract_usage_from_comments_bracket_no_space:
2210            r#"
2211#!/bin/bash
2212#[USAGE] bin "test"
2213#[USAGE] flag "--foo" help="test"
2214echo "hello"
2215            "#,
2216            r#"
2217bin "test"
2218flag "--foo" help="test"
2219            "#,
2220
2221        test_extract_usage_from_comments_double_slash_bracket_with_space:
2222            r#"
2223#!/usr/bin/env node
2224// [USAGE] bin "test"
2225// [USAGE] flag "--foo" help="test"
2226console.log("hello");
2227            "#,
2228            r#"
2229bin "test"
2230flag "--foo" help="test"
2231            "#,
2232
2233        test_extract_usage_from_comments_double_slash_bracket_no_space:
2234            r#"
2235#!/usr/bin/env node
2236//[USAGE] bin "test"
2237//[USAGE] flag "--foo" help="test"
2238console.log("hello");
2239            "#,
2240            r#"
2241bin "test"
2242flag "--foo" help="test"
2243            "#,
2244
2245        test_extract_usage_from_comments_stops_at_gap:
2246            r#"
2247#!/bin/bash
2248#USAGE bin "test"
2249#USAGE flag "--foo" help="test"
2250
2251#USAGE flag "--bar" help="should not be included"
2252echo "hello"
2253            "#,
2254            r#"
2255bin "test"
2256flag "--foo" help="test"
2257            "#,
2258
2259        test_extract_usage_from_comments_with_content_after_marker:
2260            r#"
2261#!/bin/bash
2262# [USAGE] bin "test"
2263# [USAGE] flag "--verbose" help="verbose mode"
2264# [USAGE] arg "input" help="input file"
2265echo "hello"
2266            "#,
2267            r#"
2268bin "test"
2269flag "--verbose" help="verbose mode"
2270arg "input" help="input file"
2271            "#,
2272
2273        test_extract_usage_from_comments_double_colon_original:
2274            r#"
2275::USAGE bin "test"
2276::USAGE flag "--foo" help="test"
2277echo "hello"
2278            "#,
2279            r#"
2280bin "test"
2281flag "--foo" help="test"
2282            "#,
2283
2284        test_extract_usage_from_comments_double_colon_bracket_with_space:
2285            r#"
2286:: [USAGE] bin "test"
2287:: [USAGE] flag "--foo" help="test"
2288echo "hello"
2289            "#,
2290            r#"
2291bin "test"
2292flag "--foo" help="test"
2293            "#,
2294
2295        test_extract_usage_from_comments_double_colon_bracket_no_space:
2296            r#"
2297::[USAGE] bin "test"
2298::[USAGE] flag "--foo" help="test"
2299echo "hello"
2300            "#,
2301            r#"
2302bin "test"
2303flag "--foo" help="test"
2304            "#,
2305
2306        test_extract_usage_from_comments_double_colon_stops_at_gap:
2307            r#"
2308::USAGE bin "test"
2309::USAGE flag "--foo" help="test"
2310
2311::USAGE flag "--bar" help="should not be included"
2312echo "hello"
2313            "#,
2314            r#"
2315bin "test"
2316flag "--foo" help="test"
2317            "#,
2318
2319        test_extract_usage_from_comments_double_colon_with_content_after_marker:
2320            r#"
2321::USAGE bin "test"
2322::USAGE flag "--verbose" help="verbose mode"
2323::USAGE arg "input" help="input file"
2324echo "hello"
2325            "#,
2326            r#"
2327bin "test"
2328flag "--verbose" help="verbose mode"
2329arg "input" help="input file"
2330            "#,
2331
2332        test_extract_usage_from_comments_double_colon_bracket_with_space_multiple_lines:
2333            r#"
2334:: [USAGE] bin "myapp"
2335:: [USAGE] flag "--config <file>" help="config file"
2336:: [USAGE] flag "--verbose" help="verbose output"
2337:: [USAGE] arg "input" help="input file"
2338:: [USAGE] arg "[output]" help="output file" required=#false
2339echo "done"
2340            "#,
2341            r#"
2342bin "myapp"
2343flag "--config <file>" help="config file"
2344flag "--verbose" help="verbose output"
2345arg "input" help="input file"
2346arg "[output]" help="output file" required=#false
2347            "#,
2348
2349        test_extract_usage_from_comments_empty:
2350            r#"
2351#!/bin/bash
2352echo "hello"
2353            "#,
2354            "",
2355
2356        test_extract_usage_from_comments_lowercase_usage:
2357            r#"
2358#!/bin/bash
2359#usage bin "test"
2360#usage flag "--foo" help="test"
2361echo "hello"
2362            "#,
2363            "",
2364
2365        test_extract_usage_from_comments_mixed_case_usage:
2366            r#"
2367#!/bin/bash
2368#Usage bin "test"
2369#Usage flag "--foo" help="test"
2370echo "hello"
2371            "#,
2372            "",
2373
2374        test_extract_usage_from_comments_space_before_usage:
2375            r#"
2376#!/bin/bash
2377# USAGE bin "test"
2378# USAGE flag "--foo" help="test"
2379echo "hello"
2380            "#,
2381            "",
2382
2383        test_extract_usage_from_comments_double_slash_lowercase:
2384            r#"
2385#!/usr/bin/env node
2386//usage bin "test"
2387//usage flag "--foo" help="test"
2388console.log("hello");
2389            "#,
2390            "",
2391
2392        test_extract_usage_from_comments_double_slash_mixed_case:
2393            r#"
2394#!/usr/bin/env node
2395//Usage bin "test"
2396//Usage flag "--foo" help="test"
2397console.log("hello");
2398            "#,
2399            "",
2400
2401        test_extract_usage_from_comments_double_slash_space_before_usage:
2402            r#"
2403#!/usr/bin/env node
2404// USAGE bin "test"
2405// USAGE flag "--foo" help="test"
2406console.log("hello");
2407            "#,
2408            "",
2409
2410        test_extract_usage_from_comments_bracket_lowercase:
2411            r#"
2412#!/bin/bash
2413#[usage] bin "test"
2414#[usage] flag "--foo" help="test"
2415echo "hello"
2416            "#,
2417            "",
2418
2419        test_extract_usage_from_comments_bracket_mixed_case:
2420            r#"
2421#!/bin/bash
2422#[Usage] bin "test"
2423#[Usage] flag "--foo" help="test"
2424echo "hello"
2425            "#,
2426            "",
2427
2428        test_extract_usage_from_comments_bracket_space_lowercase:
2429            r#"
2430#!/bin/bash
2431# [usage] bin "test"
2432# [usage] flag "--foo" help="test"
2433echo "hello"
2434            "#,
2435            "",
2436
2437        test_extract_usage_from_comments_double_colon_lowercase:
2438            r#"
2439::usage bin "test"
2440::usage flag "--foo" help="test"
2441echo "hello"
2442            "#,
2443            "",
2444
2445        test_extract_usage_from_comments_double_colon_mixed_case:
2446            r#"
2447::Usage bin "test"
2448::Usage flag "--foo" help="test"
2449echo "hello"
2450            "#,
2451            "",
2452
2453        test_extract_usage_from_comments_double_colon_space_before_usage:
2454            r#"
2455:: USAGE bin "test"
2456:: USAGE flag "--foo" help="test"
2457echo "hello"
2458            "#,
2459            "",
2460
2461        test_extract_usage_from_comments_double_colon_bracket_lowercase:
2462            r#"
2463::[usage] bin "test"
2464::[usage] flag "--foo" help="test"
2465echo "hello"
2466            "#,
2467            "",
2468
2469        test_extract_usage_from_comments_double_colon_bracket_mixed_case:
2470            r#"
2471::[Usage] bin "test"
2472::[Usage] flag "--foo" help="test"
2473echo "hello"
2474            "#,
2475            "",
2476
2477        test_extract_usage_from_comments_double_colon_bracket_space_lowercase:
2478            r#"
2479:: [usage] bin "test"
2480:: [usage] flag "--foo" help="test"
2481echo "hello"
2482            "#,
2483            "",
2484    }
2485
2486    #[test]
2487    fn test_spec_with_examples() {
2488        let spec = Spec::parse(
2489            &Default::default(),
2490            r#"
2491name "demo"
2492bin "demo"
2493example "demo --help" header="Getting help" help="Display help information"
2494example "demo --version" header="Check version"
2495        "#,
2496        )
2497        .unwrap();
2498
2499        assert_eq!(spec.examples.len(), 2);
2500
2501        assert_eq!(spec.examples[0].code, "demo --help");
2502        assert_eq!(spec.examples[0].header, Some("Getting help".to_string()));
2503        assert_eq!(
2504            spec.examples[0].help,
2505            Some("Display help information".to_string())
2506        );
2507
2508        assert_eq!(spec.examples[1].code, "demo --version");
2509        assert_eq!(spec.examples[1].header, Some("Check version".to_string()));
2510        assert_eq!(spec.examples[1].help, None);
2511    }
2512
2513    #[test]
2514    fn test_spec_examples_display() {
2515        let spec = Spec::parse(
2516            &Default::default(),
2517            r#"
2518name "demo"
2519bin "demo"
2520example "demo --help" header="Getting help" help="Show help"
2521example "demo --version"
2522        "#,
2523        )
2524        .unwrap();
2525
2526        let output = format!("{}", spec);
2527        assert!(
2528            output.contains("example \"demo --help\" header=\"Getting help\" help=\"Show help\"")
2529        );
2530        assert!(output.contains("example \"demo --version\""));
2531    }
2532
2533    #[test]
2534    fn test_parse_script_str() {
2535        let spec = Spec::parse_script_str(
2536            r#"
2537#!/bin/bash
2538#USAGE bin "test"
2539#USAGE flag "--foo" help="test"
2540echo "hello"
2541            "#,
2542        )
2543        .unwrap();
2544
2545        assert_eq!(spec.bin, "test");
2546        assert_eq!(spec.name, "test");
2547        assert_eq!(spec.cmd.flags.len(), 1);
2548        assert_eq!(spec.cmd.flags[0].long, ["foo"]);
2549    }
2550
2551    #[test]
2552    fn test_parse_script_str_rejects_relative_includes() {
2553        let err = Spec::parse_script_str(r#"#USAGE include file="relative.usage.kdl""#)
2554            .expect_err("relative includes need a source path");
2555
2556        match err {
2557            UsageErr::InvalidInput(msg, _, _) => {
2558                assert_eq!(msg, "relative includes require a source file");
2559            }
2560            err => panic!("unexpected error: {err:?}"),
2561        }
2562    }
2563
2564    #[test]
2565    fn test_include_does_not_infer_metadata_from_included_filename() {
2566        let dir = tempfile::tempdir().unwrap();
2567        let included = dir.path().join("overrides.usage.kdl");
2568        let root = dir.path().join("my-script.usage.kdl");
2569        std::fs::write(&included, "").unwrap();
2570        std::fs::write(&root, "include file=\"./overrides.usage.kdl\"\n").unwrap();
2571
2572        let spec = Spec::parse_file(&root).unwrap();
2573
2574        assert_eq!(spec.name, "my-script.usage.kdl");
2575        assert_eq!(spec.bin, "my-script.usage.kdl");
2576        assert!(spec.cmd.name.is_empty());
2577    }
2578
2579    #[test]
2580    fn resolving_mounts_replaces_the_unresolved_synopsis() {
2581        let mut spec: Spec = "bin ex\nmount run=tasks synopsis=\"[TASK] [ARGS]…\""
2582            .parse()
2583            .unwrap();
2584        assert!(spec.cmd.usage.contains("[TASK] [ARGS]…"));
2585        spec.resolve_mount_outputs(&HashMap::from([(
2586            "tasks".to_string(),
2587            "cmd build".to_string(),
2588        )]))
2589        .unwrap();
2590        assert!(spec.cmd.mounts.is_empty());
2591        assert_eq!(spec.cmd.usage, "[SUBCOMMAND]");
2592        assert_eq!(spec.cmd.subcommands["build"].usage, "build");
2593        let json = serde_json::to_value(&spec).unwrap();
2594        assert_eq!(json["cmd"]["usage"], "[SUBCOMMAND]");
2595    }
2596
2597    #[test]
2598    fn injected_nested_mounts_ignore_the_mounted_specs_root_default() {
2599        let mut spec: Spec = "mount run=outer".parse().unwrap();
2600        let outputs = HashMap::from([
2601            (
2602                "outer".to_string(),
2603                "default_subcommand run\nmount run=nested\ncmd run".to_string(),
2604            ),
2605            ("nested".to_string(), "cmd leaf".to_string()),
2606        ]);
2607
2608        spec.resolve_mount_outputs(&outputs).unwrap();
2609
2610        assert!(spec.cmd.subcommands.contains_key("leaf"));
2611    }
2612
2613    #[test]
2614    fn restamping_a_grafted_tree_matches_writing_it_out_and_reading_it_back() {
2615        // The claim the whole thing rests on: grafting a command in and restamping produces the
2616        // tree a spec declaring the same commands would have parsed to. If it ever stops being
2617        // true, the cheap path is silently wrong rather than slow.
2618        let host: Spec =
2619            "name \"host\"\nbin \"host\"\ncmd \"plugins\" {\n  external_subcommand #true\n}\n"
2620                .parse()
2621                .unwrap();
2622        let plugin: Spec =
2623            "name \"formatter\"\nbin \"formatter\"\narg \"[path]\"\ncmd \"check\" {\n  flag \"--fix\"\n}\n"
2624                .parse()
2625                .unwrap();
2626
2627        let mut grafted = host.clone();
2628        grafted
2629            .cmd
2630            .subcommands
2631            .get_mut("plugins")
2632            .unwrap()
2633            .subcommands
2634            .insert("formatter".to_string(), plugin.cmd.clone());
2635        grafted.restamp();
2636
2637        let reparsed: Spec = grafted.to_string().parse().unwrap();
2638        let path = |spec: &Spec, words: &[&str]| {
2639            let mut cmd = &spec.cmd;
2640            for word in words {
2641                cmd = cmd.find_subcommand(word).unwrap();
2642            }
2643            (cmd.full_cmd.clone(), cmd.usage.clone())
2644        };
2645        for words in [
2646            &[][..],
2647            &["plugins"],
2648            &["plugins", "formatter"],
2649            &["plugins", "formatter", "check"],
2650        ] {
2651            assert_eq!(
2652                path(&grafted, words),
2653                path(&reparsed, words),
2654                "at {words:?}"
2655            );
2656        }
2657
2658        // Specifically: the plugin no longer claims to be a root, and its new parent has
2659        // learned that it takes a subcommand at all.
2660        assert_eq!(
2661            path(&grafted, &["plugins", "formatter"]).0,
2662            ["plugins", "formatter"]
2663        );
2664        assert!(
2665            path(&grafted, &["plugins"]).1.contains("[SUBCOMMAND]"),
2666            "{:?}",
2667            path(&grafted, &["plugins"]).1
2668        );
2669        // And the memoized lookup answers for what is there now, aliases included.
2670        assert!(grafted
2671            .cmd
2672            .find_subcommand("plugins")
2673            .unwrap()
2674            .find_subcommand("formatter")
2675            .is_some());
2676    }
2677}