Skip to main content

usage/spec/
mod.rs

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