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