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