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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
15 pub details: Vec<SpecChoice>,
16 #[serde(default, skip_serializing_if = "crate::spec::is_false")]
18 pub ignore_case: bool,
19 #[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 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 #[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}