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