Skip to main content

usage/spec/
mod.rs

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