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