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