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