Skip to main content

usage/spec/
arg.rs

1use kdl::{KdlDocument, KdlEntry, KdlNode};
2use serde::Serialize;
3use std::fmt::Display;
4use std::hash::Hash;
5use std::str::FromStr;
6
7use crate::error::UsageErr;
8use crate::spec::builder::SpecArgBuilder;
9use crate::spec::context::ParsingContext;
10use crate::spec::effect::{SpecCommandEffect, EFFECT_VALUES};
11use crate::spec::helpers::{string_entry, NodeHelper};
12use crate::spec::is_false;
13use crate::{string, SpecChoices};
14
15#[derive(Debug, Default, Clone, Serialize, PartialEq, Eq, strum::EnumString, strum::Display)]
16#[strum(serialize_all = "snake_case")]
17pub enum SpecDoubleDashChoices {
18    /// Once an arg is entered, behave as if "--" was passed
19    Automatic,
20    /// Allow "--" to be passed
21    #[default]
22    Optional,
23    /// Require "--" to be passed
24    Required,
25    /// Preserve "--" tokens as values (only for variadic args)
26    Preserve,
27}
28
29/// A positional argument specification.
30///
31/// Arguments are positional values passed to a command without a flag prefix.
32/// They can be required or optional, and can accept multiple values (variadic).
33///
34/// # Example
35///
36/// ```
37/// use usage::SpecArg;
38///
39/// let arg = SpecArg::builder()
40///     .name("file")
41///     .required(true)
42///     .help("Input file to process")
43///     .build();
44/// ```
45#[derive(Debug, Default, Clone, Serialize)]
46#[non_exhaustive]
47pub struct SpecArg {
48    /// Name of the argument (used in help text)
49    pub name: String,
50    /// Generated usage string (e.g., "<file>" or "[file]")
51    pub usage: String,
52    /// Short help text shown in command listings
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub help: Option<String>,
55    /// Extended help text shown with --help
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub help_long: Option<String>,
58    /// Markdown-formatted help text
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub help_md: Option<String>,
61    /// First line of help text (auto-generated)
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub help_first_line: Option<String>,
64    /// Whether this argument must be provided
65    pub required: bool,
66    /// How to handle the "--" separator
67    pub double_dash: SpecDoubleDashChoices,
68    /// Whether this argument accepts multiple values
69    #[serde(skip_serializing_if = "is_false")]
70    pub var: bool,
71    /// Minimum number of values for variadic arguments
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub var_min: Option<usize>,
74    /// Maximum number of values for variadic arguments
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub var_max: Option<usize>,
77    /// Whether to hide this argument from help output
78    pub hide: bool,
79    /// Default value(s) if the argument is not provided
80    #[serde(skip_serializing_if = "Vec::is_empty")]
81    pub default: Vec<String>,
82    /// Valid choices for this argument
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub choices: Option<SpecChoices>,
85    /// Raises the effect of the command when this argument is supplied.
86    /// See [`crate::spec::effect::SpecCommandEffect`]; never lowers it.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub effect: Option<SpecCommandEffect>,
89    /// Environment variable that can provide this argument's value
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub env: Option<String>,
92}
93
94impl SpecArg {
95    /// Create a new builder for SpecArg
96    pub fn builder() -> SpecArgBuilder {
97        SpecArgBuilder::new()
98    }
99
100    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
101        let mut arg: SpecArg = node.arg(0)?.ensure_string()?.parse()?;
102        for (k, v) in node.props() {
103            match k {
104                "help" => arg.help = Some(v.ensure_string()?),
105                "long_help" => arg.help_long = Some(v.ensure_string()?),
106                "help_long" => arg.help_long = Some(v.ensure_string()?),
107                "help_md" => arg.help_md = Some(v.ensure_string()?),
108                "required" => arg.required = v.ensure_bool()?,
109                "double_dash" => arg.double_dash = v.ensure_string()?.parse()?,
110                "var" => arg.var = v.ensure_bool()?,
111                "hide" => arg.hide = v.ensure_bool()?,
112                "var_min" => arg.var_min = v.ensure_usize().map(Some)?,
113                "var_max" => arg.var_max = v.ensure_usize().map(Some)?,
114                "default" => arg.default = vec![v.ensure_string()?],
115                "effect" => {
116                    let raw = v.ensure_string()?;
117                    match raw.parse() {
118                        Ok(effect) => arg.effect = Some(effect),
119                        Err(_) => bail_parse!(
120                            ctx,
121                            v.entry.span(),
122                            "unsupported effect {raw}, expected one of: {EFFECT_VALUES}"
123                        ),
124                    }
125                }
126                "env" => arg.env = v.ensure_string().map(Some)?,
127                k => bail_parse!(ctx, v.entry.span(), "unsupported arg key {k}"),
128            }
129        }
130        if !arg.default.is_empty() {
131            arg.required = false;
132        }
133        for child in node.children() {
134            match child.name() {
135                "choices" => arg.choices = Some(SpecChoices::parse(ctx, &child)?),
136                "effect" => {
137                    let a = child.arg(0)?;
138                    let raw = a.ensure_string()?;
139                    match raw.parse() {
140                        Ok(effect) => arg.effect = Some(effect),
141                        Err(_) => bail_parse!(
142                            ctx,
143                            a.entry.span(),
144                            "unsupported effect {raw}, expected one of: {EFFECT_VALUES}"
145                        ),
146                    }
147                }
148                "env" => arg.env = child.arg(0)?.ensure_string().map(Some)?,
149                "default" => {
150                    // Support both single value and multiple values
151                    // default "bar"            -> vec!["bar"]
152                    // default { "xyz"; "bar" } -> vec!["xyz", "bar"]
153                    let children = child.children();
154                    if children.is_empty() {
155                        // Single value: default "bar"
156                        arg.default = vec![child.arg(0)?.ensure_string()?];
157                    } else {
158                        // Multiple values from children: default { "xyz"; "bar" }
159                        // In KDL, these are child nodes where the string is the node name
160                        arg.default = children.iter().map(|c| c.name().to_string()).collect();
161                    }
162                }
163                "help" => arg.help = Some(child.arg(0)?.ensure_string()?),
164                "long_help" => arg.help_long = Some(child.arg(0)?.ensure_string()?),
165                "help_long" => arg.help_long = Some(child.arg(0)?.ensure_string()?),
166                "help_md" => arg.help_md = Some(child.arg(0)?.ensure_string()?),
167                "required" => arg.required = child.arg(0)?.ensure_bool()?,
168                "var" => arg.var = child.arg(0)?.ensure_bool()?,
169                "var_min" => arg.var_min = child.arg(0)?.ensure_usize().map(Some)?,
170                "var_max" => arg.var_max = child.arg(0)?.ensure_usize().map(Some)?,
171                "hide" => arg.hide = child.arg(0)?.ensure_bool()?,
172                "double_dash" => arg.double_dash = child.arg(0)?.ensure_string()?.parse()?,
173                k => bail_parse!(ctx, child.node.name().span(), "unsupported arg child {k}"),
174            }
175        }
176        arg.usage = arg.usage();
177        if let Some(help) = &arg.help {
178            arg.help_first_line = Some(string::first_line(help));
179        }
180        Ok(arg)
181    }
182}
183
184impl SpecArg {
185    pub fn usage(&self) -> String {
186        let name = if self.double_dash == SpecDoubleDashChoices::Required {
187            format!("-- {}", self.name)
188        } else {
189            self.name.clone()
190        };
191        let mut name = if self.required {
192            format!("<{name}>")
193        } else {
194            format!("[{name}]")
195        };
196        if self.var {
197            name = format!("{name}…");
198        }
199        name
200    }
201}
202
203impl From<&SpecArg> for KdlNode {
204    fn from(arg: &SpecArg) -> Self {
205        let mut node = KdlNode::new("arg");
206        node.push(KdlEntry::new(arg.usage()));
207        if let Some(desc) = &arg.help {
208            node.push(string_entry(Some("help"), desc));
209        }
210        if let Some(desc) = &arg.help_long {
211            node.push(string_entry(Some("help_long"), desc));
212        }
213        if let Some(desc) = &arg.help_md {
214            node.push(string_entry(Some("help_md"), desc));
215        }
216        if !arg.required {
217            node.push(KdlEntry::new_prop("required", false));
218        }
219        if arg.double_dash == SpecDoubleDashChoices::Automatic
220            || arg.double_dash == SpecDoubleDashChoices::Preserve
221        {
222            node.push(KdlEntry::new_prop(
223                "double_dash",
224                arg.double_dash.to_string(),
225            ));
226        }
227        if arg.var {
228            node.push(KdlEntry::new_prop("var", true));
229        }
230        if let Some(min) = arg.var_min {
231            node.push(KdlEntry::new_prop("var_min", min as i128));
232        }
233        if let Some(max) = arg.var_max {
234            node.push(KdlEntry::new_prop("var_max", max as i128));
235        }
236        if arg.hide {
237            node.push(KdlEntry::new_prop("hide", true));
238        }
239        // Serialize default values
240        if !arg.default.is_empty() {
241            if arg.default.len() == 1 {
242                // Single value: use property default="bar"
243                node.push(string_entry(Some("default"), &arg.default[0]));
244            } else {
245                // Multiple values: use child node default { "xyz"; "bar" }
246                let children = node.children_mut().get_or_insert_with(KdlDocument::new);
247                let mut default_node = KdlNode::new("default");
248                let default_children = default_node
249                    .children_mut()
250                    .get_or_insert_with(KdlDocument::new);
251                for val in &arg.default {
252                    default_children
253                        .nodes_mut()
254                        .push(KdlNode::new(val.as_str()));
255                }
256                children.nodes_mut().push(default_node);
257            }
258        }
259        if let Some(env) = &arg.env {
260            node.push(string_entry(Some("env"), env));
261        }
262        if let Some(effect) = &arg.effect {
263            node.push(string_entry(Some("effect"), effect.as_str()));
264        }
265        if let Some(choices) = &arg.choices {
266            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
267            children.nodes_mut().push(choices.into());
268        }
269        node
270    }
271}
272
273impl From<&str> for SpecArg {
274    fn from(input: &str) -> Self {
275        let mut arg = SpecArg {
276            name: input.to_string(),
277            required: true,
278            ..Default::default()
279        };
280        // Handle trailing ellipsis: "foo..." or "foo…" or "<foo>..." or "[foo]..."
281        if let Some(name) = arg
282            .name
283            .strip_suffix("...")
284            .or_else(|| arg.name.strip_suffix("…"))
285        {
286            arg.var = true;
287            arg.name = name.to_string();
288        }
289        let first = arg.name.chars().next().unwrap_or_default();
290        let last = arg.name.chars().last().unwrap_or_default();
291        match (first, last) {
292            ('[', ']') => {
293                arg.name = arg.name[1..arg.name.len() - 1].to_string();
294                arg.required = false;
295            }
296            ('<', '>') => {
297                arg.name = arg.name[1..arg.name.len() - 1].to_string();
298            }
299            _ => {}
300        }
301        // Also handle ellipsis inside brackets: "[args...]" or "<args...>"
302        if !arg.var {
303            if let Some(name) = arg
304                .name
305                .strip_suffix("...")
306                .or_else(|| arg.name.strip_suffix("…"))
307            {
308                arg.var = true;
309                arg.name = name.to_string();
310            }
311        }
312        if let Some(name) = arg.name.strip_prefix("-- ") {
313            arg.double_dash = SpecDoubleDashChoices::Required;
314            arg.name = name.to_string();
315        }
316        arg
317    }
318}
319impl FromStr for SpecArg {
320    type Err = UsageErr;
321    fn from_str(input: &str) -> std::result::Result<Self, UsageErr> {
322        Ok(input.into())
323    }
324}
325
326#[cfg(feature = "clap")]
327impl From<&clap::Arg> for SpecArg {
328    fn from(arg: &clap::Arg) -> Self {
329        let required = arg.is_required_set();
330        let help = arg.get_help().map(|s| s.to_string());
331        let help_long = arg.get_long_help().map(|s| s.to_string());
332        let help_first_line = help.as_ref().map(|s| string::first_line(s));
333        let hide = arg.is_hide_set();
334        let var = matches!(
335            arg.get_action(),
336            clap::ArgAction::Count | clap::ArgAction::Append
337        );
338        let choices = arg
339            .get_possible_values()
340            .iter()
341            .flat_map(|v| v.get_name_and_aliases().map(|s| s.to_string()))
342            .collect::<Vec<_>>();
343        let mut arg = Self {
344            name: arg
345                .get_value_names()
346                .unwrap_or_default()
347                .first()
348                .cloned()
349                .unwrap_or_default()
350                .to_string(),
351            usage: "".into(),
352            required,
353            double_dash: if arg.is_last_set() {
354                SpecDoubleDashChoices::Required
355            } else if arg.is_trailing_var_arg_set() {
356                SpecDoubleDashChoices::Automatic
357            } else {
358                SpecDoubleDashChoices::Optional
359            },
360            help,
361            help_long,
362            help_md: None,
363            help_first_line,
364            var,
365            var_max: None,
366            var_min: None,
367            hide,
368            default: arg
369                .get_default_values()
370                .iter()
371                .map(|v| v.to_string_lossy().to_string())
372                .collect(),
373            choices: None,
374            effect: None,
375            env: None,
376        };
377        if !choices.is_empty() {
378            arg.choices = Some(SpecChoices {
379                choices,
380                ..Default::default()
381            });
382        }
383
384        arg
385    }
386}
387
388impl Display for SpecArg {
389    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390        write!(f, "{}", self.usage())
391    }
392}
393impl PartialEq for SpecArg {
394    fn eq(&self, other: &Self) -> bool {
395        self.name == other.name
396    }
397}
398impl Eq for SpecArg {}
399impl Hash for SpecArg {
400    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
401        self.name.hash(state);
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use crate::Spec;
408    use insta::assert_snapshot;
409
410    #[test]
411    fn test_arg_with_env() {
412        let spec = Spec::parse(
413            &Default::default(),
414            r#"
415arg "<input>" env="MY_INPUT" help="Input file"
416arg "<output>" env="MY_OUTPUT"
417            "#,
418        )
419        .unwrap();
420
421        assert_snapshot!(spec, @r#"
422        arg <input> help="Input file" env=MY_INPUT
423        arg <output> env=MY_OUTPUT
424        "#);
425
426        let input_arg = spec.cmd.args.iter().find(|a| a.name == "input").unwrap();
427        assert_eq!(input_arg.env, Some("MY_INPUT".to_string()));
428
429        let output_arg = spec.cmd.args.iter().find(|a| a.name == "output").unwrap();
430        assert_eq!(output_arg.env, Some("MY_OUTPUT".to_string()));
431    }
432
433    #[test]
434    fn test_arg_with_env_child_node() {
435        let spec = Spec::parse(
436            &Default::default(),
437            r#"
438arg "<input>" help="Input file" {
439    env "MY_INPUT"
440}
441arg "<output>" {
442    env "MY_OUTPUT"
443}
444            "#,
445        )
446        .unwrap();
447
448        assert_snapshot!(spec, @r#"
449        arg <input> help="Input file" env=MY_INPUT
450        arg <output> env=MY_OUTPUT
451        "#);
452
453        let input_arg = spec.cmd.args.iter().find(|a| a.name == "input").unwrap();
454        assert_eq!(input_arg.env, Some("MY_INPUT".to_string()));
455
456        let output_arg = spec.cmd.args.iter().find(|a| a.name == "output").unwrap();
457        assert_eq!(output_arg.env, Some("MY_OUTPUT".to_string()));
458    }
459
460    #[test]
461    fn test_arg_variadic_syntax() {
462        use crate::SpecArg;
463
464        // Trailing ellipsis with required brackets
465        let arg: SpecArg = "<files>...".into();
466        assert_eq!(arg.name, "files");
467        assert!(arg.var);
468        assert!(arg.required);
469
470        // Trailing ellipsis with optional brackets
471        let arg: SpecArg = "[files]...".into();
472        assert_eq!(arg.name, "files");
473        assert!(arg.var);
474        assert!(!arg.required);
475
476        // Unicode ellipsis
477        let arg: SpecArg = "<files>…".into();
478        assert_eq!(arg.name, "files");
479        assert!(arg.var);
480
481        let arg: SpecArg = "[files]…".into();
482        assert_eq!(arg.name, "files");
483        assert!(arg.var);
484        assert!(!arg.required);
485
486        // Ellipsis inside brackets: [args...] and <args...>
487        let arg: SpecArg = "[args...]".into();
488        assert_eq!(arg.name, "args");
489        assert!(arg.var);
490        assert!(!arg.required);
491
492        let arg: SpecArg = "<args...>".into();
493        assert_eq!(arg.name, "args");
494        assert!(arg.var);
495        assert!(arg.required);
496
497        // Unicode ellipsis inside brackets
498        let arg: SpecArg = "[args…]".into();
499        assert_eq!(arg.name, "args");
500        assert!(arg.var);
501        assert!(!arg.required);
502    }
503
504    #[test]
505    fn test_arg_child_nodes() {
506        let spec = Spec::parse(
507            &Default::default(),
508            r#"
509arg "<environment>" {
510    help "Deployment environment"
511    choices "dev" "staging" "prod"
512}
513arg "[services]" {
514    help "Services to deploy"
515    var #true
516    var_min 0
517}
518            "#,
519        )
520        .unwrap();
521
522        let env_arg = spec
523            .cmd
524            .args
525            .iter()
526            .find(|a| a.name == "environment")
527            .unwrap();
528        assert_eq!(env_arg.help, Some("Deployment environment".to_string()));
529        assert!(env_arg.choices.is_some());
530
531        let svc_arg = spec.cmd.args.iter().find(|a| a.name == "services").unwrap();
532        assert_eq!(svc_arg.help, Some("Services to deploy".to_string()));
533        assert!(svc_arg.var);
534        assert_eq!(svc_arg.var_min, Some(0));
535    }
536
537    #[test]
538    fn test_arg_long_help_child_node() {
539        let spec = Spec::parse(
540            &Default::default(),
541            r#"
542arg "<input>" {
543    help "Input file"
544    long_help "Extended help text for input"
545}
546            "#,
547        )
548        .unwrap();
549
550        let input_arg = spec.cmd.args.iter().find(|a| a.name == "input").unwrap();
551        assert_eq!(input_arg.help, Some("Input file".to_string()));
552        assert_eq!(
553            input_arg.help_long,
554            Some("Extended help text for input".to_string())
555        );
556    }
557}