1pub mod admonition;
2pub mod arg;
3pub mod builder;
4pub mod choices;
5pub mod clause;
6pub mod cmd;
7pub mod complete;
8pub mod config;
9pub mod config_type;
10mod context;
11pub mod data_types;
12pub mod effect;
13pub mod exit_code;
14pub mod flag;
15pub mod flagset;
16pub mod group;
17pub mod helpers;
18pub mod mount;
19pub mod output;
20pub mod unknown_flags;
21pub mod view;
22
23use crate::kdl;
24use indexmap::IndexMap;
25use kdl::{KdlDocument, KdlEntry, KdlNode};
26use log::{info, warn};
27use regex::Regex;
28use serde::Serialize;
29use std::collections::HashMap;
30use std::fmt::{Display, Formatter};
31use std::iter::once;
32use std::path::{Path, PathBuf};
33use std::str::FromStr;
34use std::sync::LazyLock;
35
36use crate::error::UsageErr;
37use crate::spec::cmd::{SpecCommand, SpecExample, SpecHeading};
38use crate::spec::config::SpecConfig;
39use crate::spec::context::ParsingContext;
40use crate::spec::exit_code::SpecExitCode;
41use crate::spec::flagset::{SpecFlagSet, SpecUse};
42use crate::spec::helpers::{string_entry, NodeHelper};
43use crate::spec::output::SpecOutput;
44use crate::{SpecArg, SpecComplete, SpecFlag};
45use view::SpecView;
46
47#[derive(Debug, Default, Clone, Serialize)]
48#[non_exhaustive]
49pub struct Spec {
50 pub name: String,
51 pub bin: String,
52 pub cmd: SpecCommand,
53 pub config: SpecConfig,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub version: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub long_version: Option<String>,
58 pub usage: String,
59 pub complete: IndexMap<String, SpecComplete>,
60 #[serde(skip_serializing_if = "IndexMap::is_empty")]
62 pub views: IndexMap<String, SpecView>,
63 #[serde(skip)]
69 pub flagsets: IndexMap<String, SpecFlagSet>,
70 #[serde(skip)]
79 pub sources: Vec<PathBuf>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub source_code_link_template: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
90 pub repository: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub author: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub about: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub about_long: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub about_md: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub license: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub before_help: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub after_help: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub before_help_long: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub after_help_long: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
119 pub help_template: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub disable_help: Option<bool>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub min_usage_version: Option<String>,
124 #[serde(skip_serializing_if = "Vec::is_empty")]
125 pub examples: Vec<SpecExample>,
126 #[serde(skip_serializing_if = "Vec::is_empty")]
128 pub outputs: Vec<SpecOutput>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub select: Option<String>,
132 #[serde(skip_serializing_if = "Vec::is_empty")]
134 pub exit_codes: Vec<SpecExitCode>,
135 #[serde(skip_serializing_if = "Option::is_none")]
138 pub default_subcommand: Option<String>,
139 #[serde(skip_serializing_if = "is_false")]
141 pub default_subcommand_flags: bool,
142 #[serde(skip)]
143 pub default_subcommand_flags_set: bool,
144 #[serde(default, skip_serializing_if = "is_false")]
151 pub multicall: bool,
152 #[doc(hidden)]
157 #[serde(skip)]
158 pub multicall_set: bool,
159 pub unknown_flags: Option<crate::spec::unknown_flags::UnknownFlags>,
162}
163
164impl Spec {
165 pub fn restamp(&mut self) {
178 restamp_paths(&mut self.cmd, &[], true);
179 }
180
181 pub fn resolve_mount_outputs(
187 &mut self,
188 outputs: &HashMap<String, String>,
189 ) -> Result<(), UsageErr> {
190 self.resolve_mount_outputs_at_root(outputs, true)
191 }
192
193 pub(crate) fn resolve_mount_outputs_at_root(
194 &mut self,
195 outputs: &HashMap<String, String>,
196 apply_default_subcommand: bool,
197 ) -> Result<(), UsageErr> {
198 fn resolve(
199 cmd: &mut SpecCommand,
200 outputs: &HashMap<String, String>,
201 skip_mounts: bool,
202 ) -> Result<(), UsageErr> {
203 if !skip_mounts && !cmd.mounts.is_empty() {
204 cmd.mount(&[], Some(outputs))?;
205 cmd.mounts.clear();
206 }
207 for subcommand in cmd.subcommands.values_mut() {
208 resolve(subcommand, outputs, false)?;
209 }
210 Ok(())
211 }
212
213 let default_outranks_root_mounts = apply_default_subcommand
214 && self.default_subcommand.is_some()
215 && !self.cmd.mounts.iter().any(|mount| mount.overrides_default);
216 resolve(&mut self.cmd, outputs, default_outranks_root_mounts)?;
217 self.restamp();
218 self.cmd
219 .validate_clause_flag_spellings()
220 .map_err(UsageErr::InvalidSpec)
221 }
222
223 #[must_use = "parsing result should be used"]
231 pub fn parse_file(file: &Path) -> Result<Spec, UsageErr> {
232 Self::parse_file_with_metadata_inference(file, true, true)
233 }
234
235 fn parse_file_with_metadata_inference(
236 file: &Path,
237 infer_metadata_from_filename: bool,
238 resolve_outputs: bool,
239 ) -> Result<Spec, UsageErr> {
240 let spec = split_script(file)?;
241 let ctx = ParsingContext::new(file, &spec);
242 let mut schema = Self::parse_with_output_resolution(&ctx, &spec, resolve_outputs)?;
243 if infer_metadata_from_filename && schema.bin.is_empty() {
244 schema.bin = file
245 .file_name()
246 .and_then(|n| n.to_str())
247 .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
248 .to_string();
249 }
250 if schema.name.is_empty() {
251 schema.name.clone_from(&schema.bin);
252 }
253 Ok(schema)
254 }
255 #[must_use = "parsing result should be used"]
261 pub fn parse_script(file: &Path) -> Result<Spec, UsageErr> {
262 let mut spec = Self::parse_script_with_path(&read_to_string(file)?, file)?;
263 if spec.bin.is_empty() {
264 spec.bin = file
265 .file_name()
266 .and_then(|n| n.to_str())
267 .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
268 .to_string();
269 }
270 if spec.name.is_empty() {
271 spec.name.clone_from(&spec.bin);
272 }
273 Ok(spec)
274 }
275
276 #[must_use = "parsing result should be used"]
284 pub fn parse_script_str(input: &str) -> Result<Spec, UsageErr> {
285 Self::parse_script_with_path(input, Path::new(""))
286 }
287
288 fn parse_script_with_path(input: &str, file: &Path) -> Result<Spec, UsageErr> {
289 let raw = extract_usage_from_comments(input);
290 let ctx = ParsingContext::new(file, &raw);
291 Self::parse(&ctx, &raw)
292 }
293
294 #[deprecated]
295 pub fn parse_spec(input: &str) -> Result<Spec, UsageErr> {
296 Self::parse(&Default::default(), input)
297 }
298
299 pub fn is_empty(&self) -> bool {
300 self.name.is_empty()
301 && self.bin.is_empty()
302 && self.usage.is_empty()
303 && self.cmd.is_empty()
304 && self.config.is_empty()
305 && self.complete.is_empty()
306 && self.views.is_empty()
307 && self.examples.is_empty()
308 }
309
310 pub fn for_view(&self, id: &str) -> Result<Spec, UsageErr> {
316 let view = self
317 .views
318 .get(id)
319 .ok_or_else(|| UsageErr::InvalidView(format!("spec declares no view named `{id}`")))?;
320 let mut command = &self.cmd;
321 for segment in view.root.split_whitespace() {
322 command = command.subcommands.get(segment).ok_or_else(|| {
323 UsageErr::InvalidView(format!(
324 "view `{id}` promotes `{}`, but `{segment}` is not a command on that path",
325 view.root
326 ))
327 })?;
328 }
329 let mut promoted = command.clone();
330 let matches_selector = |flag: &SpecFlag, selector: &str| {
331 selector
332 .strip_prefix("--")
333 .is_some_and(|name| flag.long.iter().any(|long| long == name))
334 || selector
335 .strip_prefix('-')
336 .filter(|short| short.len() == 1)
337 .and_then(|short| short.chars().next())
338 .is_some_and(|short| flag.short.contains(&short))
339 };
340 let carries = |flag: &SpecFlag| {
341 if !flag.global {
342 return false;
343 }
344 view.all_globals
345 || view
346 .globals
347 .iter()
348 .any(|selector| matches_selector(flag, selector))
349 };
350 for selector in &view.globals {
351 if !self
352 .cmd
353 .flags
354 .iter()
355 .any(|flag| flag.global && matches_selector(flag, selector))
356 {
357 return Err(UsageErr::InvalidView(format!(
358 "view `{id}` carries `{selector}`, but it is not a root global flag"
359 )));
360 }
361 }
362 let globals: Vec<SpecFlag> = self
363 .cmd
364 .flags
365 .iter()
366 .filter(|flag| carries(flag) || flag.action == crate::SpecFlagAction::Version)
369 .cloned()
370 .collect();
371 let mut globals: Vec<SpecFlag> = globals
374 .into_iter()
375 .filter(|global| {
376 !promoted
377 .flags
378 .iter()
379 .any(|local| spec_flag_forms_overlap(global, local))
380 })
381 .collect();
382 let mut complete = IndexMap::new();
387 for flag in &globals {
388 if let Some(arg) = &flag.arg {
389 let name = arg.name.to_lowercase();
390 if let Some(completer) = self.complete.get(&name) {
391 complete.insert(name, completer.clone());
392 }
393 }
394 }
395 complete.extend(std::mem::take(&mut promoted.complete));
396 let mut carried_groups = Vec::new();
400 for group in &self.cmd.groups {
401 let members: Vec<String> = group
402 .members
403 .iter()
404 .filter(|selector| {
405 globals
406 .iter()
407 .any(|flag| flag_matches_selector(flag, selector))
408 })
409 .cloned()
410 .collect();
411 match members.as_slice() {
412 [only] if group.required => {
413 if let Some(flag) = globals
414 .iter_mut()
415 .find(|flag| flag_matches_selector(flag, only))
416 {
417 flag.required = true;
418 }
419 }
420 [_, _, ..] => {
421 let mut projected = group.clone();
422 projected.members = members;
423 carried_groups.push(projected);
424 }
425 _ => {}
426 }
427 }
428 promoted.flags.splice(0..0, globals);
429 promoted.groups.splice(0..0, carried_groups);
430 promoted.name.clone_from(&view.bin);
431 promoted.full_cmd.clear();
432 promoted.aliases.clear();
433 promoted.hidden_aliases.clear();
434 promoted.disable_version_flag = self.cmd.disable_version_flag;
438 set_subcommand_ancestors(&mut promoted, &[]);
439 promoted.usage = promoted.usage();
440
441 let mut spec = self.clone();
442 spec.name.clone_from(&view.name);
443 spec.bin.clone_from(&view.bin);
444 spec.about = promoted.help.clone();
445 spec.about_long = promoted.help_long.clone();
446 spec.about_md = promoted.help_md.clone();
447 spec.before_help = promoted.before_help.clone();
448 spec.before_help_long = promoted.before_help_long.clone();
449 spec.after_help = promoted.after_help.clone();
450 spec.after_help_long = promoted.after_help_long.clone();
451 spec.examples.clone_from(&promoted.examples);
452 spec.usage = promoted.usage.clone();
453 spec.complete = complete;
454 spec.cmd = promoted;
455 spec.default_subcommand = None;
456 spec.default_subcommand_flags = false;
457 spec.default_subcommand_flags_set = false;
458 spec.multicall = false;
459 spec.multicall_set = false;
460 spec.views.clear();
461 Ok(spec)
462 }
463
464 pub fn view_for_program(&self, program: &str) -> Option<&str> {
466 let basename = crate::parse::multicall_basename(program);
467 if basename == crate::parse::multicall_basename(&self.name)
468 || (!self.bin.is_empty() && basename == crate::parse::multicall_basename(&self.bin))
469 {
470 return None;
471 }
472 self.views.values().find_map(|view| {
473 (basename == crate::parse::multicall_basename(&view.bin)
474 || basename == crate::parse::multicall_basename(&view.id))
475 .then_some(view.id.as_str())
476 })
477 }
478
479 pub(crate) fn parse(ctx: &ParsingContext, input: &str) -> Result<Spec, UsageErr> {
480 Self::parse_with_output_resolution(ctx, input, true)
481 }
482
483 fn parse_with_output_resolution(
484 ctx: &ParsingContext,
485 input: &str,
486 resolve_outputs: bool,
487 ) -> Result<Spec, UsageErr> {
488 let kdl: KdlDocument = input.parse().map_err(|err: kdl::KdlError| {
489 UsageErr::KdlError(err.with_source_name(ctx.file.to_string_lossy()))
490 })?;
491 let mut schema = Self {
492 ..Default::default()
493 };
494 if !ctx.file.as_os_str().is_empty() {
497 schema.sources.push(ctx.file.clone());
498 }
499 for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) {
500 match node.name() {
501 "name" => schema.name = node.arg(0)?.ensure_string()?,
502 "bin" => {
503 schema.bin = node.arg(0)?.ensure_string()?;
504 if schema.name.is_empty() {
505 schema.name.clone_from(&schema.bin);
506 }
507 }
508 "version" => schema.version = Some(node.arg(0)?.ensure_string()?),
509 "long_version" => schema.long_version = Some(node.arg(0)?.ensure_string()?),
510 "author" => schema.author = Some(node.arg(0)?.ensure_string()?),
511 "source_code_link_template" => {
512 schema.source_code_link_template = Some(node.arg(0)?.ensure_string()?)
513 }
514 "repository" => schema.repository = Some(node.arg(0)?.ensure_string()?),
515 "about" => schema.about = Some(node.arg(0)?.ensure_string()?),
516 "long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
517 "about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
518 "about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?),
519 "surface" => schema.cmd.surface = Some(node.arg(0)?.ensure_string()?),
520 "available_if" => {
521 schema.cmd.available_if = node
522 .ensure_arg_len(1..)?
523 .args()
524 .map(|entry| entry.ensure_string())
525 .collect::<Result<Vec<_>, _>>()?;
526 }
527 "license" => schema.license = Some(node.arg(0)?.ensure_string()?),
528 "before_help" => schema.before_help = Some(node.arg(0)?.ensure_string()?),
529 "after_help" => schema.after_help = Some(node.arg(0)?.ensure_string()?),
530 "before_long_help" | "before_help_long" => {
531 schema.before_help_long = Some(node.arg(0)?.ensure_string()?)
532 }
533 "after_long_help" | "after_help_long" => {
534 schema.after_help_long = Some(node.arg(0)?.ensure_string()?)
535 }
536 "usage" => schema.usage = node.arg(0)?.ensure_string()?,
537 "help_template" => {
541 let template = node.arg(0)?.ensure_string()?;
542 if let Err(problem) = crate::help_template::check(&template) {
543 bail_parse!(ctx, node.span(), "{problem}");
544 }
545 schema.help_template =
548 crate::help_template::is_set(&template).then_some(template);
549 }
550 "arg" => {
551 let arg = SpecArg::parse(ctx, &node)?;
552 if arg.delimiter.is_some() && !arg.var {
555 bail_parse!(
556 ctx,
557 node.node.name().span(),
558 "argument <{}> has a delimiter and holds one value; add \
559 `var=#true` for the values it splits into",
560 arg.name
561 );
562 }
563 schema.cmd.args.push(arg);
564 }
565 "clause" => {
566 if schema.cmd.clause.is_some() {
567 bail_parse!(ctx, node.span(), "a command may declare at most one clause");
568 }
569 schema.cmd.clause = Some(crate::SpecClause::parse(ctx, &node)?);
570 }
571 "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?),
572 "group" => schema.cmd.groups.push(crate::SpecGroup::parse(ctx, &node)?),
575 "mount" => schema.cmd.mounts.push(crate::SpecMount::parse(ctx, &node)?),
579 "cmd" => {
580 let node: SpecCommand = SpecCommand::parse(ctx, &node)?;
581 schema.cmd.subcommands.insert(node.name.to_string(), node);
582 }
583 "flagset" => {
584 let set = SpecFlagSet::parse(ctx, &node)?;
585 if schema.flagsets.insert(set.name.clone(), set).is_some() {
586 bail_parse!(ctx, node.span(), "a flagset may be declared only once");
587 }
588 }
589 "use" => {
592 let at = schema.cmd.flags.len();
593 schema.cmd.uses.push(SpecUse::parse(ctx, &node, at)?);
594 }
595 "config" => schema.config = SpecConfig::parse(ctx, &node)?,
596 "complete" => {
597 let complete = SpecComplete::parse(ctx, &node)?;
598 schema.complete.insert(complete.name.clone(), complete);
599 }
600 "view" => {
601 let view = SpecView::parse(ctx, &node)?;
602 if schema.views.insert(view.id.clone(), view).is_some() {
603 bail_parse!(
604 ctx,
605 node.span(),
606 "a view identifier may be declared only once"
607 );
608 }
609 }
610 "disable_help" => schema.disable_help = Some(node.arg(0)?.ensure_bool()?),
611 "min_usage_version" => {
612 let v = node.arg(0)?.ensure_string()?;
613 check_usage_version(&v);
614 schema.min_usage_version = Some(v);
615 }
616 "unknown_flags" => {
617 let raw = node.arg(0)?.ensure_string()?;
618 match raw.parse() {
619 Ok(mode) => schema.unknown_flags = Some(mode),
620 Err(_) => bail_parse!(
621 ctx,
622 node.span(),
623 "unsupported unknown_flags {raw}, expected one of: {}",
624 crate::spec::unknown_flags::UNKNOWN_FLAGS_VALUES
625 ),
626 }
627 }
628 "default_subcommand" => {
629 schema.default_subcommand = Some(node.arg(0)?.ensure_string()?)
630 }
631 "default_subcommand_flags" => {
632 schema.default_subcommand_flags = node.arg(0)?.ensure_bool()?;
633 schema.default_subcommand_flags_set = true;
634 }
635 "multicall" => {
636 schema.multicall = node.arg(0)?.ensure_bool()?;
637 schema.multicall_set = true;
638 }
639 "external_subcommand" => {
640 schema.cmd.external_subcommand = node.arg(0)?.ensure_bool()?;
641 }
642 "arg_required_else_help" => {
643 schema.cmd.arg_required_else_help = node.arg(0)?.ensure_bool()?;
644 }
645 "disable_help_flag" => {
646 schema.cmd.disable_help_flag = node.arg(0)?.ensure_bool()?;
647 }
648 "disable_help_subcommand" => {
649 schema.cmd.disable_help_subcommand = node.arg(0)?.ensure_bool()?;
650 }
651 "disable_version_flag" => {
652 schema.cmd.disable_version_flag = node.arg(0)?.ensure_bool()?;
653 }
654 "dont_delimit_trailing_values" => {
655 schema.cmd.dont_delimit_trailing_values = node.arg(0)?.ensure_bool()?;
656 }
657 "args_override_self" => {
658 schema.cmd.args_override_self = node.arg(0)?.ensure_bool()?;
659 }
660 "subcommand_negates_reqs" => {
661 schema.cmd.subcommand_negates_reqs = node.arg(0)?.ensure_bool()?;
662 }
663 "args_conflicts_with_subcommands" => {
664 schema.cmd.args_conflicts_with_subcommands = node.arg(0)?.ensure_bool()?;
665 }
666 "subcommand_precedence_over_arg" => {
667 schema.cmd.subcommand_precedence_over_arg = node.arg(0)?.ensure_bool()?;
668 }
669 "allow_missing_positional" => {
670 schema.cmd.allow_missing_positional = node.arg(0)?.ensure_bool()?;
671 }
672 "deprecated" => schema.cmd.deprecated = Some(node.arg(0)?.ensure_string()?),
673 "deprecated_warn_at" => {
674 schema.cmd.deprecated_warn_at = Some(node.arg(0)?.ensure_string()?);
675 }
676 "deprecated_remove_at" => {
677 schema.cmd.deprecated_remove_at = Some(node.arg(0)?.ensure_string()?);
678 }
679 "subcommand_required" => {
680 schema.cmd.subcommand_required = node.arg(0)?.ensure_bool()?;
681 }
682 "subcommand_help_heading" => {
683 schema.cmd.subcommand_help_heading = Some(node.arg(0)?.ensure_string()?);
684 }
685 "subcommand_value_name" => {
686 schema.cmd.subcommand_value_name = Some(node.arg(0)?.ensure_string()?);
687 }
688 "next_line_help" => {
689 schema.cmd.next_line_help = node.arg(0)?.ensure_bool()?;
690 }
691 "flatten_help" => {
692 schema.cmd.flatten_help = node.arg(0)?.ensure_bool()?;
693 }
694 "term_width" => {
695 schema.cmd.term_width = Some(node.arg(0)?.ensure_usize()?);
696 }
697 "max_term_width" => {
698 schema.cmd.max_term_width = Some(node.arg(0)?.ensure_usize()?);
699 }
700 "example" => {
701 let code = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
702 let mut example = SpecExample::new(code.trim().to_string());
703 for (k, v) in node.props() {
704 match k {
705 "header" => example.header = Some(v.ensure_string()?),
706 "help" => example.help = Some(v.ensure_string()?),
707 "lang" => example.lang = v.ensure_string()?,
708 k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
709 }
710 }
711 schema.examples.push(example);
712 }
713 "heading" => {
714 let title = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
715 let mut help = None;
716 for (k, v) in node.props() {
717 match k {
718 "help" => help = Some(v.ensure_string()?),
719 k => bail_parse!(ctx, v.entry.span(), "unsupported heading key {k}"),
720 }
721 }
722 let Some(help) = help else {
723 bail_parse!(ctx, node.node.span(), "heading {title} needs help text");
724 };
725 schema.cmd.headings.push(SpecHeading::new(title, help));
726 }
727 "output" => schema.outputs.push(SpecOutput::parse(ctx, &node)?),
728 "exit_code" => schema.exit_codes.push(SpecExitCode::parse(ctx, &node)?),
729 "select" => {
730 schema.select = Some(node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
731 }
732 "include" => {
733 let file = node
734 .props()
735 .get("file")
736 .map(|v| v.ensure_string())
737 .transpose()?
738 .ok_or_else(|| ctx.build_err("missing file".into(), node.span()))?;
739 let file = Path::new(&file);
740 let file = match file.is_relative() {
741 true => ctx
742 .file
743 .parent()
744 .ok_or_else(|| {
745 let msg = if ctx.file.as_os_str().is_empty() {
746 "relative includes require a source file".to_string()
747 } else {
748 format!("cannot get parent of {}", ctx.file.display())
749 };
750 ctx.build_err(msg, node.span())
751 })?
752 .join(file),
753 false => file.to_path_buf(),
754 };
755 info!("include: {}", file.display());
756 let other = Self::parse_file_with_metadata_inference(&file, false, false)?;
757 let clash = other.flagsets.values().find(|incoming| {
768 schema
769 .flagsets
770 .get(&incoming.name)
771 .is_some_and(|own| own.declared_in != incoming.declared_in)
772 });
773 if let Some(incoming) = clash {
774 let name = &incoming.name;
775 let owner = schema.flagsets[name].declared_in.clone();
776 let owner = match owner.as_os_str().is_empty() {
777 true => "this spec".to_string(),
778 false => owner.display().to_string(),
779 };
780 bail_parse!(
781 ctx,
782 node.span(),
783 "a flagset may be declared only once: \"{name}\" is declared in \
784 {} and in {owner}",
785 incoming.declared_in.display()
786 );
787 }
788 schema.merge(other);
789 }
790 k => bail_parse!(ctx, node.node.name().span(), "unsupported spec key {k}"),
791 }
792 }
793 schema.cmd.name = if schema.bin.is_empty() {
794 schema.name.clone()
795 } else {
796 schema.bin.clone()
797 };
798 if let Some(clause) = &schema.cmd.clause {
799 if !schema.cmd.args.is_empty() {
800 bail_parse!(
801 ctx,
802 kdl.span(),
803 "a command cannot declare both top-level arguments and a clause"
804 );
805 }
806 if schema.cmd.restart_token.is_some() {
807 bail_parse!(
808 ctx,
809 kdl.span(),
810 "a command cannot declare both restart_token and a clause"
811 );
812 }
813 if clause.args.iter().any(|arg| arg.sigil.is_some()) {
814 bail_parse!(
815 ctx,
816 kdl.span(),
817 "sigil arguments are not supported inside clauses"
818 );
819 }
820 if let Some(spelling) = clause.conflicting_flag_spelling(&schema.cmd.flags) {
821 bail_parse!(
822 ctx,
823 kdl.span(),
824 "clause flag spelling {spelling:?} conflicts with another flag on this command"
825 );
826 }
827 }
828 flagset::expand(ctx, &mut schema.cmd, &mut schema.flagsets)?;
831 schema
832 .cmd
833 .validate_clause_flag_spellings()
834 .map_err(|message| ctx.build_err(message, (0, ctx.spec.len()).into()))?;
835 if resolve_outputs {
836 output::resolve_selectors(&mut schema)?;
837 }
838 schema
839 .cmd
840 .validate_sigil_prefixes()
841 .map_err(|message| ctx.build_err(message, (0, ctx.spec.len()).into()))?;
842 schema.sources.extend(ctx.sources());
843 set_subcommand_ancestors(&mut schema.cmd, &[]);
844 Ok(schema)
845 }
846
847 pub fn merge(&mut self, other: Spec) {
848 macro_rules! merge_str {
849 ($field:ident) => {
850 if !other.$field.is_empty() {
851 self.$field = other.$field;
852 }
853 };
854 }
855 macro_rules! merge_opt {
856 ($field:ident) => {
857 if other.$field.is_some() {
858 self.$field = other.$field;
859 }
860 };
861 }
862 macro_rules! merge_extend {
863 ($field:ident) => {
864 if !other.$field.is_empty() {
865 self.$field.extend(other.$field);
866 }
867 };
868 }
869
870 merge_str!(name);
871 merge_str!(bin);
872 merge_str!(usage);
873 merge_opt!(about);
874 merge_opt!(source_code_link_template);
875 merge_opt!(repository);
876 merge_opt!(version);
877 merge_opt!(long_version);
878 merge_opt!(author);
879 merge_opt!(about_long);
880 merge_opt!(about_md);
881 merge_opt!(license);
882 merge_opt!(before_help);
883 merge_opt!(after_help);
884 merge_opt!(before_help_long);
885 merge_opt!(after_help_long);
886 merge_opt!(help_template);
887 merge_opt!(disable_help);
888 merge_opt!(min_usage_version);
889 merge_opt!(default_subcommand);
890 if other.default_subcommand_flags_set {
891 self.default_subcommand_flags = other.default_subcommand_flags;
892 self.default_subcommand_flags_set = true;
893 }
894 if other.multicall_set {
895 self.multicall = other.multicall;
896 self.multicall_set = true;
897 }
898 merge_opt!(unknown_flags);
899 merge_extend!(complete);
900 merge_extend!(views);
901 merge_extend!(flagsets);
908 merge_extend!(examples);
909 merge_extend!(outputs);
910 merge_extend!(exit_codes);
911 merge_opt!(select);
912 merge_extend!(sources);
914
915 if !other.config.is_empty() {
916 self.config.merge(&other.config);
917 }
918 self.cmd.merge(other.cmd);
919 }
920}
921
922pub(crate) fn spec_flag_forms_overlap(a: &SpecFlag, b: &SpecFlag) -> bool {
923 fn long_forms(flag: &SpecFlag) -> impl Iterator<Item = &str> {
924 flag.long
925 .iter()
926 .chain(&flag.hidden_aliases)
927 .map(String::as_str)
928 .chain(
929 flag.negate
930 .as_deref()
931 .map(|name| name.strip_prefix("--").unwrap_or(name)),
932 )
933 }
934 fn short_forms(flag: &SpecFlag) -> impl Iterator<Item = &char> {
935 flag.short.iter().chain(&flag.hidden_short_aliases)
936 }
937
938 long_forms(a).any(|name| long_forms(b).any(|other| other == name))
939 || short_forms(a).any(|name| short_forms(b).any(|other| other == name))
940}
941
942fn flag_matches_selector(flag: &SpecFlag, selector: &str) -> bool {
943 selector.strip_prefix("--").is_some_and(|name| {
944 flag.long
945 .iter()
946 .chain(&flag.hidden_aliases)
947 .any(|long| long == name)
948 || flag
949 .negate
950 .as_deref()
951 .is_some_and(|negate| negate.strip_prefix("--").unwrap_or(negate) == name)
952 }) || selector
953 .strip_prefix('-')
954 .filter(|short| short.len() == 1)
955 .and_then(|short| short.chars().next())
956 .is_some_and(|short| {
957 flag.short
958 .iter()
959 .chain(&flag.hidden_short_aliases)
960 .any(|candidate| *candidate == short)
961 })
962}
963
964fn check_usage_version(version: &str) {
965 let cur = semver::Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
966 match parse_usage_version(version) {
967 Ok(v) => {
968 if cur < v {
969 warn!(
970 "This usage spec requires at least version {version}, but you are using version {cur} of usage"
971 );
972 }
973 }
974 Err(_) => warn!("Invalid version: {version}"),
975 }
976}
977
978fn parse_usage_version(version: &str) -> Result<semver::Version, semver::Error> {
984 let core_end = version.find(['-', '+']).unwrap_or(version.len());
985 let (core, suffix) = version.split_at(core_end);
986 let missing = match core.matches('.').count() {
987 0 => ".0.0",
988 1 => ".0",
989 _ => "",
990 };
991
992 semver::Version::parse(&format!("{core}{missing}{suffix}"))
993}
994
995fn read_to_string(file: &Path) -> Result<String, UsageErr> {
1000 std::fs::read_to_string(file).map_err(|err| UsageErr::FileError(err, file.to_path_buf()))
1001}
1002
1003static USAGE_COMMENT: LazyLock<Regex> =
1006 LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])(.*)$").unwrap());
1007static HAS_USAGE_COMMENT: LazyLock<Regex> =
1010 LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])").unwrap());
1011static BLANK_COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(?:#|//|::)\s*$").unwrap());
1013
1014fn split_script(file: &Path) -> Result<String, UsageErr> {
1015 let full = read_to_string(file)?;
1016 if full.starts_with("#!") && full.lines().any(|l| HAS_USAGE_COMMENT.is_match(l)) {
1018 return Ok(extract_usage_from_comments(&full));
1019 }
1020 Ok(full)
1022}
1023
1024fn extract_usage_from_comments(full: &str) -> String {
1025 let mut usage = vec![];
1026 let mut found = false;
1027 for line in full.lines() {
1028 if let Some(captures) = USAGE_COMMENT.captures(line) {
1029 found = true;
1030 let content = captures.get(1).map_or("", |m| m.as_str());
1031 usage.push(content.trim());
1032 } else if found {
1033 if BLANK_COMMENT.is_match(line) {
1035 continue;
1036 }
1037 break;
1039 }
1040 }
1041 usage.join("\n")
1042}
1043
1044fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
1045 restamp_paths(cmd, ancestors, false);
1046}
1047
1048fn restamp_paths(cmd: &mut SpecCommand, ancestors: &[String], force: bool) {
1062 for subcmd in cmd.subcommands.values_mut() {
1063 subcmd.full_cmd = ancestors
1064 .iter()
1065 .cloned()
1066 .chain(once(subcmd.name.clone()))
1067 .collect();
1068 let child_ancestors = subcmd.full_cmd.clone();
1069 restamp_paths(subcmd, &child_ancestors, force);
1070 }
1071 if force {
1072 cmd.reset_subcommand_lookup();
1075 cmd.usage = cmd.usage();
1076 } else if cmd.usage.is_empty() {
1077 cmd.usage = cmd.usage();
1078 }
1079}
1080
1081impl Display for Spec {
1082 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1083 let mut doc = KdlDocument::new();
1084 let nodes = &mut doc.nodes_mut();
1085 if !self.name.is_empty() {
1086 let mut node = KdlNode::new("name");
1087 node.push(string_entry(None, &self.name));
1088 nodes.push(node);
1089 }
1090 if !self.bin.is_empty() {
1091 let mut node = KdlNode::new("bin");
1092 node.push(string_entry(None, &self.bin));
1093 nodes.push(node);
1094 }
1095 if let Some(version) = &self.version {
1096 let mut node = KdlNode::new("version");
1097 node.push(string_entry(None, version));
1098 nodes.push(node);
1099 }
1100 if let Some(version) = &self.long_version {
1101 let mut node = KdlNode::new("long_version");
1102 node.push(string_entry(None, version));
1103 nodes.push(node);
1104 }
1105 if let Some(author) = &self.author {
1106 let mut node = KdlNode::new("author");
1107 node.push(string_entry(None, author));
1108 nodes.push(node);
1109 }
1110 if let Some(about) = &self.about {
1111 let mut node = KdlNode::new("about");
1112 node.push(string_entry(None, about));
1113 nodes.push(node);
1114 }
1115 if let Some(source_code_link_template) = &self.source_code_link_template {
1116 let mut node = KdlNode::new("source_code_link_template");
1117 node.push(string_entry(None, source_code_link_template));
1118 nodes.push(node);
1119 }
1120 if let Some(repository) = &self.repository {
1121 let mut node = KdlNode::new("repository");
1122 node.push(string_entry(None, repository));
1123 nodes.push(node);
1124 }
1125 if let Some(about_md) = &self.about_md {
1126 let mut node = KdlNode::new("about_md");
1127 node.push(string_entry(None, about_md));
1128 nodes.push(node);
1129 }
1130 if let Some(long_about) = &self.about_long {
1131 let mut node = KdlNode::new("long_about");
1132 node.push(string_entry(None, long_about));
1133 nodes.push(node);
1134 }
1135 if let Some(license) = &self.license {
1136 let mut node = KdlNode::new("license");
1137 node.push(string_entry(None, license));
1138 nodes.push(node);
1139 }
1140 if let Some(before_help) = &self.before_help {
1141 let mut node = KdlNode::new("before_help");
1142 node.push(string_entry(None, before_help));
1143 nodes.push(node);
1144 }
1145 if let Some(after_help) = &self.after_help {
1146 let mut node = KdlNode::new("after_help");
1147 node.push(string_entry(None, after_help));
1148 nodes.push(node);
1149 }
1150 if let Some(before_help_long) = &self.before_help_long {
1151 let mut node = KdlNode::new("before_long_help");
1152 node.push(string_entry(None, before_help_long));
1153 nodes.push(node);
1154 }
1155 if let Some(after_help_long) = &self.after_help_long {
1156 let mut node = KdlNode::new("after_long_help");
1157 node.push(string_entry(None, after_help_long));
1158 nodes.push(node);
1159 }
1160 if let Some(help_template) = &self.help_template {
1161 let mut node = KdlNode::new("help_template");
1162 node.push(string_entry(None, help_template));
1163 nodes.push(node);
1164 }
1165 if let Some(disable_help) = self.disable_help {
1166 let mut node = KdlNode::new("disable_help");
1167 node.push(KdlEntry::new(disable_help));
1168 nodes.push(node);
1169 }
1170 if let Some(min_usage_version) = &self.min_usage_version {
1171 let mut node = KdlNode::new("min_usage_version");
1172 node.push(string_entry(None, min_usage_version));
1173 nodes.push(node);
1174 }
1175 if let Some(unknown_flags) = &self.unknown_flags {
1176 let mut node = KdlNode::new("unknown_flags");
1177 node.push(string_entry(None, unknown_flags.as_str()));
1178 nodes.push(node);
1179 }
1180 if let Some(default_subcommand) = &self.default_subcommand {
1181 let mut node = KdlNode::new("default_subcommand");
1182 node.push(string_entry(None, default_subcommand));
1183 nodes.push(node);
1184 }
1185 if self.default_subcommand_flags_set || self.default_subcommand_flags {
1186 let mut node = KdlNode::new("default_subcommand_flags");
1187 node.push(KdlEntry::new(self.default_subcommand_flags));
1188 nodes.push(node);
1189 }
1190 if self.multicall_set {
1191 let mut node = KdlNode::new("multicall");
1192 node.push(KdlEntry::new(self.multicall));
1193 nodes.push(node);
1194 }
1195 if self.cmd.external_subcommand {
1196 let mut node = KdlNode::new("external_subcommand");
1197 node.push(KdlEntry::new(true));
1198 nodes.push(node);
1199 }
1200 if self.cmd.arg_required_else_help {
1201 let mut node = KdlNode::new("arg_required_else_help");
1202 node.push(KdlEntry::new(true));
1203 nodes.push(node);
1204 }
1205 if self.cmd.disable_help_flag {
1206 let mut node = KdlNode::new("disable_help_flag");
1207 node.push(KdlEntry::new(true));
1208 nodes.push(node);
1209 }
1210 if self.cmd.disable_help_subcommand {
1211 let mut node = KdlNode::new("disable_help_subcommand");
1212 node.push(KdlEntry::new(true));
1213 nodes.push(node);
1214 }
1215 if self.cmd.disable_version_flag {
1216 let mut node = KdlNode::new("disable_version_flag");
1217 node.push(KdlEntry::new(true));
1218 nodes.push(node);
1219 }
1220 if self.cmd.dont_delimit_trailing_values {
1221 let mut node = KdlNode::new("dont_delimit_trailing_values");
1222 node.push(true);
1223 nodes.push(node);
1224 }
1225 if !self.cmd.args_override_self {
1226 let mut node = KdlNode::new("args_override_self");
1227 node.push(false);
1228 nodes.push(node);
1229 }
1230 if self.cmd.subcommand_negates_reqs {
1231 let mut node = KdlNode::new("subcommand_negates_reqs");
1232 node.push(true);
1233 nodes.push(node);
1234 }
1235 if self.cmd.args_conflicts_with_subcommands {
1236 let mut node = KdlNode::new("args_conflicts_with_subcommands");
1237 node.push(true);
1238 nodes.push(node);
1239 }
1240 if self.cmd.subcommand_precedence_over_arg {
1241 let mut node = KdlNode::new("subcommand_precedence_over_arg");
1242 node.push(true);
1243 nodes.push(node);
1244 }
1245 if self.cmd.allow_missing_positional {
1246 let mut node = KdlNode::new("allow_missing_positional");
1247 node.push(true);
1248 nodes.push(node);
1249 }
1250 if let Some(message) = &self.cmd.deprecated {
1251 let mut node = KdlNode::new("deprecated");
1252 node.push(string_entry(None, message));
1253 nodes.push(node);
1254 }
1255 if let Some(surface) = &self.cmd.surface {
1256 let mut node = KdlNode::new("surface");
1257 node.push(string_entry(None, surface));
1258 nodes.push(node);
1259 }
1260 if !self.cmd.available_if.is_empty() {
1261 let mut node = KdlNode::new("available_if");
1262 for condition in &self.cmd.available_if {
1263 node.push(string_entry(None, condition));
1264 }
1265 nodes.push(node);
1266 }
1267 if let Some(at) = &self.cmd.deprecated_warn_at {
1268 let mut node = KdlNode::new("deprecated_warn_at");
1269 node.push(string_entry(None, at));
1270 nodes.push(node);
1271 }
1272 if let Some(at) = &self.cmd.deprecated_remove_at {
1273 let mut node = KdlNode::new("deprecated_remove_at");
1274 node.push(string_entry(None, at));
1275 nodes.push(node);
1276 }
1277 if self.cmd.subcommand_required && !self.cmd.subcommands.is_empty() {
1278 let mut node = KdlNode::new("subcommand_required");
1279 node.push(true);
1280 nodes.push(node);
1281 }
1282 if let Some(heading) = &self.cmd.subcommand_help_heading {
1283 let mut node = KdlNode::new("subcommand_help_heading");
1284 node.push(string_entry(None, heading));
1285 nodes.push(node);
1286 }
1287 if let Some(name) = &self.cmd.subcommand_value_name {
1288 let mut node = KdlNode::new("subcommand_value_name");
1289 node.push(string_entry(None, name));
1290 nodes.push(node);
1291 }
1292 if self.cmd.next_line_help {
1293 let mut node = KdlNode::new("next_line_help");
1294 node.push(true);
1295 nodes.push(node);
1296 }
1297 if self.cmd.flatten_help {
1298 let mut node = KdlNode::new("flatten_help");
1299 node.push(true);
1300 nodes.push(node);
1301 }
1302 if let Some(width) = self.cmd.term_width {
1303 let mut node = KdlNode::new("term_width");
1304 node.push(width as i128);
1305 nodes.push(node);
1306 }
1307 if let Some(width) = self.cmd.max_term_width {
1308 let mut node = KdlNode::new("max_term_width");
1309 node.push(width as i128);
1310 nodes.push(node);
1311 }
1312 if !self.usage.is_empty() {
1313 let mut node = KdlNode::new("usage");
1314 node.push(string_entry(None, &self.usage));
1315 nodes.push(node);
1316 }
1317 for flag in self.cmd.flags.iter() {
1318 nodes.push(flag.into());
1319 }
1320 for arg in self.cmd.args.iter() {
1321 nodes.push(arg.into());
1322 }
1323 if let Some(clause) = &self.cmd.clause {
1324 nodes.push(clause.into());
1325 }
1326 for mount in self.cmd.mounts.iter() {
1329 nodes.push(mount.into());
1330 }
1331 for group in self.cmd.groups.iter() {
1332 nodes.push(group.into());
1333 }
1334 for example in self.examples.iter() {
1335 nodes.push(example.into());
1336 }
1337 for heading in self.cmd.headings.iter() {
1338 nodes.push(heading.into());
1339 }
1340 for output in self.outputs.iter() {
1341 nodes.push(output.into());
1342 }
1343 if let Some(select) = &self.select {
1344 let mut node = KdlNode::new("select");
1345 node.push(string_entry(None, select));
1346 nodes.push(node);
1347 }
1348 for exit_code in self.exit_codes.iter() {
1349 nodes.push(exit_code.into());
1350 }
1351 for complete in self.complete.values() {
1352 nodes.push(complete.into());
1353 }
1354 for complete in self.cmd.complete.values() {
1355 nodes.push(complete.into());
1356 }
1357 for view in self.views.values() {
1358 let rendered: KdlDocument = view
1359 .to_string()
1360 .parse()
1361 .expect("a view always renders valid KDL");
1362 nodes.extend(rendered.nodes().iter().cloned());
1363 }
1364 for cmd in self.cmd.subcommands.values() {
1365 nodes.push(cmd.into())
1366 }
1367 if !self.config.is_empty() {
1368 nodes.push((&self.config).into());
1369 }
1370 doc.autoformat_config(&kdl::FormatConfig::default());
1371 write!(f, "{doc}")
1372 }
1373}
1374
1375impl FromStr for Spec {
1376 type Err = UsageErr;
1377
1378 fn from_str(s: &str) -> Result<Self, Self::Err> {
1379 Self::parse(&Default::default(), s)
1380 }
1381}
1382
1383#[cfg(feature = "clap")]
1384impl From<&clap::Command> for Spec {
1385 fn from(cmd: &clap::Command) -> Self {
1386 let mut spec = Spec {
1387 name: cmd.get_name().to_string(),
1388 bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
1389 cmd: cmd.into(),
1390 version: cmd.get_version().map(|v| v.to_string()),
1391 long_version: cmd.get_long_version().map(|v| v.to_string()),
1392 about: cmd.get_about().map(|a| a.to_string()),
1393 about_long: cmd.get_long_about().map(|a| a.to_string()),
1394 usage: cmd.clone().render_usage().to_string(),
1395 unknown_flags: crate::spec::cmd::SpecCommand::from(cmd).unknown_flags,
1398 multicall: cmd.is_multicall_set(),
1399 multicall_set: cmd.is_multicall_set(),
1400 ..Default::default()
1401 };
1402 set_subcommand_ancestors(&mut spec.cmd, &[]);
1407 spec
1408 }
1409}
1410
1411impl From<SpecCommand> for Spec {
1417 fn from(cmd: SpecCommand) -> Self {
1418 let mut spec = Self {
1419 name: cmd.name.clone(),
1420 bin: cmd.name.clone(),
1421 about: cmd.help.clone(),
1422 about_long: cmd.help_long.clone(),
1423 cmd,
1424 ..Self::default()
1425 };
1426 spec.restamp();
1429 spec
1430 }
1431}
1432
1433#[inline]
1434pub fn is_true(b: &bool) -> bool {
1435 *b
1436}
1437
1438#[inline]
1439pub fn is_false(b: &bool) -> bool {
1440 !is_true(b)
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445 use super::*;
1446 use insta::assert_snapshot;
1447
1448 #[test]
1449 fn parses_relaxed_usage_versions_as_semver() {
1450 assert_eq!(
1451 parse_usage_version("6").unwrap(),
1452 semver::Version::new(6, 0, 0)
1453 );
1454 assert_eq!(
1455 parse_usage_version("6.1").unwrap(),
1456 semver::Version::new(6, 1, 0)
1457 );
1458 assert_eq!(
1459 parse_usage_version("6.1.2-beta.1+build").unwrap(),
1460 semver::Version::parse("6.1.2-beta.1+build").unwrap()
1461 );
1462 assert!(parse_usage_version("not-a-version").is_err());
1463 assert!(parse_usage_version("6.1.2.3").is_err());
1464 }
1465
1466 #[test]
1467 fn test_display() {
1468 let spec = Spec::parse(
1469 &Default::default(),
1470 r#"
1471name "Usage CLI"
1472bin "usage"
1473arg "arg1"
1474flag "-f --force" global=#true
1475cmd "config" {
1476 cmd "set" {
1477 arg "key" help="Key to set"
1478 arg "value"
1479 }
1480}
1481complete "file" run="ls" descriptions=#true
1482 "#,
1483 )
1484 .unwrap();
1485 assert_snapshot!(spec, @r#"
1486 name "Usage CLI"
1487 bin usage
1488 flag "-f --force" global=#true
1489 arg <arg1>
1490 complete file run=ls descriptions=#true
1491 cmd config {
1492 cmd set {
1493 arg <key> help="Key to set"
1494 arg <value>
1495 }
1496 }
1497 "#);
1498 }
1499
1500 #[test]
1501 fn test_repository_round_trips() {
1502 let spec = Spec::parse(
1503 &Default::default(),
1504 r#"
1505bin "mise"
1506repository "https://github.com/jdx/mise"
1507source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1508 "#,
1509 )
1510 .unwrap();
1511 assert_eq!(
1512 spec.repository.as_deref(),
1513 Some("https://github.com/jdx/mise")
1514 );
1515 assert_snapshot!(spec, @r#"
1518 name mise
1519 bin mise
1520 source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1521 repository "https://github.com/jdx/mise"
1522 "#);
1523 }
1524
1525 #[test]
1526 fn test_repository_merges_like_the_other_optionals() {
1527 let mut generated = Spec::parse(&Default::default(), r#"bin "mise""#).unwrap();
1530 let extra = Spec::parse(
1531 &Default::default(),
1532 r#"repository "https://github.com/jdx/mise""#,
1533 )
1534 .unwrap();
1535 generated.merge(extra);
1536 assert_eq!(
1537 generated.repository.as_deref(),
1538 Some("https://github.com/jdx/mise")
1539 );
1540 }
1541
1542 #[test]
1543 #[cfg(feature = "clap")]
1544 fn test_clap() {
1545 let cmd = clap::Command::new("test");
1546 assert_snapshot!(Spec::from(&cmd), @r#"
1547 name test
1548 bin test
1549 unknown_flags error
1550 args_override_self #false
1551 usage "Usage: test"
1552 "#);
1553 }
1554
1555 #[test]
1556 #[cfg(feature = "clap")]
1557 fn a_clap_subcommand_knows_where_it_sits() {
1558 let cmd = clap::Command::new("ex").subcommand(
1563 clap::Command::new("go")
1564 .about("Go somewhere")
1565 .subcommand(clap::Command::new("fast").about("Quickly")),
1566 );
1567 let spec = Spec::from(&cmd);
1568
1569 let go = spec.cmd.subcommands.get("go").expect("go");
1570 assert_eq!(go.full_cmd, ["go"]);
1571 assert_eq!(go.usage, "go [SUBCOMMAND]");
1574
1575 let fast = go.subcommands.get("fast").expect("fast");
1577 assert_eq!(fast.full_cmd, ["go", "fast"]);
1578 assert_eq!(fast.usage, "go fast");
1579 }
1580
1581 #[test]
1582 #[cfg(feature = "clap")]
1583 fn a_delimited_default_becomes_the_values_clap_would_split_it_into() {
1584 let cmd = clap::Command::new("test").arg(
1592 clap::Arg::new("events")
1593 .long("events")
1594 .value_delimiter(',')
1595 .action(clap::ArgAction::Append)
1596 .value_parser(["a", "b", "c"])
1597 .default_value("a,b"),
1598 );
1599 let spec = Spec::from(&cmd);
1600 let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1601 assert_eq!(flag.default, ["a", "b"]);
1602
1603 let cmd = clap::Command::new("test")
1606 .arg(clap::Arg::new("events").long("events").default_value("a,b"));
1607 let spec = Spec::from(&cmd);
1608 let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1609 assert_eq!(flag.default, ["a,b"]);
1610 }
1611
1612 #[test]
1613 fn multicall_round_trips() {
1614 let spec = Spec::parse(
1615 &Default::default(),
1616 r#"
1617name "busybox"
1618bin "busybox"
1619multicall #true
1620cmd "ls"
1621cmd "cat"
1622 "#,
1623 )
1624 .unwrap();
1625 assert!(spec.multicall);
1626 let emitted = spec.to_string();
1627 assert!(
1628 emitted.contains("multicall #true"),
1629 "lost on the way out: {emitted}"
1630 );
1631 let again: Spec = emitted.parse().unwrap();
1632 assert!(again.multicall);
1633 }
1634
1635 #[test]
1636 fn a_declared_view_promotes_a_command_and_selected_globals() {
1637 let spec: Spec = r#"
1638name "aube"
1639bin "aube"
1640about_md "Host **markdown**"
1641before_help "host before"
1642before_long_help "host long before"
1643after_help "host after"
1644after_long_help "host long after"
1645example "aube host" header="host example"
1646flag "-v --verbose" global=#true
1647flag "--config <FILE>" global=#true
1648view "aubr" root="run" {
1649 global "--verbose"
1650}
1651cmd "run" help="Run a package script" {
1652 before_help "run before"
1653 before_long_help "run long before"
1654 after_help "run after"
1655 after_long_help "run long after"
1656 example "aubr task" header="run example"
1657 flag "--if-present"
1658 arg "[SCRIPT]"
1659 cmd "nested"
1660}
1661"#
1662 .parse()
1663 .unwrap();
1664
1665 let rendered = spec.to_string();
1666 assert!(rendered.contains("view aubr root=run"), "{rendered}");
1667 let reparsed: Spec = rendered.parse().unwrap();
1668 let applet = reparsed.for_view("aubr").unwrap();
1669 assert_eq!(applet.name, "aubr");
1670 assert_eq!(applet.bin, "aubr");
1671 assert_eq!(applet.about.as_deref(), Some("Run a package script"));
1672 assert_eq!(applet.about_md, None);
1673 assert_eq!(applet.before_help.as_deref(), Some("run before"));
1674 assert_eq!(applet.before_help_long.as_deref(), Some("run long before"));
1675 assert_eq!(applet.after_help.as_deref(), Some("run after"));
1676 assert_eq!(applet.after_help_long.as_deref(), Some("run long after"));
1677 assert_eq!(applet.examples.len(), 1);
1678 assert_eq!(applet.examples[0].header.as_deref(), Some("run example"));
1679 assert!(applet.cmd.flags.iter().any(|flag| flag.name == "verbose"));
1680 assert!(applet
1681 .cmd
1682 .flags
1683 .iter()
1684 .any(|flag| flag.name == "if-present"));
1685 assert!(!applet.cmd.flags.iter().any(|flag| flag.name == "config"));
1686 assert!(applet.cmd.subcommands.contains_key("nested"));
1687 assert!(applet.views.is_empty());
1688 assert!(applet.to_string().contains("bin aubr"));
1689 }
1690
1691 #[test]
1692 fn a_view_preserves_the_host_version_entry_policy() {
1693 let spec: Spec = r#"
1694bin "host"
1695version "1.2.3"
1696disable_version_flag #true
1697view "runner" root=run
1698cmd "run"
1699"#
1700 .parse()
1701 .unwrap();
1702
1703 let view = spec.for_view("runner").unwrap();
1704 assert!(view.cmd.disable_version_flag);
1705 let emitted = view.to_string();
1706 assert!(emitted.contains("disable_version_flag #true"), "{emitted}");
1707 let reparsed: Spec = emitted.parse().unwrap();
1708 assert!(reparsed.cmd.disable_version_flag);
1709 }
1710
1711 #[test]
1712 fn a_promoted_flag_shadows_every_carried_global_spelling() {
1713 let spec: Spec = r#"
1714bin "host"
1715flag "--color" global=#true negate="--no-color"
1716view "runner" root=run {
1717 global "--color"
1718}
1719cmd "run" {
1720 flag "--no-color"
1721}
1722"#
1723 .parse()
1724 .unwrap();
1725
1726 let view = spec.for_view("runner").unwrap();
1727 assert_eq!(view.cmd.flags.len(), 1);
1728 assert_eq!(view.cmd.flags[0].long, ["no-color"]);
1729 }
1730
1731 #[test]
1732 fn a_view_keeps_only_its_own_and_carried_global_completers() {
1733 let spec: Spec = r#"
1734bin "host"
1735flag "--host <HOST>" global=#true
1736flag "--carried <CARRIED>" global=#true
1737complete "host" run="host candidates"
1738complete "carried" run="carried candidates"
1739view "runner" root=run {
1740 global "--carried"
1741}
1742cmd "run" {
1743 arg "<HOST>"
1744 complete "host" run="view candidates"
1745}
1746"#
1747 .parse()
1748 .unwrap();
1749
1750 let view = spec.for_view("runner").unwrap();
1751 assert_eq!(
1752 view.complete.get("host").unwrap().run.as_deref(),
1753 Some("view candidates")
1754 );
1755 assert_eq!(
1756 view.complete.get("carried").unwrap().run.as_deref(),
1757 Some("carried candidates")
1758 );
1759 assert!(view.cmd.complete.is_empty());
1760 assert_eq!(view.complete.len(), 2);
1761 assert_eq!(view.to_string().matches("complete ").count(), 2);
1762 }
1763
1764 #[test]
1765 fn a_view_projects_groups_of_carried_globals() {
1766 let spec: Spec = r#"
1767bin "host"
1768flag "--json" global=#true
1769flag "--yaml" global=#true
1770flag "--toml" global=#true
1771group "format" "--json" "--yaml" "--toml" required=#true
1772view "all" root=run globals=#true
1773view "json" root=run {
1774 global "--json"
1775}
1776cmd "run"
1777"#
1778 .parse()
1779 .unwrap();
1780
1781 let all = spec.for_view("all").unwrap();
1782 assert_eq!(all.cmd.groups.len(), 1);
1783 assert_eq!(all.cmd.groups[0].members, ["--json", "--yaml", "--toml"]);
1784 assert!(all.to_string().parse::<Spec>().is_ok());
1785
1786 let json = spec.for_view("json").unwrap();
1787 assert!(json.cmd.groups.is_empty());
1788 assert!(
1789 json.cmd
1790 .flags
1791 .iter()
1792 .find(|flag| flag.name == "json")
1793 .unwrap()
1794 .required
1795 );
1796 assert!(json.to_string().parse::<Spec>().is_ok());
1797 }
1798
1799 #[test]
1800 fn a_view_refuses_unknown_commands_and_non_global_carryovers() {
1801 let missing: Spec = "bin \"ex\"\nview \"x\" root=missing\n".parse().unwrap();
1802 assert!(missing
1803 .for_view("x")
1804 .unwrap_err()
1805 .to_string()
1806 .contains("missing"));
1807
1808 let local: Spec =
1809 "bin \"ex\"\nflag \"--local\"\nview \"x\" root=go { global \"--local\" }\ncmd go\n"
1810 .parse()
1811 .unwrap();
1812 assert!(local
1813 .for_view("x")
1814 .unwrap_err()
1815 .to_string()
1816 .contains("not a root global"));
1817 }
1818
1819 #[test]
1820 fn an_included_spec_can_enable_or_disable_multicall() {
1821 let dir = tempfile::tempdir().unwrap();
1822 let included = dir.path().join("included.usage.kdl");
1823 let root = dir.path().join("root.usage.kdl");
1824
1825 std::fs::write(&included, "multicall #false\n").unwrap();
1826 std::fs::write(
1827 &root,
1828 "multicall #true\ninclude file=\"./included.usage.kdl\"\n",
1829 )
1830 .unwrap();
1831 let spec = Spec::parse_file(&root).unwrap();
1832 assert!(!spec.multicall);
1833 assert!(spec.to_string().contains("multicall #false"));
1834
1835 std::fs::write(&included, "multicall #true\n").unwrap();
1836 std::fs::write(
1837 &root,
1838 "multicall #false\ninclude file=\"./included.usage.kdl\"\n",
1839 )
1840 .unwrap();
1841 let spec = Spec::parse_file(&root).unwrap();
1842 assert!(spec.multicall);
1843 assert!(spec.to_string().contains("multicall #true"));
1844 }
1845
1846 #[test]
1847 #[cfg(feature = "clap")]
1848 fn multicall_comes_across_from_clap() {
1849 let cmd = clap::Command::new("busybox")
1850 .multicall(true)
1851 .subcommand(clap::Command::new("ls"))
1852 .subcommand(clap::Command::new("cat"));
1853 let spec = Spec::from(&cmd);
1854 assert!(spec.multicall);
1855 assert!(
1856 spec.to_string().contains("multicall #true"),
1857 "{}",
1858 spec.to_string()
1859 );
1860
1861 let plain = clap::Command::new("ex").subcommand(clap::Command::new("ls"));
1862 assert!(!Spec::from(&plain).multicall);
1863 }
1864
1865 macro_rules! extract_usage_tests {
1866 ($($name:ident: $input:expr, $expected:expr,)*) => {
1867 $(
1868 #[test]
1869 fn $name() {
1870 let result = extract_usage_from_comments($input);
1871 let expected = $expected.trim_start_matches('\n').trim_end();
1872 assert_eq!(result, expected);
1873 }
1874 )*
1875 }
1876 }
1877
1878 extract_usage_tests! {
1879 test_extract_usage_from_comments_original_hash:
1880 r#"
1881#!/bin/bash
1882#USAGE bin "test"
1883#USAGE flag "--foo" help="test"
1884echo "hello"
1885 "#,
1886 r#"
1887bin "test"
1888flag "--foo" help="test"
1889 "#,
1890
1891 test_extract_usage_from_comments_original_double_slash:
1892 r#"
1893#!/usr/bin/env node
1894//USAGE bin "test"
1895//USAGE flag "--foo" help="test"
1896console.log("hello");
1897 "#,
1898 r#"
1899bin "test"
1900flag "--foo" help="test"
1901 "#,
1902
1903 test_extract_usage_from_comments_bracket_with_space:
1904 r#"
1905#!/bin/bash
1906# [USAGE] bin "test"
1907# [USAGE] flag "--foo" help="test"
1908echo "hello"
1909 "#,
1910 r#"
1911bin "test"
1912flag "--foo" help="test"
1913 "#,
1914
1915 test_extract_usage_from_comments_bracket_no_space:
1916 r#"
1917#!/bin/bash
1918#[USAGE] bin "test"
1919#[USAGE] flag "--foo" help="test"
1920echo "hello"
1921 "#,
1922 r#"
1923bin "test"
1924flag "--foo" help="test"
1925 "#,
1926
1927 test_extract_usage_from_comments_double_slash_bracket_with_space:
1928 r#"
1929#!/usr/bin/env node
1930// [USAGE] bin "test"
1931// [USAGE] flag "--foo" help="test"
1932console.log("hello");
1933 "#,
1934 r#"
1935bin "test"
1936flag "--foo" help="test"
1937 "#,
1938
1939 test_extract_usage_from_comments_double_slash_bracket_no_space:
1940 r#"
1941#!/usr/bin/env node
1942//[USAGE] bin "test"
1943//[USAGE] flag "--foo" help="test"
1944console.log("hello");
1945 "#,
1946 r#"
1947bin "test"
1948flag "--foo" help="test"
1949 "#,
1950
1951 test_extract_usage_from_comments_stops_at_gap:
1952 r#"
1953#!/bin/bash
1954#USAGE bin "test"
1955#USAGE flag "--foo" help="test"
1956
1957#USAGE flag "--bar" help="should not be included"
1958echo "hello"
1959 "#,
1960 r#"
1961bin "test"
1962flag "--foo" help="test"
1963 "#,
1964
1965 test_extract_usage_from_comments_with_content_after_marker:
1966 r#"
1967#!/bin/bash
1968# [USAGE] bin "test"
1969# [USAGE] flag "--verbose" help="verbose mode"
1970# [USAGE] arg "input" help="input file"
1971echo "hello"
1972 "#,
1973 r#"
1974bin "test"
1975flag "--verbose" help="verbose mode"
1976arg "input" help="input file"
1977 "#,
1978
1979 test_extract_usage_from_comments_double_colon_original:
1980 r#"
1981::USAGE bin "test"
1982::USAGE flag "--foo" help="test"
1983echo "hello"
1984 "#,
1985 r#"
1986bin "test"
1987flag "--foo" help="test"
1988 "#,
1989
1990 test_extract_usage_from_comments_double_colon_bracket_with_space:
1991 r#"
1992:: [USAGE] bin "test"
1993:: [USAGE] flag "--foo" help="test"
1994echo "hello"
1995 "#,
1996 r#"
1997bin "test"
1998flag "--foo" help="test"
1999 "#,
2000
2001 test_extract_usage_from_comments_double_colon_bracket_no_space:
2002 r#"
2003::[USAGE] bin "test"
2004::[USAGE] flag "--foo" help="test"
2005echo "hello"
2006 "#,
2007 r#"
2008bin "test"
2009flag "--foo" help="test"
2010 "#,
2011
2012 test_extract_usage_from_comments_double_colon_stops_at_gap:
2013 r#"
2014::USAGE bin "test"
2015::USAGE flag "--foo" help="test"
2016
2017::USAGE flag "--bar" help="should not be included"
2018echo "hello"
2019 "#,
2020 r#"
2021bin "test"
2022flag "--foo" help="test"
2023 "#,
2024
2025 test_extract_usage_from_comments_double_colon_with_content_after_marker:
2026 r#"
2027::USAGE bin "test"
2028::USAGE flag "--verbose" help="verbose mode"
2029::USAGE arg "input" help="input file"
2030echo "hello"
2031 "#,
2032 r#"
2033bin "test"
2034flag "--verbose" help="verbose mode"
2035arg "input" help="input file"
2036 "#,
2037
2038 test_extract_usage_from_comments_double_colon_bracket_with_space_multiple_lines:
2039 r#"
2040:: [USAGE] bin "myapp"
2041:: [USAGE] flag "--config <file>" help="config file"
2042:: [USAGE] flag "--verbose" help="verbose output"
2043:: [USAGE] arg "input" help="input file"
2044:: [USAGE] arg "[output]" help="output file" required=#false
2045echo "done"
2046 "#,
2047 r#"
2048bin "myapp"
2049flag "--config <file>" help="config file"
2050flag "--verbose" help="verbose output"
2051arg "input" help="input file"
2052arg "[output]" help="output file" required=#false
2053 "#,
2054
2055 test_extract_usage_from_comments_empty:
2056 r#"
2057#!/bin/bash
2058echo "hello"
2059 "#,
2060 "",
2061
2062 test_extract_usage_from_comments_lowercase_usage:
2063 r#"
2064#!/bin/bash
2065#usage bin "test"
2066#usage flag "--foo" help="test"
2067echo "hello"
2068 "#,
2069 "",
2070
2071 test_extract_usage_from_comments_mixed_case_usage:
2072 r#"
2073#!/bin/bash
2074#Usage bin "test"
2075#Usage flag "--foo" help="test"
2076echo "hello"
2077 "#,
2078 "",
2079
2080 test_extract_usage_from_comments_space_before_usage:
2081 r#"
2082#!/bin/bash
2083# USAGE bin "test"
2084# USAGE flag "--foo" help="test"
2085echo "hello"
2086 "#,
2087 "",
2088
2089 test_extract_usage_from_comments_double_slash_lowercase:
2090 r#"
2091#!/usr/bin/env node
2092//usage bin "test"
2093//usage flag "--foo" help="test"
2094console.log("hello");
2095 "#,
2096 "",
2097
2098 test_extract_usage_from_comments_double_slash_mixed_case:
2099 r#"
2100#!/usr/bin/env node
2101//Usage bin "test"
2102//Usage flag "--foo" help="test"
2103console.log("hello");
2104 "#,
2105 "",
2106
2107 test_extract_usage_from_comments_double_slash_space_before_usage:
2108 r#"
2109#!/usr/bin/env node
2110// USAGE bin "test"
2111// USAGE flag "--foo" help="test"
2112console.log("hello");
2113 "#,
2114 "",
2115
2116 test_extract_usage_from_comments_bracket_lowercase:
2117 r#"
2118#!/bin/bash
2119#[usage] bin "test"
2120#[usage] flag "--foo" help="test"
2121echo "hello"
2122 "#,
2123 "",
2124
2125 test_extract_usage_from_comments_bracket_mixed_case:
2126 r#"
2127#!/bin/bash
2128#[Usage] bin "test"
2129#[Usage] flag "--foo" help="test"
2130echo "hello"
2131 "#,
2132 "",
2133
2134 test_extract_usage_from_comments_bracket_space_lowercase:
2135 r#"
2136#!/bin/bash
2137# [usage] bin "test"
2138# [usage] flag "--foo" help="test"
2139echo "hello"
2140 "#,
2141 "",
2142
2143 test_extract_usage_from_comments_double_colon_lowercase:
2144 r#"
2145::usage bin "test"
2146::usage flag "--foo" help="test"
2147echo "hello"
2148 "#,
2149 "",
2150
2151 test_extract_usage_from_comments_double_colon_mixed_case:
2152 r#"
2153::Usage bin "test"
2154::Usage flag "--foo" help="test"
2155echo "hello"
2156 "#,
2157 "",
2158
2159 test_extract_usage_from_comments_double_colon_space_before_usage:
2160 r#"
2161:: USAGE bin "test"
2162:: USAGE flag "--foo" help="test"
2163echo "hello"
2164 "#,
2165 "",
2166
2167 test_extract_usage_from_comments_double_colon_bracket_lowercase:
2168 r#"
2169::[usage] bin "test"
2170::[usage] flag "--foo" help="test"
2171echo "hello"
2172 "#,
2173 "",
2174
2175 test_extract_usage_from_comments_double_colon_bracket_mixed_case:
2176 r#"
2177::[Usage] bin "test"
2178::[Usage] flag "--foo" help="test"
2179echo "hello"
2180 "#,
2181 "",
2182
2183 test_extract_usage_from_comments_double_colon_bracket_space_lowercase:
2184 r#"
2185:: [usage] bin "test"
2186:: [usage] flag "--foo" help="test"
2187echo "hello"
2188 "#,
2189 "",
2190 }
2191
2192 #[test]
2193 fn test_spec_with_examples() {
2194 let spec = Spec::parse(
2195 &Default::default(),
2196 r#"
2197name "demo"
2198bin "demo"
2199example "demo --help" header="Getting help" help="Display help information"
2200example "demo --version" header="Check version"
2201 "#,
2202 )
2203 .unwrap();
2204
2205 assert_eq!(spec.examples.len(), 2);
2206
2207 assert_eq!(spec.examples[0].code, "demo --help");
2208 assert_eq!(spec.examples[0].header, Some("Getting help".to_string()));
2209 assert_eq!(
2210 spec.examples[0].help,
2211 Some("Display help information".to_string())
2212 );
2213
2214 assert_eq!(spec.examples[1].code, "demo --version");
2215 assert_eq!(spec.examples[1].header, Some("Check version".to_string()));
2216 assert_eq!(spec.examples[1].help, None);
2217 }
2218
2219 #[test]
2220 fn test_spec_examples_display() {
2221 let spec = Spec::parse(
2222 &Default::default(),
2223 r#"
2224name "demo"
2225bin "demo"
2226example "demo --help" header="Getting help" help="Show help"
2227example "demo --version"
2228 "#,
2229 )
2230 .unwrap();
2231
2232 let output = format!("{}", spec);
2233 assert!(
2234 output.contains("example \"demo --help\" header=\"Getting help\" help=\"Show help\"")
2235 );
2236 assert!(output.contains("example \"demo --version\""));
2237 }
2238
2239 #[test]
2240 fn test_parse_script_str() {
2241 let spec = Spec::parse_script_str(
2242 r#"
2243#!/bin/bash
2244#USAGE bin "test"
2245#USAGE flag "--foo" help="test"
2246echo "hello"
2247 "#,
2248 )
2249 .unwrap();
2250
2251 assert_eq!(spec.bin, "test");
2252 assert_eq!(spec.name, "test");
2253 assert_eq!(spec.cmd.flags.len(), 1);
2254 assert_eq!(spec.cmd.flags[0].long, ["foo"]);
2255 }
2256
2257 #[test]
2258 fn test_parse_script_str_rejects_relative_includes() {
2259 let err = Spec::parse_script_str(r#"#USAGE include file="relative.usage.kdl""#)
2260 .expect_err("relative includes need a source path");
2261
2262 match err {
2263 UsageErr::InvalidInput(msg, _, _) => {
2264 assert_eq!(msg, "relative includes require a source file");
2265 }
2266 err => panic!("unexpected error: {err:?}"),
2267 }
2268 }
2269
2270 #[test]
2271 fn test_include_does_not_infer_metadata_from_included_filename() {
2272 let dir = tempfile::tempdir().unwrap();
2273 let included = dir.path().join("overrides.usage.kdl");
2274 let root = dir.path().join("my-script.usage.kdl");
2275 std::fs::write(&included, "").unwrap();
2276 std::fs::write(&root, "include file=\"./overrides.usage.kdl\"\n").unwrap();
2277
2278 let spec = Spec::parse_file(&root).unwrap();
2279
2280 assert_eq!(spec.name, "my-script.usage.kdl");
2281 assert_eq!(spec.bin, "my-script.usage.kdl");
2282 assert!(spec.cmd.name.is_empty());
2283 }
2284
2285 #[test]
2286 fn resolving_mounts_replaces_the_unresolved_synopsis() {
2287 let mut spec: Spec = "bin ex\nmount run=tasks synopsis=\"[TASK] [ARGS]…\""
2288 .parse()
2289 .unwrap();
2290 assert!(spec.cmd.usage.contains("[TASK] [ARGS]…"));
2291 spec.resolve_mount_outputs(&HashMap::from([(
2292 "tasks".to_string(),
2293 "cmd build".to_string(),
2294 )]))
2295 .unwrap();
2296 assert!(spec.cmd.mounts.is_empty());
2297 assert_eq!(spec.cmd.usage, "[SUBCOMMAND]");
2298 assert_eq!(spec.cmd.subcommands["build"].usage, "build");
2299 let json = serde_json::to_value(&spec).unwrap();
2300 assert_eq!(json["cmd"]["usage"], "[SUBCOMMAND]");
2301 }
2302
2303 #[test]
2304 fn injected_nested_mounts_ignore_the_mounted_specs_root_default() {
2305 let mut spec: Spec = "mount run=outer".parse().unwrap();
2306 let outputs = HashMap::from([
2307 (
2308 "outer".to_string(),
2309 "default_subcommand run\nmount run=nested\ncmd run".to_string(),
2310 ),
2311 ("nested".to_string(), "cmd leaf".to_string()),
2312 ]);
2313
2314 spec.resolve_mount_outputs(&outputs).unwrap();
2315
2316 assert!(spec.cmd.subcommands.contains_key("leaf"));
2317 }
2318
2319 #[test]
2320 fn restamping_a_grafted_tree_matches_writing_it_out_and_reading_it_back() {
2321 let host: Spec =
2325 "name \"host\"\nbin \"host\"\ncmd \"plugins\" {\n external_subcommand #true\n}\n"
2326 .parse()
2327 .unwrap();
2328 let plugin: Spec =
2329 "name \"formatter\"\nbin \"formatter\"\narg \"[path]\"\ncmd \"check\" {\n flag \"--fix\"\n}\n"
2330 .parse()
2331 .unwrap();
2332
2333 let mut grafted = host.clone();
2334 grafted
2335 .cmd
2336 .subcommands
2337 .get_mut("plugins")
2338 .unwrap()
2339 .subcommands
2340 .insert("formatter".to_string(), plugin.cmd.clone());
2341 grafted.restamp();
2342
2343 let reparsed: Spec = grafted.to_string().parse().unwrap();
2344 let path = |spec: &Spec, words: &[&str]| {
2345 let mut cmd = &spec.cmd;
2346 for word in words {
2347 cmd = cmd.find_subcommand(word).unwrap();
2348 }
2349 (cmd.full_cmd.clone(), cmd.usage.clone())
2350 };
2351 for words in [
2352 &[][..],
2353 &["plugins"],
2354 &["plugins", "formatter"],
2355 &["plugins", "formatter", "check"],
2356 ] {
2357 assert_eq!(
2358 path(&grafted, words),
2359 path(&reparsed, words),
2360 "at {words:?}"
2361 );
2362 }
2363
2364 assert_eq!(
2367 path(&grafted, &["plugins", "formatter"]).0,
2368 ["plugins", "formatter"]
2369 );
2370 assert!(
2371 path(&grafted, &["plugins"]).1.contains("[SUBCOMMAND]"),
2372 "{:?}",
2373 path(&grafted, &["plugins"]).1
2374 );
2375 assert!(grafted
2377 .cmd
2378 .find_subcommand("plugins")
2379 .unwrap()
2380 .find_subcommand("formatter")
2381 .is_some());
2382 }
2383}