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