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