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;
7mod context;
8pub mod data_types;
9pub mod effect;
10pub mod flag;
11pub mod helpers;
12pub mod mount;
13
14use indexmap::IndexMap;
15use kdl::{KdlDocument, KdlEntry, KdlNode};
16use log::{info, warn};
17use serde::Serialize;
18use std::fmt::{Display, Formatter};
19use std::iter::once;
20use std::path::Path;
21use std::str::FromStr;
22use xx::file;
23
24use crate::error::UsageErr;
25use crate::spec::cmd::{SpecCommand, SpecExample};
26use crate::spec::config::SpecConfig;
27use crate::spec::context::ParsingContext;
28use crate::spec::helpers::{string_entry, NodeHelper};
29use crate::{SpecArg, SpecComplete, SpecFlag};
30
31#[derive(Debug, Default, Clone, Serialize)]
32#[non_exhaustive]
33pub struct Spec {
34    pub name: String,
35    pub bin: String,
36    pub cmd: SpecCommand,
37    pub config: SpecConfig,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub version: Option<String>,
40    pub usage: String,
41    pub complete: IndexMap<String, SpecComplete>,
42
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub source_code_link_template: Option<String>,
45    /// Where the CLI's source lives, e.g. `https://github.com/jdx/mise`.
46    ///
47    /// Distinct from [`Self::source_code_link_template`], which is a per-command
48    /// deep link with a `{{path}}` placeholder and is only usable for building
49    /// "view source" links in generated docs. Scraping a repository out of it
50    /// works for one forge and one URL layout and fails everywhere else.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub repository: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub author: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub about: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub about_long: Option<String>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub about_md: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub license: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub before_help: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub after_help: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub before_help_long: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub after_help_long: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub disable_help: Option<bool>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub min_usage_version: Option<String>,
75    #[serde(skip_serializing_if = "Vec::is_empty")]
76    pub examples: Vec<SpecExample>,
77    /// Default subcommand to use when first non-flag argument is not a known subcommand.
78    /// This enables "naked" command syntax like `mise foo` instead of `mise run foo`.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub default_subcommand: Option<String>,
81}
82
83impl Spec {
84    /// Parse a spec from a file.
85    ///
86    /// Automatically detects whether the file is:
87    /// - A `.kdl` or `.usage.kdl` file containing a raw spec
88    /// - A script file with embedded `#USAGE` comments
89    ///
90    /// If `bin` is not specified in the spec, it defaults to the filename.
91    #[must_use = "parsing result should be used"]
92    pub fn parse_file(file: &Path) -> Result<Spec, UsageErr> {
93        Self::parse_file_with_metadata_inference(file, true)
94    }
95
96    fn parse_file_with_metadata_inference(
97        file: &Path,
98        infer_metadata_from_filename: bool,
99    ) -> Result<Spec, UsageErr> {
100        let spec = split_script(file)?;
101        let ctx = ParsingContext::new(file, &spec);
102        let mut schema = Self::parse(&ctx, &spec)?;
103        if infer_metadata_from_filename && schema.bin.is_empty() {
104            schema.bin = file
105                .file_name()
106                .and_then(|n| n.to_str())
107                .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
108                .to_string();
109        }
110        if schema.name.is_empty() {
111            schema.name.clone_from(&schema.bin);
112        }
113        Ok(schema)
114    }
115    /// Parse a spec from a script file's embedded USAGE comments.
116    ///
117    /// Extracts the spec from comment lines marked with `#USAGE`, `//USAGE`,
118    /// `::USAGE`, or their `[USAGE]` variants.
119    /// If `bin` is not specified in the spec, it defaults to the filename.
120    #[must_use = "parsing result should be used"]
121    pub fn parse_script(file: &Path) -> Result<Spec, UsageErr> {
122        let mut spec = Self::parse_script_with_path(&file::read_to_string(file)?, file)?;
123        if spec.bin.is_empty() {
124            spec.bin = file
125                .file_name()
126                .and_then(|n| n.to_str())
127                .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
128                .to_string();
129        }
130        if spec.name.is_empty() {
131            spec.name.clone_from(&spec.bin);
132        }
133        Ok(spec)
134    }
135
136    /// Parse a spec from a script string's embedded USAGE comments.
137    ///
138    /// Extracts the spec from comment lines marked with `#USAGE`, `//USAGE`,
139    /// `::USAGE`, or their `[USAGE]` variants. Unlike [`Self::parse_script`],
140    /// this function cannot infer `bin` or `name` from a filename. Relative
141    /// `include` paths are rejected because there is no source path to resolve
142    /// them against; absolute `include` paths remain supported.
143    #[must_use = "parsing result should be used"]
144    pub fn parse_script_str(input: &str) -> Result<Spec, UsageErr> {
145        Self::parse_script_with_path(input, Path::new(""))
146    }
147
148    fn parse_script_with_path(input: &str, file: &Path) -> Result<Spec, UsageErr> {
149        let raw = extract_usage_from_comments(input);
150        let ctx = ParsingContext::new(file, &raw);
151        Self::parse(&ctx, &raw)
152    }
153
154    #[deprecated]
155    pub fn parse_spec(input: &str) -> Result<Spec, UsageErr> {
156        Self::parse(&Default::default(), input)
157    }
158
159    pub fn is_empty(&self) -> bool {
160        self.name.is_empty()
161            && self.bin.is_empty()
162            && self.usage.is_empty()
163            && self.cmd.is_empty()
164            && self.config.is_empty()
165            && self.complete.is_empty()
166            && self.examples.is_empty()
167    }
168
169    pub(crate) fn parse(ctx: &ParsingContext, input: &str) -> Result<Spec, UsageErr> {
170        let kdl: KdlDocument = input
171            .parse()
172            .map_err(|err: kdl::KdlError| UsageErr::KdlError(err))?;
173        let mut schema = Self {
174            ..Default::default()
175        };
176        for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) {
177            match node.name() {
178                "name" => schema.name = node.arg(0)?.ensure_string()?,
179                "bin" => {
180                    schema.bin = node.arg(0)?.ensure_string()?;
181                    if schema.name.is_empty() {
182                        schema.name.clone_from(&schema.bin);
183                    }
184                }
185                "version" => schema.version = Some(node.arg(0)?.ensure_string()?),
186                "author" => schema.author = Some(node.arg(0)?.ensure_string()?),
187                "source_code_link_template" => {
188                    schema.source_code_link_template = Some(node.arg(0)?.ensure_string()?)
189                }
190                "repository" => schema.repository = Some(node.arg(0)?.ensure_string()?),
191                "about" => schema.about = Some(node.arg(0)?.ensure_string()?),
192                "long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
193                "about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
194                "about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?),
195                "license" => schema.license = Some(node.arg(0)?.ensure_string()?),
196                "before_help" => schema.before_help = Some(node.arg(0)?.ensure_string()?),
197                "after_help" => schema.after_help = Some(node.arg(0)?.ensure_string()?),
198                "before_long_help" | "before_help_long" => {
199                    schema.before_help_long = Some(node.arg(0)?.ensure_string()?)
200                }
201                "after_long_help" | "after_help_long" => {
202                    schema.after_help_long = Some(node.arg(0)?.ensure_string()?)
203                }
204                "usage" => schema.usage = node.arg(0)?.ensure_string()?,
205                "arg" => schema.cmd.args.push(SpecArg::parse(ctx, &node)?),
206                "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?),
207                "cmd" => {
208                    let node: SpecCommand = SpecCommand::parse(ctx, &node)?;
209                    schema.cmd.subcommands.insert(node.name.to_string(), node);
210                }
211                "config" => schema.config = SpecConfig::parse(ctx, &node)?,
212                "complete" => {
213                    let complete = SpecComplete::parse(ctx, &node)?;
214                    schema.complete.insert(complete.name.clone(), complete);
215                }
216                "disable_help" => schema.disable_help = Some(node.arg(0)?.ensure_bool()?),
217                "min_usage_version" => {
218                    let v = node.arg(0)?.ensure_string()?;
219                    check_usage_version(&v);
220                    schema.min_usage_version = Some(v);
221                }
222                "default_subcommand" => {
223                    schema.default_subcommand = Some(node.arg(0)?.ensure_string()?)
224                }
225                "example" => {
226                    let code = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
227                    let mut example = SpecExample::new(code.trim().to_string());
228                    for (k, v) in node.props() {
229                        match k {
230                            "header" => example.header = Some(v.ensure_string()?),
231                            "help" => example.help = Some(v.ensure_string()?),
232                            "lang" => example.lang = v.ensure_string()?,
233                            k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
234                        }
235                    }
236                    schema.examples.push(example);
237                }
238                "include" => {
239                    let file = node
240                        .props()
241                        .get("file")
242                        .map(|v| v.ensure_string())
243                        .transpose()?
244                        .ok_or_else(|| ctx.build_err("missing file".into(), node.span()))?;
245                    let file = Path::new(&file);
246                    let file = match file.is_relative() {
247                        true => ctx
248                            .file
249                            .parent()
250                            .ok_or_else(|| {
251                                let msg = if ctx.file.as_os_str().is_empty() {
252                                    "relative includes require a source file".to_string()
253                                } else {
254                                    format!("cannot get parent of {}", ctx.file.display())
255                                };
256                                ctx.build_err(msg, node.span())
257                            })?
258                            .join(file),
259                        false => file.to_path_buf(),
260                    };
261                    info!("include: {}", file.display());
262                    let other = Self::parse_file_with_metadata_inference(&file, false)?;
263                    schema.merge(other);
264                }
265                k => bail_parse!(ctx, node.node.name().span(), "unsupported spec key {k}"),
266            }
267        }
268        schema.cmd.name = if schema.bin.is_empty() {
269            schema.name.clone()
270        } else {
271            schema.bin.clone()
272        };
273        set_subcommand_ancestors(&mut schema.cmd, &[]);
274        Ok(schema)
275    }
276
277    pub fn merge(&mut self, other: Spec) {
278        macro_rules! merge_str {
279            ($field:ident) => {
280                if !other.$field.is_empty() {
281                    self.$field = other.$field;
282                }
283            };
284        }
285        macro_rules! merge_opt {
286            ($field:ident) => {
287                if other.$field.is_some() {
288                    self.$field = other.$field;
289                }
290            };
291        }
292        macro_rules! merge_extend {
293            ($field:ident) => {
294                if !other.$field.is_empty() {
295                    self.$field.extend(other.$field);
296                }
297            };
298        }
299
300        merge_str!(name);
301        merge_str!(bin);
302        merge_str!(usage);
303        merge_opt!(about);
304        merge_opt!(source_code_link_template);
305        merge_opt!(repository);
306        merge_opt!(version);
307        merge_opt!(author);
308        merge_opt!(about_long);
309        merge_opt!(about_md);
310        merge_opt!(license);
311        merge_opt!(before_help);
312        merge_opt!(after_help);
313        merge_opt!(before_help_long);
314        merge_opt!(after_help_long);
315        merge_opt!(disable_help);
316        merge_opt!(min_usage_version);
317        merge_opt!(default_subcommand);
318        merge_extend!(complete);
319        merge_extend!(examples);
320
321        if !other.config.is_empty() {
322            self.config.merge(&other.config);
323        }
324        self.cmd.merge(other.cmd);
325    }
326}
327
328fn check_usage_version(version: &str) {
329    let cur = versions::Versioning::new(env!("CARGO_PKG_VERSION")).unwrap();
330    match versions::Versioning::new(version) {
331        Some(v) => {
332            if cur < v {
333                warn!(
334                    "This usage spec requires at least version {version}, but you are using version {cur} of usage"
335                );
336            }
337        }
338        _ => warn!("Invalid version: {version}"),
339    }
340}
341
342fn split_script(file: &Path) -> Result<String, UsageErr> {
343    let full = file::read_to_string(file)?;
344    // If file has a shebang and USAGE comments, extract the spec from comments
345    if full.starts_with("#!") {
346        let usage_regex = xx::regex!(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])");
347        if full.lines().any(|l| usage_regex.is_match(l)) {
348            return Ok(extract_usage_from_comments(&full));
349        }
350    }
351    // Otherwise treat the whole file as a KDL spec (e.g., .usage.kdl files)
352    Ok(full)
353}
354
355fn extract_usage_from_comments(full: &str) -> String {
356    let usage_regex = xx::regex!(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])(.*)$");
357    let blank_comment_regex = xx::regex!(r"^(?:#|//|::)\s*$");
358    let mut usage = vec![];
359    let mut found = false;
360    for line in full.lines() {
361        if let Some(captures) = usage_regex.captures(line) {
362            found = true;
363            let content = captures.get(1).map_or("", |m| m.as_str());
364            usage.push(content.trim());
365        } else if found {
366            // Allow blank comment lines to continue parsing
367            if blank_comment_regex.is_match(line) {
368                continue;
369            }
370            // if there is a non-blank non-USAGE line, stop reading
371            break;
372        }
373    }
374    usage.join("\n")
375}
376
377fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
378    for subcmd in cmd.subcommands.values_mut() {
379        subcmd.full_cmd = ancestors
380            .iter()
381            .cloned()
382            .chain(once(subcmd.name.clone()))
383            .collect();
384        let child_ancestors = subcmd.full_cmd.clone();
385        set_subcommand_ancestors(subcmd, &child_ancestors);
386    }
387    if cmd.usage.is_empty() {
388        cmd.usage = cmd.usage();
389    }
390}
391
392impl Display for Spec {
393    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
394        let mut doc = KdlDocument::new();
395        let nodes = &mut doc.nodes_mut();
396        if !self.name.is_empty() {
397            let mut node = KdlNode::new("name");
398            node.push(string_entry(None, &self.name));
399            nodes.push(node);
400        }
401        if !self.bin.is_empty() {
402            let mut node = KdlNode::new("bin");
403            node.push(string_entry(None, &self.bin));
404            nodes.push(node);
405        }
406        if let Some(version) = &self.version {
407            let mut node = KdlNode::new("version");
408            node.push(string_entry(None, version));
409            nodes.push(node);
410        }
411        if let Some(author) = &self.author {
412            let mut node = KdlNode::new("author");
413            node.push(string_entry(None, author));
414            nodes.push(node);
415        }
416        if let Some(about) = &self.about {
417            let mut node = KdlNode::new("about");
418            node.push(string_entry(None, about));
419            nodes.push(node);
420        }
421        if let Some(source_code_link_template) = &self.source_code_link_template {
422            let mut node = KdlNode::new("source_code_link_template");
423            node.push(string_entry(None, source_code_link_template));
424            nodes.push(node);
425        }
426        if let Some(repository) = &self.repository {
427            let mut node = KdlNode::new("repository");
428            node.push(string_entry(None, repository));
429            nodes.push(node);
430        }
431        if let Some(about_md) = &self.about_md {
432            let mut node = KdlNode::new("about_md");
433            node.push(string_entry(None, about_md));
434            nodes.push(node);
435        }
436        if let Some(long_about) = &self.about_long {
437            let mut node = KdlNode::new("long_about");
438            node.push(string_entry(None, long_about));
439            nodes.push(node);
440        }
441        if let Some(license) = &self.license {
442            let mut node = KdlNode::new("license");
443            node.push(string_entry(None, license));
444            nodes.push(node);
445        }
446        if let Some(before_help) = &self.before_help {
447            let mut node = KdlNode::new("before_help");
448            node.push(string_entry(None, before_help));
449            nodes.push(node);
450        }
451        if let Some(after_help) = &self.after_help {
452            let mut node = KdlNode::new("after_help");
453            node.push(string_entry(None, after_help));
454            nodes.push(node);
455        }
456        if let Some(before_help_long) = &self.before_help_long {
457            let mut node = KdlNode::new("before_long_help");
458            node.push(string_entry(None, before_help_long));
459            nodes.push(node);
460        }
461        if let Some(after_help_long) = &self.after_help_long {
462            let mut node = KdlNode::new("after_long_help");
463            node.push(string_entry(None, after_help_long));
464            nodes.push(node);
465        }
466        if let Some(disable_help) = self.disable_help {
467            let mut node = KdlNode::new("disable_help");
468            node.push(KdlEntry::new(disable_help));
469            nodes.push(node);
470        }
471        if let Some(min_usage_version) = &self.min_usage_version {
472            let mut node = KdlNode::new("min_usage_version");
473            node.push(string_entry(None, min_usage_version));
474            nodes.push(node);
475        }
476        if let Some(default_subcommand) = &self.default_subcommand {
477            let mut node = KdlNode::new("default_subcommand");
478            node.push(string_entry(None, default_subcommand));
479            nodes.push(node);
480        }
481        if !self.usage.is_empty() {
482            let mut node = KdlNode::new("usage");
483            node.push(string_entry(None, &self.usage));
484            nodes.push(node);
485        }
486        for flag in self.cmd.flags.iter() {
487            nodes.push(flag.into());
488        }
489        for arg in self.cmd.args.iter() {
490            nodes.push(arg.into());
491        }
492        for example in self.examples.iter() {
493            nodes.push(example.into());
494        }
495        for complete in self.complete.values() {
496            nodes.push(complete.into());
497        }
498        for complete in self.cmd.complete.values() {
499            nodes.push(complete.into());
500        }
501        for cmd in self.cmd.subcommands.values() {
502            nodes.push(cmd.into())
503        }
504        if !self.config.is_empty() {
505            nodes.push((&self.config).into());
506        }
507        doc.autoformat_config(&kdl::FormatConfigBuilder::new().build());
508        write!(f, "{doc}")
509    }
510}
511
512impl FromStr for Spec {
513    type Err = UsageErr;
514
515    fn from_str(s: &str) -> Result<Self, Self::Err> {
516        Self::parse(&Default::default(), s)
517    }
518}
519
520#[cfg(feature = "clap")]
521impl From<&clap::Command> for Spec {
522    fn from(cmd: &clap::Command) -> Self {
523        Spec {
524            name: cmd.get_name().to_string(),
525            bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
526            cmd: cmd.into(),
527            version: cmd.get_version().map(|v| v.to_string()),
528            about: cmd.get_about().map(|a| a.to_string()),
529            about_long: cmd.get_long_about().map(|a| a.to_string()),
530            usage: cmd.clone().render_usage().to_string(),
531            ..Default::default()
532        }
533    }
534}
535
536#[inline]
537pub fn is_true(b: &bool) -> bool {
538    *b
539}
540
541#[inline]
542pub fn is_false(b: &bool) -> bool {
543    !is_true(b)
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use insta::assert_snapshot;
550
551    #[test]
552    fn test_display() {
553        let spec = Spec::parse(
554            &Default::default(),
555            r#"
556name "Usage CLI"
557bin "usage"
558arg "arg1"
559flag "-f --force" global=#true
560cmd "config" {
561  cmd "set" {
562    arg "key" help="Key to set"
563    arg "value"
564  }
565}
566complete "file" run="ls" descriptions=#true
567        "#,
568        )
569        .unwrap();
570        assert_snapshot!(spec, @r#"
571        name "Usage CLI"
572        bin usage
573        flag "-f --force" global=#true
574        arg <arg1>
575        complete file run=ls descriptions=#true
576        cmd config {
577            cmd set {
578                arg <key> help="Key to set"
579                arg <value>
580            }
581        }
582        "#);
583    }
584
585    #[test]
586    fn test_repository_round_trips() {
587        let spec = Spec::parse(
588            &Default::default(),
589            r#"
590bin "mise"
591repository "https://github.com/jdx/mise"
592source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
593        "#,
594        )
595        .unwrap();
596        assert_eq!(
597            spec.repository.as_deref(),
598            Some("https://github.com/jdx/mise")
599        );
600        // A spec that is parsed and re-emitted must not lose it, which is the
601        // failure mode for every field added to this struct.
602        assert_snapshot!(spec, @r#"
603        name mise
604        bin mise
605        source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
606        repository "https://github.com/jdx/mise"
607        "#);
608    }
609
610    #[test]
611    fn test_repository_merges_like_the_other_optionals() {
612        // Extra specs are merged over a generated one, which is how a clap CLI
613        // declares anything clap has no concept of.
614        let mut generated = Spec::parse(&Default::default(), r#"bin "mise""#).unwrap();
615        let extra = Spec::parse(
616            &Default::default(),
617            r#"repository "https://github.com/jdx/mise""#,
618        )
619        .unwrap();
620        generated.merge(extra);
621        assert_eq!(
622            generated.repository.as_deref(),
623            Some("https://github.com/jdx/mise")
624        );
625    }
626
627    #[test]
628    #[cfg(feature = "clap")]
629    fn test_clap() {
630        let cmd = clap::Command::new("test");
631        assert_snapshot!(Spec::from(&cmd), @r#"
632        name test
633        bin test
634        usage "Usage: test"
635        "#);
636    }
637
638    macro_rules! extract_usage_tests {
639        ($($name:ident: $input:expr, $expected:expr,)*) => {
640        $(
641            #[test]
642            fn $name() {
643                let result = extract_usage_from_comments($input);
644                let expected = $expected.trim_start_matches('\n').trim_end();
645                assert_eq!(result, expected);
646            }
647        )*
648        }
649    }
650
651    extract_usage_tests! {
652        test_extract_usage_from_comments_original_hash:
653            r#"
654#!/bin/bash
655#USAGE bin "test"
656#USAGE flag "--foo" help="test"
657echo "hello"
658            "#,
659            r#"
660bin "test"
661flag "--foo" help="test"
662            "#,
663
664        test_extract_usage_from_comments_original_double_slash:
665            r#"
666#!/usr/bin/env node
667//USAGE bin "test"
668//USAGE flag "--foo" help="test"
669console.log("hello");
670            "#,
671            r#"
672bin "test"
673flag "--foo" help="test"
674            "#,
675
676        test_extract_usage_from_comments_bracket_with_space:
677            r#"
678#!/bin/bash
679# [USAGE] bin "test"
680# [USAGE] flag "--foo" help="test"
681echo "hello"
682            "#,
683            r#"
684bin "test"
685flag "--foo" help="test"
686            "#,
687
688        test_extract_usage_from_comments_bracket_no_space:
689            r#"
690#!/bin/bash
691#[USAGE] bin "test"
692#[USAGE] flag "--foo" help="test"
693echo "hello"
694            "#,
695            r#"
696bin "test"
697flag "--foo" help="test"
698            "#,
699
700        test_extract_usage_from_comments_double_slash_bracket_with_space:
701            r#"
702#!/usr/bin/env node
703// [USAGE] bin "test"
704// [USAGE] flag "--foo" help="test"
705console.log("hello");
706            "#,
707            r#"
708bin "test"
709flag "--foo" help="test"
710            "#,
711
712        test_extract_usage_from_comments_double_slash_bracket_no_space:
713            r#"
714#!/usr/bin/env node
715//[USAGE] bin "test"
716//[USAGE] flag "--foo" help="test"
717console.log("hello");
718            "#,
719            r#"
720bin "test"
721flag "--foo" help="test"
722            "#,
723
724        test_extract_usage_from_comments_stops_at_gap:
725            r#"
726#!/bin/bash
727#USAGE bin "test"
728#USAGE flag "--foo" help="test"
729
730#USAGE flag "--bar" help="should not be included"
731echo "hello"
732            "#,
733            r#"
734bin "test"
735flag "--foo" help="test"
736            "#,
737
738        test_extract_usage_from_comments_with_content_after_marker:
739            r#"
740#!/bin/bash
741# [USAGE] bin "test"
742# [USAGE] flag "--verbose" help="verbose mode"
743# [USAGE] arg "input" help="input file"
744echo "hello"
745            "#,
746            r#"
747bin "test"
748flag "--verbose" help="verbose mode"
749arg "input" help="input file"
750            "#,
751
752        test_extract_usage_from_comments_double_colon_original:
753            r#"
754::USAGE bin "test"
755::USAGE flag "--foo" help="test"
756echo "hello"
757            "#,
758            r#"
759bin "test"
760flag "--foo" help="test"
761            "#,
762
763        test_extract_usage_from_comments_double_colon_bracket_with_space:
764            r#"
765:: [USAGE] bin "test"
766:: [USAGE] flag "--foo" help="test"
767echo "hello"
768            "#,
769            r#"
770bin "test"
771flag "--foo" help="test"
772            "#,
773
774        test_extract_usage_from_comments_double_colon_bracket_no_space:
775            r#"
776::[USAGE] bin "test"
777::[USAGE] flag "--foo" help="test"
778echo "hello"
779            "#,
780            r#"
781bin "test"
782flag "--foo" help="test"
783            "#,
784
785        test_extract_usage_from_comments_double_colon_stops_at_gap:
786            r#"
787::USAGE bin "test"
788::USAGE flag "--foo" help="test"
789
790::USAGE flag "--bar" help="should not be included"
791echo "hello"
792            "#,
793            r#"
794bin "test"
795flag "--foo" help="test"
796            "#,
797
798        test_extract_usage_from_comments_double_colon_with_content_after_marker:
799            r#"
800::USAGE bin "test"
801::USAGE flag "--verbose" help="verbose mode"
802::USAGE arg "input" help="input file"
803echo "hello"
804            "#,
805            r#"
806bin "test"
807flag "--verbose" help="verbose mode"
808arg "input" help="input file"
809            "#,
810
811        test_extract_usage_from_comments_double_colon_bracket_with_space_multiple_lines:
812            r#"
813:: [USAGE] bin "myapp"
814:: [USAGE] flag "--config <file>" help="config file"
815:: [USAGE] flag "--verbose" help="verbose output"
816:: [USAGE] arg "input" help="input file"
817:: [USAGE] arg "[output]" help="output file" required=#false
818echo "done"
819            "#,
820            r#"
821bin "myapp"
822flag "--config <file>" help="config file"
823flag "--verbose" help="verbose output"
824arg "input" help="input file"
825arg "[output]" help="output file" required=#false
826            "#,
827
828        test_extract_usage_from_comments_empty:
829            r#"
830#!/bin/bash
831echo "hello"
832            "#,
833            "",
834
835        test_extract_usage_from_comments_lowercase_usage:
836            r#"
837#!/bin/bash
838#usage bin "test"
839#usage flag "--foo" help="test"
840echo "hello"
841            "#,
842            "",
843
844        test_extract_usage_from_comments_mixed_case_usage:
845            r#"
846#!/bin/bash
847#Usage bin "test"
848#Usage flag "--foo" help="test"
849echo "hello"
850            "#,
851            "",
852
853        test_extract_usage_from_comments_space_before_usage:
854            r#"
855#!/bin/bash
856# USAGE bin "test"
857# USAGE flag "--foo" help="test"
858echo "hello"
859            "#,
860            "",
861
862        test_extract_usage_from_comments_double_slash_lowercase:
863            r#"
864#!/usr/bin/env node
865//usage bin "test"
866//usage flag "--foo" help="test"
867console.log("hello");
868            "#,
869            "",
870
871        test_extract_usage_from_comments_double_slash_mixed_case:
872            r#"
873#!/usr/bin/env node
874//Usage bin "test"
875//Usage flag "--foo" help="test"
876console.log("hello");
877            "#,
878            "",
879
880        test_extract_usage_from_comments_double_slash_space_before_usage:
881            r#"
882#!/usr/bin/env node
883// USAGE bin "test"
884// USAGE flag "--foo" help="test"
885console.log("hello");
886            "#,
887            "",
888
889        test_extract_usage_from_comments_bracket_lowercase:
890            r#"
891#!/bin/bash
892#[usage] bin "test"
893#[usage] flag "--foo" help="test"
894echo "hello"
895            "#,
896            "",
897
898        test_extract_usage_from_comments_bracket_mixed_case:
899            r#"
900#!/bin/bash
901#[Usage] bin "test"
902#[Usage] flag "--foo" help="test"
903echo "hello"
904            "#,
905            "",
906
907        test_extract_usage_from_comments_bracket_space_lowercase:
908            r#"
909#!/bin/bash
910# [usage] bin "test"
911# [usage] flag "--foo" help="test"
912echo "hello"
913            "#,
914            "",
915
916        test_extract_usage_from_comments_double_colon_lowercase:
917            r#"
918::usage bin "test"
919::usage flag "--foo" help="test"
920echo "hello"
921            "#,
922            "",
923
924        test_extract_usage_from_comments_double_colon_mixed_case:
925            r#"
926::Usage bin "test"
927::Usage flag "--foo" help="test"
928echo "hello"
929            "#,
930            "",
931
932        test_extract_usage_from_comments_double_colon_space_before_usage:
933            r#"
934:: USAGE bin "test"
935:: USAGE flag "--foo" help="test"
936echo "hello"
937            "#,
938            "",
939
940        test_extract_usage_from_comments_double_colon_bracket_lowercase:
941            r#"
942::[usage] bin "test"
943::[usage] flag "--foo" help="test"
944echo "hello"
945            "#,
946            "",
947
948        test_extract_usage_from_comments_double_colon_bracket_mixed_case:
949            r#"
950::[Usage] bin "test"
951::[Usage] flag "--foo" help="test"
952echo "hello"
953            "#,
954            "",
955
956        test_extract_usage_from_comments_double_colon_bracket_space_lowercase:
957            r#"
958:: [usage] bin "test"
959:: [usage] flag "--foo" help="test"
960echo "hello"
961            "#,
962            "",
963    }
964
965    #[test]
966    fn test_spec_with_examples() {
967        let spec = Spec::parse(
968            &Default::default(),
969            r#"
970name "demo"
971bin "demo"
972example "demo --help" header="Getting help" help="Display help information"
973example "demo --version" header="Check version"
974        "#,
975        )
976        .unwrap();
977
978        assert_eq!(spec.examples.len(), 2);
979
980        assert_eq!(spec.examples[0].code, "demo --help");
981        assert_eq!(spec.examples[0].header, Some("Getting help".to_string()));
982        assert_eq!(
983            spec.examples[0].help,
984            Some("Display help information".to_string())
985        );
986
987        assert_eq!(spec.examples[1].code, "demo --version");
988        assert_eq!(spec.examples[1].header, Some("Check version".to_string()));
989        assert_eq!(spec.examples[1].help, None);
990    }
991
992    #[test]
993    fn test_spec_examples_display() {
994        let spec = Spec::parse(
995            &Default::default(),
996            r#"
997name "demo"
998bin "demo"
999example "demo --help" header="Getting help" help="Show help"
1000example "demo --version"
1001        "#,
1002        )
1003        .unwrap();
1004
1005        let output = format!("{}", spec);
1006        assert!(
1007            output.contains("example \"demo --help\" header=\"Getting help\" help=\"Show help\"")
1008        );
1009        assert!(output.contains("example \"demo --version\""));
1010    }
1011
1012    #[test]
1013    fn test_parse_script_str() {
1014        let spec = Spec::parse_script_str(
1015            r#"
1016#!/bin/bash
1017#USAGE bin "test"
1018#USAGE flag "--foo" help="test"
1019echo "hello"
1020            "#,
1021        )
1022        .unwrap();
1023
1024        assert_eq!(spec.bin, "test");
1025        assert_eq!(spec.name, "test");
1026        assert_eq!(spec.cmd.flags.len(), 1);
1027        assert_eq!(spec.cmd.flags[0].long, ["foo"]);
1028    }
1029
1030    #[test]
1031    fn test_parse_script_str_rejects_relative_includes() {
1032        let err = Spec::parse_script_str(r#"#USAGE include file="relative.usage.kdl""#)
1033            .expect_err("relative includes need a source path");
1034
1035        match err {
1036            UsageErr::InvalidInput(msg, _, _) => {
1037                assert_eq!(msg, "relative includes require a source file");
1038            }
1039            err => panic!("unexpected error: {err:?}"),
1040        }
1041    }
1042
1043    #[test]
1044    fn test_include_does_not_infer_metadata_from_included_filename() {
1045        let dir = tempfile::tempdir().unwrap();
1046        let included = dir.path().join("overrides.usage.kdl");
1047        let root = dir.path().join("my-script.usage.kdl");
1048        std::fs::write(&included, "").unwrap();
1049        std::fs::write(&root, "include file=\"./overrides.usage.kdl\"\n").unwrap();
1050
1051        let spec = Spec::parse_file(&root).unwrap();
1052
1053        assert_eq!(spec.name, "my-script.usage.kdl");
1054        assert_eq!(spec.bin, "my-script.usage.kdl");
1055        assert!(spec.cmd.name.is_empty());
1056    }
1057}