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::helpers::NodeHelper;
9use crate::spec::is_false;
10use crate::spec::mount::SpecMount;
11use crate::{Spec, SpecArg, SpecComplete, SpecFlag};
12use indexmap::IndexMap;
13use itertools::Itertools;
14use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue};
15use serde::Serialize;
16
17/// A CLI command or subcommand specification.
18///
19/// Commands define the structure of a CLI, including their flags, arguments,
20/// and nested subcommands. The root command represents the main CLI entry point.
21///
22/// # Example
23///
24/// ```
25/// use usage::{SpecCommand, SpecFlag, SpecArg};
26///
27/// let cmd = SpecCommand::builder()
28///     .name("install")
29///     .help("Install a package")
30///     .alias("i")
31///     .flag(SpecFlag::builder().short('f').long("force").build())
32///     .arg(SpecArg::builder().name("package").required(true).build())
33///     .build();
34/// ```
35#[derive(Debug, Serialize, Clone)]
36pub struct SpecCommand {
37    /// Full command path from root (e.g., ["git", "remote", "add"])
38    pub full_cmd: Vec<String>,
39    /// Generated usage string
40    pub usage: String,
41    /// Nested subcommands indexed by name
42    pub subcommands: IndexMap<String, SpecCommand>,
43    /// Positional arguments for this command
44    pub args: Vec<SpecArg>,
45    /// Flags/options for this command
46    pub flags: Vec<SpecFlag>,
47    /// Mounted external specs
48    pub mounts: Vec<SpecMount>,
49    /// Deprecation message if this command is deprecated
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub deprecated: Option<String>,
52    /// Whether to hide this command from help output
53    pub hide: bool,
54    /// Whether a subcommand must be provided
55    #[serde(skip_serializing_if = "is_false")]
56    pub subcommand_required: bool,
57    /// Token that resets argument parsing, allowing multiple command invocations.
58    /// e.g., `mise run lint ::: test ::: check` with restart_token=":::"
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub restart_token: Option<String>,
61    /// Short help text shown in command listings
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub help: Option<String>,
64    /// Extended help text shown with --help
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub help_long: Option<String>,
67    /// Markdown-formatted help text
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub help_md: Option<String>,
70    /// Command name (e.g., "install")
71    pub name: String,
72    /// Alternative names for this command
73    pub aliases: Vec<String>,
74    /// Hidden alternative names (not shown in help)
75    pub hidden_aliases: Vec<String>,
76    /// Text displayed before the help content
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub before_help: Option<String>,
79    /// Extended text displayed before help content
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub before_help_long: Option<String>,
82    /// Markdown text displayed before help content
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub before_help_md: Option<String>,
85    /// Text displayed after the help content
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub after_help: Option<String>,
88    /// Extended text displayed after help content
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub after_help_long: Option<String>,
91    /// Markdown text displayed after help content
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub after_help_md: Option<String>,
94    /// Usage examples for this command
95    pub examples: Vec<SpecExample>,
96    /// Custom completers for arguments
97    #[serde(skip_serializing_if = "IndexMap::is_empty")]
98    pub complete: IndexMap<String, SpecComplete>,
99
100    /// Cache for subcommand name lookups (including aliases)
101    #[serde(skip)]
102    subcommand_lookup: OnceLock<HashMap<String, String>>,
103}
104
105impl Default for SpecCommand {
106    fn default() -> Self {
107        Self {
108            full_cmd: vec![],
109            usage: "".to_string(),
110            subcommands: IndexMap::new(),
111            args: vec![],
112            flags: vec![],
113            mounts: vec![],
114            deprecated: None,
115            hide: false,
116            subcommand_required: false,
117            restart_token: None,
118            help: None,
119            help_long: None,
120            help_md: None,
121            name: "".to_string(),
122            aliases: vec![],
123            hidden_aliases: vec![],
124            before_help: None,
125            before_help_long: None,
126            before_help_md: None,
127            after_help: None,
128            after_help_long: None,
129            after_help_md: None,
130            examples: vec![],
131            subcommand_lookup: OnceLock::new(),
132            complete: IndexMap::new(),
133        }
134    }
135}
136
137#[derive(Debug, Default, Serialize, Clone)]
138pub struct SpecExample {
139    pub code: String,
140    pub header: Option<String>,
141    pub help: Option<String>,
142    pub lang: String,
143}
144
145impl SpecExample {
146    pub(crate) fn new(code: String) -> Self {
147        Self {
148            code,
149            ..Default::default()
150        }
151    }
152}
153
154impl From<&SpecExample> for KdlNode {
155    fn from(example: &SpecExample) -> KdlNode {
156        let mut node = KdlNode::new("example");
157        node.push(KdlEntry::new(example.code.clone()));
158        if let Some(header) = &example.header {
159            node.push(KdlEntry::new_prop("header", header.clone()));
160        }
161        if let Some(help) = &example.help {
162            node.push(KdlEntry::new_prop("help", help.clone()));
163        }
164        if !example.lang.is_empty() {
165            node.push(KdlEntry::new_prop("lang", example.lang.clone()));
166        }
167        node
168    }
169}
170
171impl SpecCommand {
172    /// Create a new builder for SpecCommand
173    pub fn builder() -> SpecCommandBuilder {
174        SpecCommandBuilder::new()
175    }
176
177    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
178        node.ensure_arg_len(1..=1)?;
179        let mut cmd = Self {
180            name: node.arg(0)?.ensure_string()?.to_string(),
181            ..Default::default()
182        };
183        for (k, v) in node.props() {
184            match k {
185                "help" => cmd.help = Some(v.ensure_string()?),
186                "long_help" => cmd.help_long = Some(v.ensure_string()?),
187                "help_long" => cmd.help_long = Some(v.ensure_string()?),
188                "help_md" => cmd.help_md = Some(v.ensure_string()?),
189                "before_help" => cmd.before_help = Some(v.ensure_string()?),
190                "before_long_help" => cmd.before_help_long = Some(v.ensure_string()?),
191                "before_help_long" => cmd.before_help_long = Some(v.ensure_string()?),
192                "before_help_md" => cmd.before_help_md = Some(v.ensure_string()?),
193                "after_help" => cmd.after_help = Some(v.ensure_string()?),
194                "after_long_help" => {
195                    cmd.after_help_long = Some(v.ensure_string()?);
196                }
197                "after_help_long" => {
198                    cmd.after_help_long = Some(v.ensure_string()?);
199                }
200                "after_help_md" => cmd.after_help_md = Some(v.ensure_string()?),
201                "subcommand_required" => cmd.subcommand_required = v.ensure_bool()?,
202                "hide" => cmd.hide = v.ensure_bool()?,
203                "restart_token" => cmd.restart_token = Some(v.ensure_string()?),
204                "deprecated" => {
205                    cmd.deprecated = match v.value.as_bool() {
206                        Some(true) => Some("deprecated".to_string()),
207                        Some(false) => None,
208                        None => Some(v.ensure_string()?),
209                    }
210                }
211                k => bail_parse!(ctx, v.entry.span(), "unsupported cmd prop {k}"),
212            }
213        }
214        for child in node.children() {
215            match child.name() {
216                "flag" => cmd.flags.push(SpecFlag::parse(ctx, &child)?),
217                "arg" => cmd.args.push(SpecArg::parse(ctx, &child)?),
218                "mount" => cmd.mounts.push(SpecMount::parse(ctx, &child)?),
219                "cmd" => {
220                    let node = SpecCommand::parse(ctx, &child)?;
221                    cmd.subcommands.insert(node.name.to_string(), node);
222                }
223                "alias" => {
224                    let alias = child
225                        .ensure_arg_len(1..)?
226                        .args()
227                        .map(|e| e.ensure_string())
228                        .collect::<Result<Vec<_>, _>>()?;
229                    let hide = child
230                        .get("hide")
231                        .map(|n| n.ensure_bool())
232                        .unwrap_or(Ok(false))?;
233                    if hide {
234                        cmd.hidden_aliases.extend(alias);
235                    } else {
236                        cmd.aliases.extend(alias);
237                    }
238                }
239                "example" => {
240                    let code = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
241                    let mut example = SpecExample::new(code.trim().to_string());
242                    for (k, v) in child.props() {
243                        match k {
244                            "header" => example.header = Some(v.ensure_string()?),
245                            "help" => example.help = Some(v.ensure_string()?),
246                            "lang" => example.lang = v.ensure_string()?,
247                            k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
248                        }
249                    }
250                    cmd.examples.push(example);
251                }
252                "help" => {
253                    cmd.help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
254                }
255                "long_help" => {
256                    cmd.help_long = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
257                }
258                "before_help" => {
259                    cmd.before_help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
260                }
261                "before_long_help" => {
262                    cmd.before_help_long =
263                        Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
264                }
265                "after_help" => {
266                    cmd.after_help = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
267                }
268                "after_long_help" => {
269                    cmd.after_help_long =
270                        Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
271                }
272                "subcommand_required" => {
273                    cmd.subcommand_required = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?
274                }
275                "hide" => cmd.hide = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?,
276                "restart_token" => {
277                    cmd.restart_token = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?)
278                }
279                "deprecated" => {
280                    cmd.deprecated = match child.arg(0)?.value.as_bool() {
281                        Some(true) => Some("deprecated".to_string()),
282                        Some(false) => None,
283                        None => Some(child.arg(0)?.ensure_string()?),
284                    }
285                }
286                "complete" => {
287                    let complete = SpecComplete::parse(ctx, &child)?;
288                    cmd.complete.insert(complete.name.clone(), complete);
289                }
290                k => bail_parse!(ctx, child.node.name().span(), "unsupported cmd key {k}"),
291            }
292        }
293        Ok(cmd)
294    }
295    pub(crate) fn is_empty(&self) -> bool {
296        self.args.is_empty()
297            && self.flags.is_empty()
298            && self.mounts.is_empty()
299            && self.subcommands.is_empty()
300    }
301    pub fn usage(&self) -> String {
302        let mut usage = self.full_cmd.join(" ");
303        let flags = self.flags.iter().filter(|f| !f.hide).collect_vec();
304        let args = self.args.iter().filter(|a| !a.hide).collect_vec();
305        if !flags.is_empty() {
306            if flags.len() <= 2 {
307                let inlines = flags
308                    .iter()
309                    .map(|f| {
310                        if f.required {
311                            format!("<{}>", f.usage())
312                        } else {
313                            format!("[{}]", f.usage())
314                        }
315                    })
316                    .join(" ");
317                usage = format!("{usage} {inlines}").trim().to_string();
318            } else if flags.iter().any(|f| f.required) {
319                usage = format!("{usage} <FLAGS>");
320            } else {
321                usage = format!("{usage} [FLAGS]");
322            }
323        }
324        if !args.is_empty() {
325            if args.len() <= 2 {
326                let inlines = args.iter().map(|a| a.usage()).join(" ");
327                usage = format!("{usage} {inlines}").trim().to_string();
328            } else if args.iter().any(|a| a.required) {
329                usage = format!("{usage} <ARGS>…");
330            } else {
331                usage = format!("{usage} [ARGS]…");
332            }
333        }
334        // TODO: mounts?
335        // if !self.mounts.is_empty() {
336        //     name = format!("{name} [mounts]");
337        // }
338        if !self.subcommands.is_empty() {
339            usage = format!("{usage} <SUBCOMMAND>");
340        }
341        usage.trim().to_string()
342    }
343    pub(crate) fn merge(&mut self, other: Self) {
344        if !other.name.is_empty() {
345            self.name = other.name;
346        }
347        if other.help.is_some() {
348            self.help = other.help;
349        }
350        if other.help_long.is_some() {
351            self.help_long = other.help_long;
352        }
353        if other.help_md.is_some() {
354            self.help_md = other.help_md;
355        }
356        if other.before_help.is_some() {
357            self.before_help = other.before_help;
358        }
359        if other.before_help_long.is_some() {
360            self.before_help_long = other.before_help_long;
361        }
362        if other.before_help_md.is_some() {
363            self.before_help_md = other.before_help_md;
364        }
365        if other.after_help.is_some() {
366            self.after_help = other.after_help;
367        }
368        if other.after_help_long.is_some() {
369            self.after_help_long = other.after_help_long;
370        }
371        if other.after_help_md.is_some() {
372            self.after_help_md = other.after_help_md;
373        }
374        if !other.args.is_empty() {
375            self.args = other.args;
376        }
377        if !other.flags.is_empty() {
378            self.flags = other.flags;
379        }
380        if !other.mounts.is_empty() {
381            self.mounts = other.mounts;
382        }
383        if !other.aliases.is_empty() {
384            self.aliases = other.aliases;
385        }
386        if !other.hidden_aliases.is_empty() {
387            self.hidden_aliases = other.hidden_aliases;
388        }
389        if !other.examples.is_empty() {
390            self.examples = other.examples;
391        }
392        self.hide = other.hide;
393        self.subcommand_required = other.subcommand_required;
394        if other.restart_token.is_some() {
395            self.restart_token = other.restart_token;
396        }
397        for (name, cmd) in other.subcommands {
398            self.subcommands.insert(name, cmd);
399        }
400        for (name, complete) in other.complete {
401            self.complete.insert(name, complete);
402        }
403    }
404
405    pub fn all_subcommands(&self) -> Vec<&SpecCommand> {
406        let mut cmds = vec![];
407        for cmd in self.subcommands.values() {
408            cmds.push(cmd);
409            cmds.extend(cmd.all_subcommands());
410        }
411        cmds
412    }
413
414    pub fn find_subcommand(&self, name: &str) -> Option<&SpecCommand> {
415        let sl = self.subcommand_lookup.get_or_init(|| {
416            let mut map = HashMap::new();
417            for (name, cmd) in &self.subcommands {
418                map.insert(name.clone(), name.clone());
419                for alias in &cmd.aliases {
420                    map.insert(alias.clone(), name.clone());
421                }
422                for alias in &cmd.hidden_aliases {
423                    map.insert(alias.clone(), name.clone());
424                }
425            }
426            map
427        });
428        let name = sl.get(name)?;
429        self.subcommands.get(name)
430    }
431
432    pub(crate) fn mount(&mut self, global_flag_args: &[String]) -> Result<(), UsageErr> {
433        for mount in self.mounts.iter().cloned().collect_vec() {
434            let cmd = if global_flag_args.is_empty() {
435                mount.run.clone()
436            } else {
437                // Parse the mount command into tokens, insert global flags after the first token
438                // e.g., "mise tasks ls" becomes "mise --cd dir2 tasks ls"
439                // Handles quoted arguments correctly: "cmd 'arg with spaces'" stays correct
440                let mut tokens = shell_words::split(&mount.run)
441                    .expect("mount command should be valid shell syntax");
442                if !tokens.is_empty() {
443                    // Insert global flags after the first token (the command name)
444                    tokens.splice(1..1, global_flag_args.iter().cloned());
445                }
446                // Join tokens back into a properly quoted command string
447                shell_words::join(tokens)
448            };
449            let output = sh(&cmd)?;
450            let spec: Spec = output.parse()?;
451            self.merge(spec.cmd);
452        }
453        Ok(())
454    }
455}
456
457impl From<&SpecCommand> for KdlNode {
458    fn from(cmd: &SpecCommand) -> Self {
459        let mut node = Self::new("cmd");
460        node.entries_mut().push(cmd.name.clone().into());
461        if cmd.hide {
462            node.entries_mut().push(KdlEntry::new_prop("hide", true));
463        }
464        if cmd.subcommand_required {
465            node.entries_mut()
466                .push(KdlEntry::new_prop("subcommand_required", true));
467        }
468        if let Some(restart_token) = &cmd.restart_token {
469            node.entries_mut()
470                .push(KdlEntry::new_prop("restart_token", restart_token.clone()));
471        }
472        if !cmd.aliases.is_empty() {
473            let mut aliases = KdlNode::new("alias");
474            for alias in &cmd.aliases {
475                aliases.entries_mut().push(alias.clone().into());
476            }
477            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
478            children.nodes_mut().push(aliases);
479        }
480        if !cmd.hidden_aliases.is_empty() {
481            let mut aliases = KdlNode::new("alias");
482            for alias in &cmd.hidden_aliases {
483                aliases.entries_mut().push(alias.clone().into());
484            }
485            aliases.entries_mut().push(KdlEntry::new_prop("hide", true));
486            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
487            children.nodes_mut().push(aliases);
488        }
489        if let Some(help) = &cmd.help {
490            node.entries_mut()
491                .push(KdlEntry::new_prop("help", help.clone()));
492        }
493        if let Some(help) = &cmd.help_long {
494            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
495            let mut node = KdlNode::new("long_help");
496            node.insert(0, KdlValue::String(help.clone()));
497            children.nodes_mut().push(node);
498        }
499        if let Some(help) = &cmd.help_md {
500            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
501            let mut node = KdlNode::new("help_md");
502            node.insert(0, KdlValue::String(help.clone()));
503            children.nodes_mut().push(node);
504        }
505        if let Some(help) = &cmd.before_help {
506            node.entries_mut()
507                .push(KdlEntry::new_prop("before_help", help.clone()));
508        }
509        if let Some(help) = &cmd.before_help_long {
510            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
511            let mut node = KdlNode::new("before_long_help");
512            node.insert(0, KdlValue::String(help.clone()));
513            children.nodes_mut().push(node);
514        }
515        if let Some(help) = &cmd.before_help_md {
516            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
517            let mut node = KdlNode::new("before_help_md");
518            node.insert(0, KdlValue::String(help.clone()));
519            children.nodes_mut().push(node);
520        }
521        if let Some(help) = &cmd.after_help {
522            node.entries_mut()
523                .push(KdlEntry::new_prop("after_help", help.clone()));
524        }
525        if let Some(help) = &cmd.after_help_long {
526            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
527            let mut node = KdlNode::new("after_long_help");
528            node.insert(0, KdlValue::String(help.clone()));
529            children.nodes_mut().push(node);
530        }
531        if let Some(help) = &cmd.after_help_md {
532            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
533            let mut node = KdlNode::new("after_help_md");
534            node.insert(0, KdlValue::String(help.clone()));
535            children.nodes_mut().push(node);
536        }
537        for flag in &cmd.flags {
538            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
539            children.nodes_mut().push(flag.into());
540        }
541        for arg in &cmd.args {
542            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
543            children.nodes_mut().push(arg.into());
544        }
545        for mount in &cmd.mounts {
546            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
547            children.nodes_mut().push(mount.into());
548        }
549        for cmd in cmd.subcommands.values() {
550            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
551            children.nodes_mut().push(cmd.into());
552        }
553        for complete in cmd.complete.values() {
554            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
555            children.nodes_mut().push(complete.into());
556        }
557        node
558    }
559}
560
561#[cfg(feature = "clap")]
562impl From<&clap::Command> for SpecCommand {
563    fn from(cmd: &clap::Command) -> Self {
564        let mut spec = Self {
565            name: cmd.get_name().to_string(),
566            hide: cmd.is_hide_set(),
567            help: cmd.get_about().map(|s| s.to_string()),
568            help_long: cmd.get_long_about().map(|s| s.to_string()),
569            before_help: cmd.get_before_help().map(|s| s.to_string()),
570            before_help_long: cmd.get_before_long_help().map(|s| s.to_string()),
571            after_help: cmd.get_after_help().map(|s| s.to_string()),
572            after_help_long: cmd.get_after_long_help().map(|s| s.to_string()),
573            ..Default::default()
574        };
575        for alias in cmd.get_visible_aliases() {
576            spec.aliases.push(alias.to_string());
577        }
578        for alias in cmd.get_all_aliases() {
579            if spec.aliases.contains(&alias.to_string()) {
580                continue;
581            }
582            spec.hidden_aliases.push(alias.to_string());
583        }
584        for arg in cmd.get_arguments() {
585            if arg.is_positional() {
586                spec.args.push(arg.into())
587            } else {
588                spec.flags.push(arg.into())
589            }
590        }
591        spec.subcommand_required = cmd.is_subcommand_required_set();
592        for subcmd in cmd.get_subcommands() {
593            let mut scmd: SpecCommand = subcmd.into();
594            scmd.name = subcmd.get_name().to_string();
595            spec.subcommands.insert(scmd.name.clone(), scmd);
596        }
597        spec
598    }
599}
600
601#[cfg(feature = "clap")]
602impl From<clap::Command> for Spec {
603    fn from(cmd: clap::Command) -> Self {
604        (&cmd).into()
605    }
606}