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