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