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