Skip to main content

usage/spec/
choices.rs

1use crate::kdl::{KdlDocument, KdlEntry, KdlNode};
2use serde::{Deserialize, Serialize};
3use std::cell::{Cell, RefCell};
4use std::collections::HashMap;
5
6use crate::error::UsageErr;
7use crate::spec::context::ParsingContext;
8use crate::spec::helpers::NodeHelper;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[non_exhaustive]
12pub struct SpecChoices {
13    pub choices: Vec<String>,
14    /// Metadata for canonical values that need more than the shorthand string form.
15    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16    pub details: Vec<SpecChoice>,
17    /// Match canonical values and aliases without regard to ASCII case.
18    #[serde(default, skip_serializing_if = "crate::spec::is_false")]
19    pub ignore_case: bool,
20    /// Whether values outside the declared set are rejected.
21    #[serde(
22        default = "default_strict",
23        skip_serializing_if = "crate::spec::is_true"
24    )]
25    pub strict: bool,
26    #[cfg(feature = "unstable_choices_env")]
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub env: Option<String>,
29    /// A shell command whose output lists further values, one per line.
30    ///
31    /// Run only where a program is running: when a value is checked, when a word is being
32    /// completed, and for a help page asked for with [`crate::docs::cli::render_runtime_help`]
33    /// or by `--help` during a parse. Generated documentation and code never run it, and
34    /// describe it instead.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub run: Option<String>,
37}
38
39const fn default_strict() -> bool {
40    true
41}
42
43impl Default for SpecChoices {
44    fn default() -> Self {
45        Self {
46            choices: Vec::new(),
47            details: Vec::new(),
48            ignore_case: false,
49            strict: true,
50            #[cfg(feature = "unstable_choices_env")]
51            env: None,
52            run: None,
53        }
54    }
55}
56
57#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub struct SpecChoice {
59    pub value: String,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub help: Option<String>,
62    #[serde(default, skip_serializing_if = "crate::spec::is_false")]
63    pub hide: bool,
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub aliases: Vec<SpecChoiceAlias>,
66}
67
68#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct SpecChoiceAlias {
70    pub value: String,
71    #[serde(default, skip_serializing_if = "crate::spec::is_false")]
72    pub hide: bool,
73}
74
75impl SpecChoices {
76    /// The set of values an arg or flag accepts.
77    pub fn new(choices: impl IntoIterator<Item = impl Into<String>>) -> Self {
78        Self {
79            choices: choices.into_iter().map(Into::into).collect(),
80            ..Default::default()
81        }
82    }
83}
84
85impl SpecChoices {
86    #[cfg(feature = "unstable_choices_env")]
87    #[must_use]
88    pub fn env(&self) -> Option<&str> {
89        self.env.as_deref()
90    }
91
92    #[cfg(not(feature = "unstable_choices_env"))]
93    #[must_use]
94    pub fn env(&self) -> Option<&str> {
95        None
96    }
97
98    #[cfg(feature = "unstable_choices_env")]
99    pub fn set_env(&mut self, env: Option<String>) {
100        self.env = env;
101    }
102
103    /// The command whose output lists further values, if the choices name one.
104    #[must_use]
105    pub fn run(&self) -> Option<&str> {
106        self.run.as_deref()
107    }
108
109    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
110        let mut config = Self {
111            choices: node
112                .args()
113                .map(|e| e.ensure_string())
114                .collect::<Result<_, _>>()?,
115            ..Default::default()
116        };
117
118        for (k, v) in node.props() {
119            match k {
120                #[cfg(feature = "unstable_choices_env")]
121                "env" => config.set_env(Some(v.ensure_string()?)),
122                "ignore_case" => config.ignore_case = v.ensure_bool()?,
123                "run" => config.run = Some(v.ensure_string()?),
124                "strict" => config.strict = v.ensure_bool()?,
125                k => bail_parse!(ctx, v.entry.span(), "unsupported choices key {k}"),
126            }
127        }
128
129        for choice in node.children() {
130            if choice.name() != "choice" {
131                bail_parse!(
132                    ctx,
133                    choice.node.name().span(),
134                    "a choices block holds `choice` nodes"
135                );
136            }
137            choice.ensure_arg_len(1..=1)?;
138            let value = choice.arg(0)?.ensure_string()?;
139            let mut detail = SpecChoice {
140                value: value.clone(),
141                ..Default::default()
142            };
143            for (key, entry) in choice.props() {
144                match key {
145                    "help" => detail.help = Some(entry.ensure_string()?),
146                    "hide" => detail.hide = entry.ensure_bool()?,
147                    key => bail_parse!(ctx, entry.entry.span(), "unsupported choice key {key}"),
148                }
149            }
150            for alias in choice.children() {
151                if alias.name() != "alias" {
152                    bail_parse!(
153                        ctx,
154                        alias.node.name().span(),
155                        "a choice block holds `alias` nodes"
156                    );
157                }
158                alias.ensure_arg_len(1..=1)?;
159                let mut parsed = SpecChoiceAlias {
160                    value: alias.arg(0)?.ensure_string()?,
161                    ..Default::default()
162                };
163                for (key, entry) in alias.props() {
164                    match key {
165                        "hide" => parsed.hide = entry.ensure_bool()?,
166                        key => bail_parse!(ctx, entry.entry.span(), "unsupported alias key {key}"),
167                    }
168                }
169                if !alias.children().is_empty() {
170                    bail_parse!(ctx, alias.span(), "an alias cannot have children");
171                }
172                detail.aliases.push(parsed);
173            }
174            if config.choices.contains(&value) {
175                bail_parse!(
176                    ctx,
177                    choice.span(),
178                    "choice `{value}` is declared more than once"
179                );
180            }
181            config.choices.push(value);
182            config.details.push(detail);
183        }
184
185        if config.choices.is_empty() && config.run.is_none() {
186            #[cfg(feature = "unstable_choices_env")]
187            if config.env().is_none() {
188                bail_parse!(
189                    ctx,
190                    node.span(),
191                    "choices must have at least 1 argument, an env property, or a run property"
192                );
193            }
194            #[cfg(not(feature = "unstable_choices_env"))]
195            bail_parse!(
196                ctx,
197                node.span(),
198                "choices must have at least 1 argument or a run property"
199            );
200        }
201
202        Ok(config)
203    }
204
205    /// The declared values, and those named by `env=`. Never runs `run=`: see
206    /// [`Self::values_from_run`] and [`Self::resolved_values`] for that.
207    pub fn values(&self) -> Vec<String> {
208        self.values_with_env(None)
209    }
210
211    /// Run `run=` and return the values it printed: one per line, trimmed, with blank lines
212    /// skipped. Empty when the choices name no command.
213    ///
214    /// `env` is added to the command's environment, for a caller parsing against a
215    /// different one (see [`crate::Parser::with_env`]). Within a single parse or help page the
216    /// same command runs once, however many values are checked against it.
217    pub fn values_from_run(
218        &self,
219        env: Option<&HashMap<String, String>>,
220    ) -> Result<Vec<String>, UsageErr> {
221        match &self.run {
222            Some(run) => run_choices(run, env),
223            None => Ok(Vec::new()),
224        }
225    }
226
227    /// Every value these choices offer: the declared ones, `env=`'s, and what `run=` prints,
228    /// without repeats. What completion offers and what a running program's help lists.
229    pub fn resolved_values(
230        &self,
231        env: Option<&HashMap<String, String>>,
232    ) -> Result<Vec<String>, UsageErr> {
233        let mut values = self.values_with_env(env);
234        for value in self.values_from_run(env)? {
235            if !values.contains(&value) {
236                values.push(value);
237            }
238        }
239        Ok(values)
240    }
241
242    /// Whether `value` is one of the choices, running `run=` only when the declared values
243    /// and `env=` do not already accept it.
244    pub(crate) fn matches_resolved(
245        &self,
246        value: &str,
247        env: Option<&HashMap<String, String>>,
248    ) -> Result<bool, UsageErr> {
249        if self.matches_with_env(value, env) {
250            return Ok(true);
251        }
252        Ok(self.matches_values(value, self.values_from_run(env)?))
253    }
254
255    /// Whether `value` is one of the declared values or those named by `env=`. Never runs
256    /// `run=`.
257    pub fn matches(&self, value: &str) -> bool {
258        self.matches_static(value) || self.matches_values(value, self.values_with_env(None))
259    }
260
261    fn matches_static(&self, value: &str) -> bool {
262        let equals = |candidate: &str| {
263            if self.ignore_case {
264                candidate.eq_ignore_ascii_case(value)
265            } else {
266                candidate == value
267            }
268        };
269        self.choices.iter().any(|choice| equals(choice))
270            || self
271                .details
272                .iter()
273                .flat_map(|choice| &choice.aliases)
274                .any(|alias| equals(&alias.value))
275    }
276
277    pub(crate) fn matches_with_env(
278        &self,
279        value: &str,
280        env: Option<&HashMap<String, String>>,
281    ) -> bool {
282        self.matches_static(value) || self.matches_values(value, self.values_with_env(env))
283    }
284
285    fn matches_values(&self, value: &str, values: impl IntoIterator<Item = String>) -> bool {
286        values.into_iter().any(|candidate| {
287            if self.ignore_case {
288                candidate.eq_ignore_ascii_case(value)
289            } else {
290                candidate == value
291            }
292        })
293    }
294
295    pub(crate) fn values_with_env(&self, env: Option<&HashMap<String, String>>) -> Vec<String> {
296        let values = self.visible_declared();
297
298        #[cfg(not(feature = "unstable_choices_env"))]
299        let _ = env;
300
301        #[cfg(feature = "unstable_choices_env")]
302        let values = {
303            let mut values = values;
304            if let Some(env_key) = self.env() {
305                let env_value = if let Some(env_map) = env {
306                    env_map.get(env_key).cloned()
307                } else {
308                    std::env::var(env_key).ok()
309                };
310
311                if let Some(env_value) = env_value {
312                    for choice in env_value
313                        .split(|c: char| c == ',' || c.is_whitespace())
314                        .filter(|choice| !choice.is_empty())
315                    {
316                        let choice = choice.to_string();
317                        if !values.contains(&choice) {
318                            values.push(choice);
319                        }
320                    }
321                }
322            }
323            values
324        };
325
326        values
327    }
328
329    fn visible_declared(&self) -> Vec<String> {
330        let mut values: Vec<String> = self
331            .choices
332            .iter()
333            .filter(|value| {
334                !self
335                    .details
336                    .iter()
337                    .any(|detail| detail.value == (*value).as_str() && detail.hide)
338            })
339            .cloned()
340            .collect();
341        for alias in self
342            .details
343            .iter()
344            .flat_map(|detail| &detail.aliases)
345            .filter(|alias| !alias.hide)
346        {
347            if !values.contains(&alias.value) {
348                values.push(alias.value.clone());
349            }
350        }
351        values
352    }
353
354    /// The choices as a help page lists them: visible values only, and no details.
355    // Only `cli-help` renders help, and without it this is dead code under `-D warnings`.
356    #[cfg(feature = "cli-help")]
357    pub(crate) fn for_help(&self) -> Self {
358        let mut choices = self.clone();
359        choices.choices = self.visible_declared();
360        choices.details.clear();
361        choices
362    }
363
364    /// A help page's choices with `run=`'s output folded into the list, for a program that is
365    /// running. A command that fails leaves `run` in place, so the page says where the values
366    /// come from rather than listing none of them.
367    #[cfg(feature = "cli-help")]
368    pub(crate) fn resolve_for_help(&mut self, env: Option<&HashMap<String, String>>) {
369        if let Ok(found) = self.values_from_run(env) {
370            for value in found {
371                if !self.choices.contains(&value) {
372                    self.choices.push(value);
373                }
374            }
375            self.run = None;
376        }
377    }
378}
379
380/// What each `run=` printed, or why it failed, keyed by the command.
381type RunOutputs = HashMap<String, Result<Vec<String>, String>>;
382
383thread_local! {
384    /// The outputs for the parse or help page in progress. `None` outside one.
385    static RUN_OUTPUTS: RefCell<Option<RunOutputs>> = const { RefCell::new(None) };
386    /// Set while a caller that must not start programs is parsing; see [`NoRunScope`].
387    static RUNS_DISABLED: Cell<bool> = const { Cell::new(false) };
388}
389
390/// Keeps `run=` commands from running until it is dropped.
391///
392/// For a caller that inspects a command line rather than acting on it, such as
393/// `usage explain`, which promises never to start another program: a spec file that
394/// arrived with a bug report should not run its commands on the machine of whoever reads
395/// the report. While it is held, a value the declared choices do not accept is let through
396/// unchecked rather than checked against a command, and help describes the command instead
397/// of listing its output.
398pub(crate) struct NoRunScope {
399    was: bool,
400}
401
402impl NoRunScope {
403    pub(crate) fn enter() -> Self {
404        Self {
405            was: RUNS_DISABLED.with(|disabled| disabled.replace(true)),
406        }
407    }
408}
409
410impl Drop for NoRunScope {
411    fn drop(&mut self) {
412        RUNS_DISABLED.with(|disabled| disabled.set(self.was));
413    }
414}
415
416/// Whether a [`NoRunScope`] is held, so `run=` commands must not start.
417pub(crate) fn runs_disabled() -> bool {
418    RUNS_DISABLED.with(Cell::get)
419}
420
421/// Remembers what `run=` commands printed until it is dropped, so a parse checking five
422/// values against one command runs it once. A scope opened inside another shares it.
423///
424/// Scoped rather than kept on the spec: a long-lived process that parses the same spec
425/// twice should see the command's answer as of each parse, not as of the first.
426pub(crate) struct RunScope {
427    owner: bool,
428}
429
430impl RunScope {
431    pub(crate) fn enter() -> Self {
432        let owner = RUN_OUTPUTS.with(|outputs| {
433            let mut outputs = outputs.borrow_mut();
434            let owner = outputs.is_none();
435            if owner {
436                *outputs = Some(HashMap::new());
437            }
438            owner
439        });
440        Self { owner }
441    }
442}
443
444impl Drop for RunScope {
445    fn drop(&mut self) {
446        if self.owner {
447            RUN_OUTPUTS.with(|outputs| outputs.borrow_mut().take());
448        }
449    }
450}
451
452fn run_choices(run: &str, env: Option<&HashMap<String, String>>) -> Result<Vec<String>, UsageErr> {
453    if runs_disabled() {
454        return Err(UsageErr::ShellError(format!(
455            "`{run}` was not run: commands are disabled for this parse"
456        )));
457    }
458    let cached = RUN_OUTPUTS.with(|outputs| {
459        outputs
460            .borrow()
461            .as_ref()
462            .and_then(|outputs| outputs.get(run).cloned())
463    });
464    let result = match cached {
465        Some(result) => result,
466        None => {
467            let result = crate::sh::sh_with_env(run, env)
468                .map(|stdout| {
469                    let mut values: Vec<String> = Vec::new();
470                    for line in stdout.lines().map(str::trim).filter(|l| !l.is_empty()) {
471                        if !values.iter().any(|v| v == line) {
472                            values.push(line.to_string());
473                        }
474                    }
475                    values
476                })
477                .map_err(|err| err.to_string());
478            RUN_OUTPUTS.with(|outputs| {
479                if let Some(outputs) = outputs.borrow_mut().as_mut() {
480                    outputs.insert(run.to_string(), result.clone());
481                }
482            });
483            result
484        }
485    };
486    result.map_err(UsageErr::ShellError)
487}
488
489impl From<&SpecChoices> for KdlNode {
490    fn from(arg: &SpecChoices) -> Self {
491        let mut node = KdlNode::new("choices");
492        if arg.details.is_empty() {
493            for choice in &arg.choices {
494                node.push(choice.to_string());
495            }
496        } else {
497            let mut children = KdlDocument::new();
498            for value in &arg.choices {
499                let detail = arg.details.iter().find(|detail| detail.value == *value);
500                let mut choice = KdlNode::new("choice");
501                choice.push(crate::spec::helpers::string_entry(None, value));
502                if let Some(detail) = detail {
503                    if let Some(help) = &detail.help {
504                        choice.push(crate::spec::helpers::string_entry(Some("help"), help));
505                    }
506                    if detail.hide {
507                        choice.push(KdlEntry::new_prop("hide", true));
508                    }
509                    if !detail.aliases.is_empty() {
510                        let mut aliases = KdlDocument::new();
511                        for item in &detail.aliases {
512                            let mut alias = KdlNode::new("alias");
513                            alias.push(crate::spec::helpers::string_entry(None, &item.value));
514                            if item.hide {
515                                alias.push(KdlEntry::new_prop("hide", true));
516                            }
517                            aliases.nodes_mut().push(alias);
518                        }
519                        choice.set_children(aliases);
520                    }
521                }
522                children.nodes_mut().push(choice);
523            }
524            node.set_children(children);
525        }
526        if arg.ignore_case {
527            node.push(KdlEntry::new_prop("ignore_case", true));
528        }
529        if !arg.strict {
530            node.push(KdlEntry::new_prop("strict", false));
531        }
532        #[cfg(feature = "unstable_choices_env")]
533        if let Some(env) = arg.env() {
534            node.push(KdlEntry::new_prop("env", env.to_string()));
535        }
536        if let Some(run) = arg.run() {
537            node.push(crate::spec::helpers::string_entry(Some("run"), run));
538        }
539        node
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    #[cfg(feature = "unstable_choices_env")]
546    use super::SpecChoices;
547    #[cfg(feature = "unstable_choices_env")]
548    use std::collections::HashMap;
549
550    #[test]
551    fn rich_choices_round_trip_and_match_their_aliases() {
552        let source = r#"
553name "ex"
554bin "ex"
555arg "<color>" {
556  choices ignore_case=#true {
557    choice "always" help="Always use color" {
558      alias "yes"
559      alias "on" hide=#true
560    }
561    choice "never" hide=#true
562  }
563}
564"#;
565        let spec: crate::Spec = source.parse().unwrap();
566        let choices = spec.cmd.args[0].choices.as_ref().unwrap();
567        assert!(choices.matches("ALWAYS"));
568        assert!(choices.matches("YES"));
569        assert!(choices.matches("ON"));
570        assert_eq!(choices.values(), vec!["always", "yes"]);
571        crate::parse(&spec, &["ex".into(), "YES".into()]).unwrap();
572        crate::parse(&spec, &["ex".into(), "NEVER".into()]).unwrap();
573        assert!(crate::parse(&spec, &["ex".into(), "sometimes".into()]).is_err());
574
575        let rendered = spec.to_string();
576        let reparsed: crate::Spec = rendered.parse().unwrap();
577        let choices = reparsed.cmd.args[0].choices.as_ref().unwrap();
578        assert_eq!(
579            choices.details,
580            spec.cmd.args[0].choices.as_ref().unwrap().details
581        );
582        assert!(choices.ignore_case);
583        #[cfg(feature = "markdown")]
584        assert_eq!(choices.for_help().choices, vec!["always", "yes"]);
585    }
586
587    #[test]
588    fn duplicate_structured_choices_are_rejected() {
589        let source = r#"
590name "ex"
591arg "<color>" {
592  choices {
593    choice "always"
594    choice "always" help="duplicate"
595  }
596}
597"#;
598        let err = format!("{:?}", source.parse::<crate::Spec>().unwrap_err());
599        assert!(err.contains("declared more than once"), "{err}");
600    }
601
602    #[cfg(feature = "unstable_choices_env")]
603    #[test]
604    fn values_with_env_splits_on_commas_and_whitespace() {
605        let mut choices = SpecChoices {
606            choices: vec!["local".into()],
607            ..Default::default()
608        };
609        choices.set_env(Some("DEPLOY_ENVS".into()));
610
611        let env = HashMap::from([("DEPLOY_ENVS".to_string(), "foo,bar baz\nqux".to_string())]);
612
613        assert_eq!(
614            choices.values_with_env(Some(&env)),
615            vec!["local", "foo", "bar", "baz", "qux"]
616        );
617    }
618
619    #[cfg(feature = "unstable_choices_env")]
620    #[test]
621    fn values_with_env_deduplicates_existing_choices() {
622        let mut choices = SpecChoices {
623            choices: vec!["foo".into()],
624            ..Default::default()
625        };
626        choices.set_env(Some("DEPLOY_ENVS".into()));
627
628        let env = HashMap::from([("DEPLOY_ENVS".to_string(), "foo,bar foo".to_string())]);
629
630        assert_eq!(choices.values_with_env(Some(&env)), vec!["foo", "bar"]);
631    }
632
633    #[cfg(feature = "unstable_choices_env")]
634    #[test]
635    fn values_with_env_does_not_fallback_when_custom_env_is_present() {
636        let mut choices = SpecChoices {
637            choices: vec!["local".into()],
638            ..Default::default()
639        };
640        choices.set_env(Some(
641            "USAGE_TEST_CHOICES_ENV_DOES_NOT_EXIST_A5E0F4D1".into(),
642        ));
643
644        assert_eq!(
645            choices.values_with_env(Some(&HashMap::new())),
646            vec!["local"]
647        );
648    }
649
650    #[cfg(feature = "unstable_choices_env")]
651    #[test]
652    fn matches_resolves_env_backed_choices() {
653        const KEY: &str = "USAGE_TEST_MATCHES_CHOICES_ENV_9C47C3C5";
654        let mut choices = SpecChoices {
655            ignore_case: true,
656            ..Default::default()
657        };
658        choices.set_env(Some(KEY.into()));
659        // FIXME: Audit that the environment access only happens in single-threaded code.
660        unsafe { std::env::set_var(KEY, "staging") };
661
662        assert!(choices.matches("STAGING"));
663
664        // FIXME: Audit that the environment access only happens in single-threaded code.
665        unsafe { std::env::remove_var(KEY) };
666    }
667}