Skip to main content

usage/spec/
choices.rs

1use kdl::{KdlDocument, KdlEntry, KdlNode};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5use crate::error::UsageErr;
6use crate::spec::context::ParsingContext;
7use crate::spec::helpers::NodeHelper;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[non_exhaustive]
11pub struct SpecChoices {
12    pub choices: Vec<String>,
13    /// Metadata for canonical values that need more than the shorthand string form.
14    #[serde(default, skip_serializing_if = "Vec::is_empty")]
15    pub details: Vec<SpecChoice>,
16    /// Match canonical values and aliases without regard to ASCII case.
17    #[serde(default, skip_serializing_if = "crate::spec::is_false")]
18    pub ignore_case: bool,
19    /// Whether values outside the declared set are rejected.
20    #[serde(
21        default = "default_strict",
22        skip_serializing_if = "crate::spec::is_true"
23    )]
24    pub strict: bool,
25    #[cfg(feature = "unstable_choices_env")]
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub env: Option<String>,
28}
29
30const fn default_strict() -> bool {
31    true
32}
33
34impl Default for SpecChoices {
35    fn default() -> Self {
36        Self {
37            choices: Vec::new(),
38            details: Vec::new(),
39            ignore_case: false,
40            strict: true,
41            #[cfg(feature = "unstable_choices_env")]
42            env: None,
43        }
44    }
45}
46
47#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
48pub struct SpecChoice {
49    pub value: String,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub help: Option<String>,
52    #[serde(default, skip_serializing_if = "crate::spec::is_false")]
53    pub hide: bool,
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub aliases: Vec<SpecChoiceAlias>,
56}
57
58#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct SpecChoiceAlias {
60    pub value: String,
61    #[serde(default, skip_serializing_if = "crate::spec::is_false")]
62    pub hide: bool,
63}
64
65impl SpecChoices {
66    /// The set of values an arg or flag accepts.
67    pub fn new(choices: impl IntoIterator<Item = impl Into<String>>) -> Self {
68        Self {
69            choices: choices.into_iter().map(Into::into).collect(),
70            ..Default::default()
71        }
72    }
73}
74
75impl SpecChoices {
76    #[cfg(feature = "unstable_choices_env")]
77    #[must_use]
78    pub fn env(&self) -> Option<&str> {
79        self.env.as_deref()
80    }
81
82    #[cfg(not(feature = "unstable_choices_env"))]
83    #[must_use]
84    pub fn env(&self) -> Option<&str> {
85        None
86    }
87
88    #[cfg(feature = "unstable_choices_env")]
89    pub fn set_env(&mut self, env: Option<String>) {
90        self.env = env;
91    }
92
93    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
94        let mut config = Self {
95            choices: node
96                .args()
97                .map(|e| e.ensure_string())
98                .collect::<Result<_, _>>()?,
99            ..Default::default()
100        };
101
102        for (k, v) in node.props() {
103            match k {
104                #[cfg(feature = "unstable_choices_env")]
105                "env" => config.set_env(Some(v.ensure_string()?)),
106                "ignore_case" => config.ignore_case = v.ensure_bool()?,
107                "strict" => config.strict = v.ensure_bool()?,
108                k => bail_parse!(ctx, v.entry.span(), "unsupported choices key {k}"),
109            }
110        }
111
112        for choice in node.children() {
113            if choice.name() != "choice" {
114                bail_parse!(
115                    ctx,
116                    choice.node.name().span(),
117                    "a choices block holds `choice` nodes"
118                );
119            }
120            choice.ensure_arg_len(1..=1)?;
121            let value = choice.arg(0)?.ensure_string()?;
122            let mut detail = SpecChoice {
123                value: value.clone(),
124                ..Default::default()
125            };
126            for (key, entry) in choice.props() {
127                match key {
128                    "help" => detail.help = Some(entry.ensure_string()?),
129                    "hide" => detail.hide = entry.ensure_bool()?,
130                    key => bail_parse!(ctx, entry.entry.span(), "unsupported choice key {key}"),
131                }
132            }
133            for alias in choice.children() {
134                if alias.name() != "alias" {
135                    bail_parse!(
136                        ctx,
137                        alias.node.name().span(),
138                        "a choice block holds `alias` nodes"
139                    );
140                }
141                alias.ensure_arg_len(1..=1)?;
142                let mut parsed = SpecChoiceAlias {
143                    value: alias.arg(0)?.ensure_string()?,
144                    ..Default::default()
145                };
146                for (key, entry) in alias.props() {
147                    match key {
148                        "hide" => parsed.hide = entry.ensure_bool()?,
149                        key => bail_parse!(ctx, entry.entry.span(), "unsupported alias key {key}"),
150                    }
151                }
152                if !alias.children().is_empty() {
153                    bail_parse!(ctx, alias.span(), "an alias cannot have children");
154                }
155                detail.aliases.push(parsed);
156            }
157            if config.choices.contains(&value) {
158                bail_parse!(
159                    ctx,
160                    choice.span(),
161                    "choice `{value}` is declared more than once"
162                );
163            }
164            config.choices.push(value);
165            config.details.push(detail);
166        }
167
168        if config.choices.is_empty() {
169            #[cfg(feature = "unstable_choices_env")]
170            if config.env().is_none() {
171                bail_parse!(
172                    ctx,
173                    node.span(),
174                    "choices must have at least 1 argument or env property"
175                );
176            }
177            #[cfg(not(feature = "unstable_choices_env"))]
178            bail_parse!(ctx, node.span(), "choices must have at least 1 argument");
179        }
180
181        Ok(config)
182    }
183
184    pub fn values(&self) -> Vec<String> {
185        self.values_with_env(None)
186    }
187
188    pub fn matches(&self, value: &str) -> bool {
189        self.matches_static(value) || self.matches_values(value, self.values_with_env(None))
190    }
191
192    fn matches_static(&self, value: &str) -> bool {
193        let equals = |candidate: &str| {
194            if self.ignore_case {
195                candidate.eq_ignore_ascii_case(value)
196            } else {
197                candidate == value
198            }
199        };
200        self.choices.iter().any(|choice| equals(choice))
201            || self
202                .details
203                .iter()
204                .flat_map(|choice| &choice.aliases)
205                .any(|alias| equals(&alias.value))
206    }
207
208    pub(crate) fn matches_with_env(
209        &self,
210        value: &str,
211        env: Option<&HashMap<String, String>>,
212    ) -> bool {
213        self.matches_static(value) || self.matches_values(value, self.values_with_env(env))
214    }
215
216    fn matches_values(&self, value: &str, values: impl IntoIterator<Item = String>) -> bool {
217        values.into_iter().any(|candidate| {
218            if self.ignore_case {
219                candidate.eq_ignore_ascii_case(value)
220            } else {
221                candidate == value
222            }
223        })
224    }
225
226    pub(crate) fn values_with_env(&self, env: Option<&HashMap<String, String>>) -> Vec<String> {
227        let values = self.visible_declared();
228
229        #[cfg(not(feature = "unstable_choices_env"))]
230        let _ = env;
231
232        #[cfg(feature = "unstable_choices_env")]
233        let values = {
234            let mut values = values;
235            if let Some(env_key) = self.env() {
236                let env_value = if let Some(env_map) = env {
237                    env_map.get(env_key).cloned()
238                } else {
239                    std::env::var(env_key).ok()
240                };
241
242                if let Some(env_value) = env_value {
243                    for choice in env_value
244                        .split(|c: char| c == ',' || c.is_whitespace())
245                        .filter(|choice| !choice.is_empty())
246                    {
247                        let choice = choice.to_string();
248                        if !values.contains(&choice) {
249                            values.push(choice);
250                        }
251                    }
252                }
253            }
254            values
255        };
256
257        values
258    }
259
260    fn visible_declared(&self) -> Vec<String> {
261        let mut values: Vec<String> = self
262            .choices
263            .iter()
264            .filter(|value| {
265                !self
266                    .details
267                    .iter()
268                    .any(|detail| detail.value == (*value).as_str() && detail.hide)
269            })
270            .cloned()
271            .collect();
272        for alias in self
273            .details
274            .iter()
275            .flat_map(|detail| &detail.aliases)
276            .filter(|alias| !alias.hide)
277        {
278            if !values.contains(&alias.value) {
279                values.push(alias.value.clone());
280            }
281        }
282        values
283    }
284
285    /// The choices as a help page lists them: visible values only, and no details.
286    // Only `docs` renders help, and without it this is dead code that `-D warnings` fails on.
287    #[cfg(feature = "docs")]
288    pub(crate) fn for_help(&self) -> Self {
289        let mut choices = self.clone();
290        choices.choices = self.visible_declared();
291        choices.details.clear();
292        choices
293    }
294}
295
296impl From<&SpecChoices> for KdlNode {
297    fn from(arg: &SpecChoices) -> Self {
298        let mut node = KdlNode::new("choices");
299        if arg.details.is_empty() {
300            for choice in &arg.choices {
301                node.push(choice.to_string());
302            }
303        } else {
304            let mut children = KdlDocument::new();
305            for value in &arg.choices {
306                let detail = arg.details.iter().find(|detail| detail.value == *value);
307                let mut choice = KdlNode::new("choice");
308                choice.push(crate::spec::helpers::string_entry(None, value));
309                if let Some(detail) = detail {
310                    if let Some(help) = &detail.help {
311                        choice.push(crate::spec::helpers::string_entry(Some("help"), help));
312                    }
313                    if detail.hide {
314                        choice.push(KdlEntry::new_prop("hide", true));
315                    }
316                    if !detail.aliases.is_empty() {
317                        let mut aliases = KdlDocument::new();
318                        for item in &detail.aliases {
319                            let mut alias = KdlNode::new("alias");
320                            alias.push(crate::spec::helpers::string_entry(None, &item.value));
321                            if item.hide {
322                                alias.push(KdlEntry::new_prop("hide", true));
323                            }
324                            aliases.nodes_mut().push(alias);
325                        }
326                        choice.set_children(aliases);
327                    }
328                }
329                children.nodes_mut().push(choice);
330            }
331            node.set_children(children);
332        }
333        if arg.ignore_case {
334            node.push(KdlEntry::new_prop("ignore_case", true));
335        }
336        if !arg.strict {
337            node.push(KdlEntry::new_prop("strict", false));
338        }
339        #[cfg(feature = "unstable_choices_env")]
340        if let Some(env) = arg.env() {
341            node.push(KdlEntry::new_prop("env", env.to_string()));
342        }
343        node
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    #[cfg(feature = "unstable_choices_env")]
350    use super::SpecChoices;
351    #[cfg(feature = "unstable_choices_env")]
352    use std::collections::HashMap;
353
354    #[test]
355    fn rich_choices_round_trip_and_match_their_aliases() {
356        let source = r#"
357name "ex"
358bin "ex"
359arg "<color>" {
360  choices ignore_case=#true {
361    choice "always" help="Always use color" {
362      alias "yes"
363      alias "on" hide=#true
364    }
365    choice "never" hide=#true
366  }
367}
368"#;
369        let spec: crate::Spec = source.parse().unwrap();
370        let choices = spec.cmd.args[0].choices.as_ref().unwrap();
371        assert!(choices.matches("ALWAYS"));
372        assert!(choices.matches("YES"));
373        assert!(choices.matches("ON"));
374        assert_eq!(choices.values(), vec!["always", "yes"]);
375        crate::parse(&spec, &["ex".into(), "YES".into()]).unwrap();
376        crate::parse(&spec, &["ex".into(), "NEVER".into()]).unwrap();
377        assert!(crate::parse(&spec, &["ex".into(), "sometimes".into()]).is_err());
378
379        let rendered = spec.to_string();
380        let reparsed: crate::Spec = rendered.parse().unwrap();
381        let choices = reparsed.cmd.args[0].choices.as_ref().unwrap();
382        assert_eq!(
383            choices.details,
384            spec.cmd.args[0].choices.as_ref().unwrap().details
385        );
386        assert!(choices.ignore_case);
387        #[cfg(feature = "docs")]
388        assert_eq!(choices.for_help().choices, vec!["always", "yes"]);
389    }
390
391    #[test]
392    fn duplicate_structured_choices_are_rejected() {
393        let source = r#"
394name "ex"
395arg "<color>" {
396  choices {
397    choice "always"
398    choice "always" help="duplicate"
399  }
400}
401"#;
402        let err = format!("{:?}", source.parse::<crate::Spec>().unwrap_err());
403        assert!(err.contains("declared more than once"), "{err}");
404    }
405
406    #[cfg(feature = "unstable_choices_env")]
407    #[test]
408    fn values_with_env_splits_on_commas_and_whitespace() {
409        let mut choices = SpecChoices {
410            choices: vec!["local".into()],
411            ..Default::default()
412        };
413        choices.set_env(Some("DEPLOY_ENVS".into()));
414
415        let env = HashMap::from([("DEPLOY_ENVS".to_string(), "foo,bar baz\nqux".to_string())]);
416
417        assert_eq!(
418            choices.values_with_env(Some(&env)),
419            vec!["local", "foo", "bar", "baz", "qux"]
420        );
421    }
422
423    #[cfg(feature = "unstable_choices_env")]
424    #[test]
425    fn values_with_env_deduplicates_existing_choices() {
426        let mut choices = SpecChoices {
427            choices: vec!["foo".into()],
428            ..Default::default()
429        };
430        choices.set_env(Some("DEPLOY_ENVS".into()));
431
432        let env = HashMap::from([("DEPLOY_ENVS".to_string(), "foo,bar foo".to_string())]);
433
434        assert_eq!(choices.values_with_env(Some(&env)), vec!["foo", "bar"]);
435    }
436
437    #[cfg(feature = "unstable_choices_env")]
438    #[test]
439    fn values_with_env_does_not_fallback_when_custom_env_is_present() {
440        let mut choices = SpecChoices {
441            choices: vec!["local".into()],
442            ..Default::default()
443        };
444        choices.set_env(Some(
445            "USAGE_TEST_CHOICES_ENV_DOES_NOT_EXIST_A5E0F4D1".into(),
446        ));
447
448        assert_eq!(
449            choices.values_with_env(Some(&HashMap::new())),
450            vec!["local"]
451        );
452    }
453
454    #[cfg(feature = "unstable_choices_env")]
455    #[test]
456    fn matches_resolves_env_backed_choices() {
457        const KEY: &str = "USAGE_TEST_MATCHES_CHOICES_ENV_9C47C3C5";
458        let mut choices = SpecChoices {
459            ignore_case: true,
460            ..Default::default()
461        };
462        choices.set_env(Some(KEY.into()));
463        std::env::set_var(KEY, "staging");
464
465        assert!(choices.matches("STAGING"));
466
467        std::env::remove_var(KEY);
468    }
469}