Skip to main content

usage/spec/
cmd.rs

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/// A CLI command or subcommand specification.
19///
20/// Commands define the structure of a CLI, including their flags, arguments,
21/// and nested subcommands. The root command represents the main CLI entry point.
22///
23/// # Example
24///
25/// ```
26/// use usage::{SpecCommand, SpecFlag, SpecArg};
27///
28/// let cmd = SpecCommand::builder()
29///     .name("install")
30///     .help("Install a package")
31///     .alias("i")
32///     .flag(SpecFlag::builder().short('f').long("force").build())
33///     .arg(SpecArg::builder().name("package").required(true).build())
34///     .build();
35/// ```
36#[derive(Debug, Serialize, Clone)]
37pub struct SpecCommand {
38    /// Full command path from root (e.g., ["git", "remote", "add"])
39    pub full_cmd: Vec<String>,
40    /// Generated usage string
41    pub usage: String,
42    /// Nested subcommands indexed by name
43    pub subcommands: IndexMap<String, SpecCommand>,
44    /// Positional arguments for this command
45    pub args: Vec<SpecArg>,
46    /// Flags/options for this command
47    pub flags: Vec<SpecFlag>,
48    /// Mounted external specs
49    pub mounts: Vec<SpecMount>,
50    /// Deprecation message if this command is deprecated
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub deprecated: Option<String>,
53    /// What running this command does to the world: read, write or destructive.
54    /// Not inherited by subcommands.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub effect: Option<SpecCommandEffect>,
57    /// Whether to hide this command from help output
58    pub hide: bool,
59    /// True when this command came from a [`SpecMount`], i.e. it describes another
60    /// program's CLI that was merged in at parse time.
61    ///
62    /// The flags of the commands *above* a mounted command belong to the mounting CLI,
63    /// not to the mounted program, so they are not offered in completions once a mounted
64    /// command has been reached. They stay recognized by the parser, since they may
65    /// legitimately appear *before* the mounted command on the command line.
66    ///
67    /// Runtime-only: it is derived from `mount` nodes and is not part of the spec syntax.
68    #[serde(skip)]
69    pub mounted: bool,
70    /// True when a [`SpecMount`] brought flags of its own onto this command. A mounted spec's
71    /// root flags are merged into the command the mount sits on, *replacing* that command's
72    /// flags (see [`SpecCommand::merge`]), so when this is set every flag here describes the
73    /// mounted program and is offered inside the mounted commands accordingly.
74    ///
75    /// Runtime-only, like [`SpecCommand::mounted`].
76    #[serde(skip)]
77    pub flags_from_mount: bool,
78    /// Whether a subcommand must be provided
79    #[serde(skip_serializing_if = "is_false")]
80    pub subcommand_required: bool,
81    /// Token that resets argument parsing, allowing multiple command invocations.
82    /// e.g., `mise run lint ::: test ::: check` with restart_token=":::"
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub restart_token: Option<String>,
85    /// Short help text shown in command listings
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub help: Option<String>,
88    /// Extended help text shown with --help
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub help_long: Option<String>,
91    /// Markdown-formatted help text
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub help_md: Option<String>,
94    /// Command name (e.g., "install")
95    pub name: String,
96    /// Alternative names for this command
97    pub aliases: Vec<String>,
98    /// Hidden alternative names (not shown in help)
99    pub hidden_aliases: Vec<String>,
100    /// Text displayed before the help content
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub before_help: Option<String>,
103    /// Extended text displayed before help content
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub before_help_long: Option<String>,
106    /// Markdown text displayed before help content
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub before_help_md: Option<String>,
109    /// Text displayed after the help content
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub after_help: Option<String>,
112    /// Extended text displayed after help content
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub after_help_long: Option<String>,
115    /// Markdown text displayed after help content
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub after_help_md: Option<String>,
118    /// Usage examples for this command
119    pub examples: Vec<SpecExample>,
120    /// Custom completers for arguments
121    #[serde(skip_serializing_if = "IndexMap::is_empty")]
122    pub complete: IndexMap<String, SpecComplete>,
123
124    /// Cache for subcommand name lookups (including aliases).
125    ///
126    /// `pub(crate)` only so that other modules can destructure `SpecCommand`
127    /// exhaustively; it stays private to the crate.
128    #[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    /// Create a new builder for SpecCommand
203    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        // TODO: mounts?
398        // if !self.mounts.is_empty() {
399        //     name = format!("{name} [mounts]");
400        // }
401        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        // Destructured exhaustively (no `..`) so that adding a field to
408        // SpecCommand fails to compile until this decides what merging it means.
409        // Runtime-derived fields are explicitly ignored rather than skipped.
410        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            // Recomputed from the merged command, never carried over.
435            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                // Parse the mount command into tokens, insert global flags after the first token
541                // e.g., "mise tasks ls" becomes "mise --cd dir2 tasks ls"
542                // Handles quoted arguments correctly: "cmd 'arg with spaces'" stays correct
543                let mut tokens = shell_words::split(&mount.run)
544                    .expect("mount command should be valid shell syntax");
545                if !tokens.is_empty() {
546                    // Insert global flags after the first token (the command name)
547                    tokens.splice(1..1, global_flag_args.iter().cloned());
548                }
549                // Join tokens back into a properly quoted command string
550                shell_words::join(tokens)
551            };
552            let output = sh(&cmd)?;
553            let mut spec: Spec = output.parse()?;
554            // The subcommands emitted by a mount describe another program, so mark them (and
555            // everything below them) as mounted. See `SpecCommand::mounted`.
556            for cmd in spec.cmd.subcommands.values_mut() {
557                cmd.mark_mounted();
558            }
559            // `merge` folds the mounted spec's root flags into this command; remember that they
560            // came from the mount. See `SpecCommand::flags_from_mount`.
561            self.flags_from_mount |= !spec.cmd.flags.is_empty();
562            self.merge(spec.cmd);
563        }
564        Ok(())
565    }
566
567    /// Mark this command and all of its subcommands as coming from a mount.
568    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        // Destructured exhaustively (no `..`) so that adding a field to
579        // SpecCommand fails to compile until this decides how to serialize it.
580        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            // Derived from the spec rather than written by it.
605            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        // Unspecified stays unknown rather than defaulting to anything.
803        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    /// `merge` is how included and mounted specs are composed onto a command.
850    /// It has to treat `effect` the way it treats every other optional field:
851    /// an overlay that says nothing must not erase what is already declared.
852    #[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    /// An overlay that says nothing about deprecation must not un-deprecate a
905    /// command that already declared it.
906    #[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    /// Serializing a spec back to KDL and reparsing it must not lose anything.
927    ///
928    /// The parser is a match on node names, so exhaustive destructuring can't
929    /// catch a field the serializer knows about but the parser doesn't, or the
930    /// reverse. Comparing the serde representation covers every field without
931    /// this test having to enumerate them, so a new field is covered the day it
932    /// is added.
933    #[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        // Equality is only meaningful if the fixture actually populated the
982        // fields, so guard against a future edit quietly emptying it out.
983        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                // These sit on other commands in the fixture.
1004                "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}