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