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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
16 pub details: Vec<SpecChoice>,
17 #[serde(default, skip_serializing_if = "crate::spec::is_false")]
19 pub ignore_case: bool,
20 #[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 #[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 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 #[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 pub fn values(&self) -> Vec<String> {
208 self.values_with_env(None)
209 }
210
211 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 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 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 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 #[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 #[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
380type RunOutputs = HashMap<String, Result<Vec<String>, String>>;
382
383thread_local! {
384 static RUN_OUTPUTS: RefCell<Option<RunOutputs>> = const { RefCell::new(None) };
386 static RUNS_DISABLED: Cell<bool> = const { Cell::new(false) };
388}
389
390pub(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
416pub(crate) fn runs_disabled() -> bool {
418 RUNS_DISABLED.with(Cell::get)
419}
420
421pub(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 unsafe { std::env::set_var(KEY, "staging") };
661
662 assert!(choices.matches("STAGING"));
663
664 unsafe { std::env::remove_var(KEY) };
666 }
667}