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