Skip to main content

usage/spec/
mod.rs

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