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)]
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    /// An example invocation shown in generated docs and help.
178    pub fn new(code: impl Into<String>) -> Self {
179        Self {
180            code: code.into(),
181            ..Default::default()
182        }
183    }
184
185    /// Heading shown above the example.
186    pub fn header(mut self, header: impl Into<String>) -> Self {
187        self.header = Some(header.into());
188        self
189    }
190
191    /// Prose shown with the example.
192    pub fn help(mut self, help: impl Into<String>) -> Self {
193        self.help = Some(help.into());
194        self
195    }
196
197    /// Language used for syntax highlighting.
198    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    /// Create a new builder for SpecCommand
223    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        // TODO: mounts?
418        // if !self.mounts.is_empty() {
419        //     name = format!("{name} [mounts]");
420        // }
421        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        // Destructured exhaustively (no `..`) so that adding a field to
428        // SpecCommand fails to compile until this decides what merging it means.
429        // Runtime-derived fields are explicitly ignored rather than skipped.
430        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            // Recomputed from the merged command, never carried over.
455            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                // Parse the mount command into tokens, insert global flags after the first token
561                // e.g., "mise tasks ls" becomes "mise --cd dir2 tasks ls"
562                // Handles quoted arguments correctly: "cmd 'arg with spaces'" stays correct
563                let mut tokens = shell_words::split(&mount.run)
564                    .expect("mount command should be valid shell syntax");
565                if !tokens.is_empty() {
566                    // Insert global flags after the first token (the command name)
567                    tokens.splice(1..1, global_flag_args.iter().cloned());
568                }
569                // Join tokens back into a properly quoted command string
570                shell_words::join(tokens)
571            };
572            let output = sh(&cmd)?;
573            let mut spec: Spec = output.parse()?;
574            // The subcommands emitted by a mount describe another program, so mark them (and
575            // everything below them) as mounted. See `SpecCommand::mounted`.
576            for cmd in spec.cmd.subcommands.values_mut() {
577                cmd.mark_mounted();
578            }
579            // `merge` folds the mounted spec's root flags into this command; remember that they
580            // came from the mount. See `SpecCommand::flags_from_mount`.
581            self.flags_from_mount |= !spec.cmd.flags.is_empty();
582            self.merge(spec.cmd);
583        }
584        Ok(())
585    }
586
587    /// Mark this command and all of its subcommands as coming from a mount.
588    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        // Destructured exhaustively (no `..`) so that adding a field to
599        // SpecCommand fails to compile until this decides how to serialize it.
600        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            // Derived from the spec rather than written by it.
625            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        // Unspecified stays unknown rather than defaulting to anything.
823        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    /// `merge` is how included and mounted specs are composed onto a command.
870    /// It has to treat `effect` the way it treats every other optional field:
871    /// an overlay that says nothing must not erase what is already declared.
872    #[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    /// An overlay that says nothing about deprecation must not un-deprecate a
925    /// command that already declared it.
926    #[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    /// Serializing a spec back to KDL and reparsing it must not lose anything.
947    ///
948    /// The parser is a match on node names, so exhaustive destructuring can't
949    /// catch a field the serializer knows about but the parser doesn't, or the
950    /// reverse. Comparing the serde representation covers every field without
951    /// this test having to enumerate them, so a new field is covered the day it
952    /// is added.
953    #[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        // Equality is only meaningful if the fixture actually populated the
1004        // fields, so guard against a future edit quietly emptying it out.
1005        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                // These sit on other commands in the fixture.
1026                "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        // Flag- and arg-level effects live one level down, so check them
1045        // explicitly rather than by name against the command object.
1046        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}