1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4use crate::error::UsageErr;
5use crate::sh::sh;
6use crate::spec::builder::SpecCommandBuilder;
7use crate::spec::context::ParsingContext;
8use crate::spec::effect::{SpecCommandEffect, EFFECT_VALUES};
9use crate::spec::helpers::{string_entry, NodeHelper};
10use crate::spec::is_false;
11use crate::spec::mount::SpecMount;
12use crate::{Spec, SpecArg, SpecComplete, SpecFlag};
13use indexmap::IndexMap;
14use itertools::Itertools;
15use kdl::{KdlDocument, KdlEntry, KdlNode};
16use serde::Serialize;
17
18#[derive(Debug, Serialize, Clone)]
37pub struct SpecCommand {
38 pub full_cmd: Vec<String>,
40 pub usage: String,
42 pub subcommands: IndexMap<String, SpecCommand>,
44 pub args: Vec<SpecArg>,
46 pub flags: Vec<SpecFlag>,
48 pub mounts: Vec<SpecMount>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub deprecated: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
56 pub effect: Option<SpecCommandEffect>,
57 pub hide: bool,
59 #[serde(skip)]
69 pub mounted: bool,
70 #[serde(skip)]
77 pub flags_from_mount: bool,
78 #[serde(skip_serializing_if = "is_false")]
80 pub subcommand_required: bool,
81 #[serde(skip_serializing_if = "Option::is_none")]
84 pub restart_token: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub help: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub help_long: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub help_md: Option<String>,
94 pub name: String,
96 pub aliases: Vec<String>,
98 pub hidden_aliases: Vec<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub before_help: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub before_help_long: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub before_help_md: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub after_help: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub after_help_long: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub after_help_md: Option<String>,
118 pub examples: Vec<SpecExample>,
120 #[serde(skip_serializing_if = "IndexMap::is_empty")]
122 pub complete: IndexMap<String, SpecComplete>,
123
124 #[serde(skip)]
129 pub(crate) subcommand_lookup: OnceLock<HashMap<String, String>>,
130}
131
132impl Default for SpecCommand {
133 fn default() -> Self {
134 Self {
135 full_cmd: vec![],
136 usage: "".to_string(),
137 subcommands: IndexMap::new(),
138 args: vec![],
139 flags: vec![],
140 mounts: vec![],
141 deprecated: None,
142 effect: None,
143 hide: false,
144 mounted: false,
145 flags_from_mount: false,
146 subcommand_required: false,
147 restart_token: None,
148 help: None,
149 help_long: None,
150 help_md: None,
151 name: "".to_string(),
152 aliases: vec![],
153 hidden_aliases: vec![],
154 before_help: None,
155 before_help_long: None,
156 before_help_md: None,
157 after_help: None,
158 after_help_long: None,
159 after_help_md: None,
160 examples: vec![],
161 subcommand_lookup: OnceLock::new(),
162 complete: IndexMap::new(),
163 }
164 }
165}
166
167#[derive(Debug, Default, Serialize, Clone)]
168#[non_exhaustive]
169pub struct SpecExample {
170 pub code: String,
171 pub header: Option<String>,
172 pub help: Option<String>,
173 pub lang: String,
174}
175
176impl SpecExample {
177 pub fn new(code: impl Into<String>) -> Self {
179 Self {
180 code: code.into(),
181 ..Default::default()
182 }
183 }
184
185 pub fn header(mut self, header: impl Into<String>) -> Self {
187 self.header = Some(header.into());
188 self
189 }
190
191 pub fn help(mut self, help: impl Into<String>) -> Self {
193 self.help = Some(help.into());
194 self
195 }
196
197 pub fn lang(mut self, lang: impl Into<String>) -> Self {
199 self.lang = lang.into();
200 self
201 }
202}
203
204impl From<&SpecExample> for KdlNode {
205 fn from(example: &SpecExample) -> KdlNode {
206 let mut node = KdlNode::new("example");
207 node.push(string_entry(None, &example.code));
208 if let Some(header) = &example.header {
209 node.push(string_entry(Some("header"), header));
210 }
211 if let Some(help) = &example.help {
212 node.push(string_entry(Some("help"), help));
213 }
214 if !example.lang.is_empty() {
215 node.push(string_entry(Some("lang"), &example.lang));
216 }
217 node
218 }
219}
220
221impl SpecCommand {
222 pub fn builder() -> SpecCommandBuilder {
224 SpecCommandBuilder::new()
225 }
226
227 pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
228 node.ensure_arg_len(1..=1)?;
229 let mut cmd = Self {
230 name: node.arg(0)?.ensure_string()?.to_string(),
231 ..Default::default()
232 };
233 for (k, v) in node.props() {
234 match k {
235 "help" => cmd.help = Some(v.ensure_string()?),
236 "long_help" => cmd.help_long = Some(v.ensure_string()?),
237 "help_long" => cmd.help_long = Some(v.ensure_string()?),
238 "help_md" => cmd.help_md = Some(v.ensure_string()?),
239 "before_help" => cmd.before_help = Some(v.ensure_string()?),
240 "before_long_help" => cmd.before_help_long = Some(v.ensure_string()?),
241 "before_help_long" => cmd.before_help_long = Some(v.ensure_string()?),
242 "before_help_md" => cmd.before_help_md = Some(v.ensure_string()?),
243 "after_help" => cmd.after_help = Some(v.ensure_string()?),
244 "after_long_help" => {
245 cmd.after_help_long = Some(v.ensure_string()?);
246 }
247 "after_help_long" => {
248 cmd.after_help_long = Some(v.ensure_string()?);
249 }
250 "after_help_md" => cmd.after_help_md = Some(v.ensure_string()?),
251 "subcommand_required" => cmd.subcommand_required = v.ensure_bool()?,
252 "hide" => cmd.hide = v.ensure_bool()?,
253 "effect" => {
254 let raw = v.ensure_string()?;
255 match raw.parse() {
256 Ok(effect) => cmd.effect = Some(effect),
257 Err(_) => bail_parse!(
258 ctx,
259 v.entry.span(),
260 "unsupported effect {raw}, expected one of: {EFFECT_VALUES}"
261 ),
262 }
263 }
264 "restart_token" => cmd.restart_token = Some(v.ensure_string()?),
265 "deprecated" => {
266 cmd.deprecated = match v.value.as_bool() {
267 Some(true) => Some("deprecated".to_string()),
268 Some(false) => None,
269 None => Some(v.ensure_string()?),
270 }
271 }
272 k => bail_parse!(ctx, v.entry.span(), "unsupported cmd prop {k}"),
273 }
274 }
275 for child in node.children() {
276 match child.name() {
277 "flag" => cmd.flags.push(SpecFlag::parse(ctx, &child)?),
278 "arg" => cmd.args.push(SpecArg::parse(ctx, &child)?),
279 "mount" => cmd.mounts.push(SpecMount::parse(ctx, &child)?),
280 "cmd" => {
281 let node = SpecCommand::parse(ctx, &child)?;
282 cmd.subcommands.insert(node.name.to_string(), node);
283 }
284 "alias" => {
285 let alias = child
286 .ensure_arg_len(1..)?
287 .args()
288 .map(|e| e.ensure_string())
289 .collect::<Result<Vec<_>, _>>()?;
290 let hide = child
291 .get("hide")
292 .map(|n| n.ensure_bool())
293 .unwrap_or(Ok(false))?;
294 if hide {
295 cmd.hidden_aliases.extend(alias);
296 } else {
297 cmd.aliases.extend(alias);
298 }
299 }
300 "example" => {
301 let code = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
302 let mut example = SpecExample::new(code.trim().to_string());
303 for (k, v) in child.props() {
304 match k {
305 "header" => example.header = Some(v.ensure_string()?),
306 "help" => example.help = Some(v.ensure_string()?),
307 "lang" => example.lang = v.ensure_string()?,
308 k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
309 }
310 }
311 cmd.examples.push(example);
312 }
313 "help" => {
314 cmd.help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
315 }
316 "long_help" => {
317 cmd.help_long = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
318 }
319 "help_md" => {
320 cmd.help_md = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
321 }
322 "before_help" => {
323 cmd.before_help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
324 }
325 "before_long_help" => {
326 cmd.before_help_long =
327 Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
328 }
329 "before_help_md" => {
330 cmd.before_help_md =
331 Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
332 }
333 "after_help" => {
334 cmd.after_help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
335 }
336 "after_long_help" => {
337 cmd.after_help_long =
338 Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
339 }
340 "after_help_md" => {
341 cmd.after_help_md = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
342 }
343 "subcommand_required" => {
344 cmd.subcommand_required = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
345 }
346 "hide" => cmd.hide = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?,
347 "effect" => {
348 let arg = child.ensure_arg_len(1..=1)?.arg(0)?;
349 let raw = arg.ensure_string()?;
350 match raw.parse() {
351 Ok(effect) => cmd.effect = Some(effect),
352 Err(_) => bail_parse!(
353 ctx,
354 arg.entry.span(),
355 "unsupported effect {raw}, expected one of: {EFFECT_VALUES}"
356 ),
357 }
358 }
359 "restart_token" => {
360 cmd.restart_token = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?)
361 }
362 "deprecated" => {
363 cmd.deprecated = match child.arg(0)?.value.as_bool() {
364 Some(true) => Some("deprecated".to_string()),
365 Some(false) => None,
366 None => Some(child.arg(0)?.ensure_string()?),
367 }
368 }
369 "complete" => {
370 let complete = SpecComplete::parse(ctx, &child)?;
371 cmd.complete.insert(complete.name.clone(), complete);
372 }
373 k => bail_parse!(ctx, child.node.name().span(), "unsupported cmd key {k}"),
374 }
375 }
376 Ok(cmd)
377 }
378 pub(crate) fn is_empty(&self) -> bool {
379 self.args.is_empty()
380 && self.flags.is_empty()
381 && self.mounts.is_empty()
382 && self.subcommands.is_empty()
383 }
384 pub fn usage(&self) -> String {
385 let mut usage = self.full_cmd.join(" ");
386 let flags = self.flags.iter().filter(|f| !f.hide).collect_vec();
387 let args = self.args.iter().filter(|a| !a.hide).collect_vec();
388 if !flags.is_empty() {
389 if flags.len() <= 2 {
390 let inlines = flags
391 .iter()
392 .map(|f| {
393 if f.required {
394 format!("<{}>", f.usage())
395 } else {
396 format!("[{}]", f.usage())
397 }
398 })
399 .join(" ");
400 usage = format!("{usage} {inlines}").trim().to_string();
401 } else if flags.iter().any(|f| f.required) {
402 usage = format!("{usage} <FLAGS>");
403 } else {
404 usage = format!("{usage} [FLAGS]");
405 }
406 }
407 if !args.is_empty() {
408 if args.len() <= 2 {
409 let inlines = args.iter().map(|a| a.usage()).join(" ");
410 usage = format!("{usage} {inlines}").trim().to_string();
411 } else if args.iter().any(|a| a.required) {
412 usage = format!("{usage} <ARGS>…");
413 } else {
414 usage = format!("{usage} [ARGS]…");
415 }
416 }
417 if !self.subcommands.is_empty() {
422 usage = format!("{usage} <SUBCOMMAND>");
423 }
424 usage.trim().to_string()
425 }
426 pub(crate) fn merge(&mut self, other: Self) {
427 let Self {
431 name,
432 help,
433 help_long,
434 help_md,
435 before_help,
436 before_help_long,
437 before_help_md,
438 after_help,
439 after_help_long,
440 after_help_md,
441 args,
442 flags,
443 mounts,
444 aliases,
445 hidden_aliases,
446 examples,
447 hide,
448 subcommand_required,
449 restart_token,
450 subcommands,
451 complete,
452 deprecated,
453 effect,
454 full_cmd: _,
456 usage: _,
457 mounted: _,
458 flags_from_mount: _,
459 subcommand_lookup: _,
460 } = other;
461 if !name.is_empty() {
462 self.name = name;
463 }
464 if help.is_some() {
465 self.help = help;
466 }
467 if help_long.is_some() {
468 self.help_long = help_long;
469 }
470 if help_md.is_some() {
471 self.help_md = help_md;
472 }
473 if before_help.is_some() {
474 self.before_help = before_help;
475 }
476 if before_help_long.is_some() {
477 self.before_help_long = before_help_long;
478 }
479 if before_help_md.is_some() {
480 self.before_help_md = before_help_md;
481 }
482 if after_help.is_some() {
483 self.after_help = after_help;
484 }
485 if after_help_long.is_some() {
486 self.after_help_long = after_help_long;
487 }
488 if after_help_md.is_some() {
489 self.after_help_md = after_help_md;
490 }
491 if !args.is_empty() {
492 self.args = args;
493 }
494 if !flags.is_empty() {
495 self.flags = flags;
496 }
497 if !mounts.is_empty() {
498 self.mounts = mounts;
499 }
500 if !aliases.is_empty() {
501 self.aliases = aliases;
502 }
503 if !hidden_aliases.is_empty() {
504 self.hidden_aliases = hidden_aliases;
505 }
506 if !examples.is_empty() {
507 self.examples = examples;
508 }
509 self.hide = hide;
510 self.subcommand_required = subcommand_required;
511 if effect.is_some() {
512 self.effect = effect;
513 }
514 if deprecated.is_some() {
515 self.deprecated = deprecated;
516 }
517 if restart_token.is_some() {
518 self.restart_token = restart_token;
519 }
520 for (name, cmd) in subcommands {
521 self.subcommands.insert(name, cmd);
522 }
523 for (name, complete) in complete {
524 self.complete.insert(name, complete);
525 }
526 }
527
528 pub fn all_subcommands(&self) -> Vec<&SpecCommand> {
529 let mut cmds = vec![];
530 for cmd in self.subcommands.values() {
531 cmds.push(cmd);
532 cmds.extend(cmd.all_subcommands());
533 }
534 cmds
535 }
536
537 pub fn find_subcommand(&self, name: &str) -> Option<&SpecCommand> {
538 let sl = self.subcommand_lookup.get_or_init(|| {
539 let mut map = HashMap::new();
540 for (name, cmd) in &self.subcommands {
541 map.insert(name.clone(), name.clone());
542 for alias in &cmd.aliases {
543 map.insert(alias.clone(), name.clone());
544 }
545 for alias in &cmd.hidden_aliases {
546 map.insert(alias.clone(), name.clone());
547 }
548 }
549 map
550 });
551 let name = sl.get(name)?;
552 self.subcommands.get(name)
553 }
554
555 pub(crate) fn mount(&mut self, global_flag_args: &[String]) -> Result<(), UsageErr> {
556 for mount in self.mounts.iter().cloned().collect_vec() {
557 let cmd = if global_flag_args.is_empty() {
558 mount.run.clone()
559 } else {
560 let mut tokens = shell_words::split(&mount.run)
564 .expect("mount command should be valid shell syntax");
565 if !tokens.is_empty() {
566 tokens.splice(1..1, global_flag_args.iter().cloned());
568 }
569 shell_words::join(tokens)
571 };
572 let output = sh(&cmd)?;
573 let mut spec: Spec = output.parse()?;
574 for cmd in spec.cmd.subcommands.values_mut() {
577 cmd.mark_mounted();
578 }
579 self.flags_from_mount |= !spec.cmd.flags.is_empty();
582 self.merge(spec.cmd);
583 }
584 Ok(())
585 }
586
587 pub(crate) fn mark_mounted(&mut self) {
589 self.mounted = true;
590 for cmd in self.subcommands.values_mut() {
591 cmd.mark_mounted();
592 }
593 }
594}
595
596impl From<&SpecCommand> for KdlNode {
597 fn from(cmd: &SpecCommand) -> Self {
598 let SpecCommand {
601 name,
602 hide,
603 subcommand_required,
604 restart_token,
605 aliases,
606 hidden_aliases,
607 help,
608 help_long,
609 help_md,
610 before_help,
611 before_help_long,
612 before_help_md,
613 after_help,
614 after_help_long,
615 after_help_md,
616 deprecated,
617 effect,
618 flags,
619 args,
620 mounts,
621 subcommands,
622 complete,
623 examples,
624 full_cmd: _,
626 usage: _,
627 mounted: _,
628 flags_from_mount: _,
629 subcommand_lookup: _,
630 } = cmd;
631 let mut node = Self::new("cmd");
632 node.entries_mut().push(name.clone().into());
633 if *hide {
634 node.entries_mut().push(KdlEntry::new_prop("hide", true));
635 }
636 if *subcommand_required {
637 node.entries_mut()
638 .push(KdlEntry::new_prop("subcommand_required", true));
639 }
640 if let Some(restart_token) = &restart_token {
641 node.entries_mut()
642 .push(KdlEntry::new_prop("restart_token", restart_token.clone()));
643 }
644 if !aliases.is_empty() {
645 let mut alias_node = KdlNode::new("alias");
646 for alias in aliases {
647 alias_node.entries_mut().push(alias.clone().into());
648 }
649 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
650 children.nodes_mut().push(alias_node);
651 }
652 if !hidden_aliases.is_empty() {
653 let mut alias_node = KdlNode::new("alias");
654 for alias in hidden_aliases {
655 alias_node.entries_mut().push(alias.clone().into());
656 }
657 alias_node
658 .entries_mut()
659 .push(KdlEntry::new_prop("hide", true));
660 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
661 children.nodes_mut().push(alias_node);
662 }
663 if let Some(help) = &help {
664 node.entries_mut().push(string_entry(Some("help"), help));
665 }
666 if let Some(help) = &help_long {
667 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
668 let mut node = KdlNode::new("long_help");
669 node.push(string_entry(None, help));
670 children.nodes_mut().push(node);
671 }
672 if let Some(help) = &help_md {
673 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
674 let mut node = KdlNode::new("help_md");
675 node.push(string_entry(None, help));
676 children.nodes_mut().push(node);
677 }
678 if let Some(help) = &before_help {
679 node.entries_mut()
680 .push(string_entry(Some("before_help"), help));
681 }
682 if let Some(help) = &before_help_long {
683 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
684 let mut node = KdlNode::new("before_long_help");
685 node.push(string_entry(None, help));
686 children.nodes_mut().push(node);
687 }
688 if let Some(help) = &before_help_md {
689 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
690 let mut node = KdlNode::new("before_help_md");
691 node.push(string_entry(None, help));
692 children.nodes_mut().push(node);
693 }
694 if let Some(help) = &after_help {
695 node.entries_mut()
696 .push(string_entry(Some("after_help"), help));
697 }
698 if let Some(help) = &after_help_long {
699 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
700 let mut node = KdlNode::new("after_long_help");
701 node.push(string_entry(None, help));
702 children.nodes_mut().push(node);
703 }
704 if let Some(help) = &after_help_md {
705 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
706 let mut node = KdlNode::new("after_help_md");
707 node.push(string_entry(None, help));
708 children.nodes_mut().push(node);
709 }
710 if let Some(deprecated) = &deprecated {
711 node.entries_mut()
712 .push(string_entry(Some("deprecated"), deprecated));
713 }
714 if let Some(effect) = effect {
715 node.entries_mut()
716 .push(string_entry(Some("effect"), effect.as_str()));
717 }
718 for flag in flags {
719 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
720 children.nodes_mut().push(flag.into());
721 }
722 for arg in args {
723 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
724 children.nodes_mut().push(arg.into());
725 }
726 for mount in mounts {
727 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
728 children.nodes_mut().push(mount.into());
729 }
730 for cmd in subcommands.values() {
731 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
732 children.nodes_mut().push(cmd.into());
733 }
734 for example in examples {
735 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
736 children.nodes_mut().push(example.into());
737 }
738 for complete in complete.values() {
739 let children = node.children_mut().get_or_insert_with(KdlDocument::new);
740 children.nodes_mut().push(complete.into());
741 }
742 node
743 }
744}
745
746#[cfg(feature = "clap")]
747impl From<&clap::Command> for SpecCommand {
748 fn from(cmd: &clap::Command) -> Self {
749 let mut spec = Self {
750 name: cmd.get_name().to_string(),
751 hide: cmd.is_hide_set(),
752 help: cmd.get_about().map(|s| s.to_string()),
753 help_long: cmd.get_long_about().map(|s| s.to_string()),
754 before_help: cmd.get_before_help().map(|s| s.to_string()),
755 before_help_long: cmd.get_before_long_help().map(|s| s.to_string()),
756 after_help: cmd.get_after_help().map(|s| s.to_string()),
757 after_help_long: cmd.get_after_long_help().map(|s| s.to_string()),
758 ..Default::default()
759 };
760 for alias in cmd.get_visible_aliases() {
761 spec.aliases.push(alias.to_string());
762 }
763 for alias in cmd.get_all_aliases() {
764 if spec.aliases.contains(&alias.to_string()) {
765 continue;
766 }
767 spec.hidden_aliases.push(alias.to_string());
768 }
769 for arg in cmd.get_arguments() {
770 if arg.is_positional() {
771 spec.args.push(arg.into())
772 } else {
773 spec.flags.push(arg.into())
774 }
775 }
776 spec.subcommand_required = cmd.is_subcommand_required_set();
777 for subcmd in cmd.get_subcommands() {
778 let mut scmd: SpecCommand = subcmd.into();
779 scmd.name = subcmd.get_name().to_string();
780 spec.subcommands.insert(scmd.name.clone(), scmd);
781 }
782 spec
783 }
784}
785
786#[cfg(feature = "clap")]
787impl From<clap::Command> for Spec {
788 fn from(cmd: clap::Command) -> Self {
789 (&cmd).into()
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use crate::spec::effect::SpecCommandEffect;
796 use crate::Spec;
797 use insta::assert_snapshot;
798
799 #[test]
800 fn test_effect_prop_and_child_node() {
801 let spec = Spec::parse(
802 &Default::default(),
803 r#"
804bin "mise"
805cmd "ls" effect="read"
806cmd "use" effect="write"
807cmd "uninstall" {
808 effect "destructive"
809}
810cmd "version"
811 "#,
812 )
813 .unwrap();
814
815 let cmds = &spec.cmd.subcommands;
816 assert_eq!(cmds["ls"].effect, Some(SpecCommandEffect::Read));
817 assert_eq!(cmds["use"].effect, Some(SpecCommandEffect::Write));
818 assert_eq!(
819 cmds["uninstall"].effect,
820 Some(SpecCommandEffect::Destructive)
821 );
822 assert_eq!(cmds["version"].effect, None);
824 }
825
826 #[test]
827 fn test_effect_is_not_inherited_by_subcommands() {
828 let spec = Spec::parse(
829 &Default::default(),
830 r#"
831bin "git"
832cmd "remote" effect="read" {
833 cmd "add" effect="write"
834 cmd "show"
835}
836 "#,
837 )
838 .unwrap();
839
840 let remote = &spec.cmd.subcommands["remote"];
841 assert_eq!(remote.effect, Some(SpecCommandEffect::Read));
842 assert_eq!(
843 remote.subcommands["add"].effect,
844 Some(SpecCommandEffect::Write)
845 );
846 assert_eq!(remote.subcommands["show"].effect, None);
847 }
848
849 #[test]
850 fn test_effect_roundtrips_through_kdl() {
851 let spec = Spec::parse(
852 &Default::default(),
853 r#"
854bin "mise"
855cmd "ls" effect="read"
856cmd "uninstall" effect="destructive"
857 "#,
858 )
859 .unwrap();
860
861 assert_snapshot!(spec, @r#"
862 name mise
863 bin mise
864 cmd ls effect=read
865 cmd uninstall effect=destructive
866 "#);
867 }
868
869 #[test]
873 fn test_effect_survives_merge() {
874 let cmd_with = |src: &str| {
875 Spec::parse(&Default::default(), src)
876 .unwrap()
877 .cmd
878 .subcommands["uninstall"]
879 .clone()
880 };
881
882 let declared = cmd_with(r#"cmd "uninstall" effect="destructive""#);
883 let silent = cmd_with(r#"cmd "uninstall" help="Remove a tool""#);
884 let contradicting = cmd_with(r#"cmd "uninstall" effect="write""#);
885
886 let mut cmd = declared.clone();
887 cmd.merge(silent);
888 assert_eq!(cmd.effect, Some(SpecCommandEffect::Destructive));
889
890 let mut cmd = declared;
891 cmd.merge(contradicting);
892 assert_eq!(cmd.effect, Some(SpecCommandEffect::Write));
893 }
894
895 #[test]
896 fn test_unknown_effect_is_an_error() {
897 let err = Spec::parse(
898 &Default::default(),
899 r#"
900bin "mise"
901cmd "ls" effect="readonly"
902 "#,
903 )
904 .unwrap_err();
905 assert!(
906 err.to_string().contains("Invalid usage config"),
907 "unexpected error: {err}"
908 );
909 }
910}
911
912#[cfg(test)]
913mod merge_tests {
914 use crate::Spec;
915
916 fn uninstall(src: &str) -> crate::SpecCommand {
917 Spec::parse(&Default::default(), src)
918 .unwrap()
919 .cmd
920 .subcommands["uninstall"]
921 .clone()
922 }
923
924 #[test]
927 fn test_deprecated_survives_merge() {
928 let declared = uninstall(r#"cmd "uninstall" deprecated="use `remove`""#);
929 let silent = uninstall(r#"cmd "uninstall" help="Remove a tool""#);
930 let contradicting = uninstall(r#"cmd "uninstall" deprecated="gone in v3""#);
931
932 let mut cmd = declared.clone();
933 cmd.merge(silent);
934 assert_eq!(cmd.deprecated.as_deref(), Some("use `remove`"));
935
936 let mut cmd = declared;
937 cmd.merge(contradicting);
938 assert_eq!(cmd.deprecated.as_deref(), Some("gone in v3"));
939 }
940}
941
942#[cfg(test)]
943mod roundtrip_tests {
944 use crate::Spec;
945
946 #[test]
954 fn test_spec_survives_a_kdl_roundtrip() {
955 let src = r#"
956name "My CLI"
957bin "mycli"
958about "does things"
959version "1.0.0"
960author "nobody"
961license "MIT"
962
963flag "-v --verbose" help="Verbose logging" global=#true count=#true
964arg "<dir>" help="Directory to use"
965
966cmd "install" help="Install a package" subcommand_required=#false {
967 alias "i"
968 alias "add" hide=#true
969 long_help "The long help for install"
970 help_md "The **markdown** help for install"
971 before_help "before"
972 before_long_help "The long before-help for install"
973 before_help_md "The **markdown** before-help for install"
974 after_help "after"
975 after_long_help "The long after-help for install"
976 after_help_md "The **markdown** after-help for install"
977 arg "<pkg>" help="Package to install"
978 arg "[dest]" effect="write"
979 flag "-f --force" help="Overwrite"
980 flag "--purge" effect="destructive"
981 complete "pkg" run="mycli list --available" descriptions=#true
982 example "mycli install foo" header="Install foo" help="Installs foo" lang="sh"
983 example "mycli install bar"
984 cmd "from" help="Install from a source" {
985 arg "<src>"
986 }
987}
988cmd "wrapped" help="Wraps another CLI" {
989 mount run="mycli plugin usage-spec"
990}
991cmd "remove" help="Remove a package" deprecated="use `uninstall`" effect="destructive"
992cmd "run" restart_token=":::" help="Run tasks"
993cmd "hidden" hide=#true
994 "#;
995
996 let original = Spec::parse(&Default::default(), src).unwrap();
997 let reparsed = Spec::parse(&Default::default(), &original.to_string()).unwrap();
998
999 let original = serde_json::to_value(&original).unwrap();
1000 let reparsed = serde_json::to_value(&reparsed).unwrap();
1001 pretty_assertions::assert_eq!(original, reparsed);
1002
1003 let install = &original["cmd"]["subcommands"]["install"];
1006 for key in [
1007 "help_long",
1008 "help_md",
1009 "before_help",
1010 "before_help_long",
1011 "before_help_md",
1012 "after_help",
1013 "after_help_long",
1014 "after_help_md",
1015 "deprecated",
1016 "effect",
1017 "restart_token",
1018 "examples",
1019 "complete",
1020 "mounts",
1021 "aliases",
1022 "hidden_aliases",
1023 ] {
1024 let populated = match key {
1025 "deprecated" | "effect" => {
1027 original["cmd"]["subcommands"]["remove"].get(key).is_some()
1028 }
1029 "restart_token" => original["cmd"]["subcommands"]["run"].get(key).is_some(),
1030 "mounts" => !original["cmd"]["subcommands"]["wrapped"]["mounts"]
1031 .as_array()
1032 .unwrap()
1033 .is_empty(),
1034 _ => match install.get(key) {
1035 Some(serde_json::Value::Array(a)) => !a.is_empty(),
1036 Some(serde_json::Value::Object(o)) => !o.is_empty(),
1037 Some(_) => true,
1038 None => false,
1039 },
1040 };
1041 assert!(populated, "fixture does not exercise `{key}`");
1042 }
1043
1044 assert!(
1047 install["flags"]
1048 .as_array()
1049 .unwrap()
1050 .iter()
1051 .any(|f| f.get("effect").is_some()),
1052 "fixture does not exercise a flag-level `effect`"
1053 );
1054 assert!(
1055 install["args"]
1056 .as_array()
1057 .unwrap()
1058 .iter()
1059 .any(|a| a.get("effect").is_some()),
1060 "fixture does not exercise an arg-level `effect`"
1061 );
1062 }
1063}