1use super::error::{duplicate_error, invalid_value, missing_value};
2use super::help::combination_usage;
3use super::*;
4use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsString;
6use std::path::{Path, PathBuf};
7
8#[derive(Clone, Debug)]
10pub struct BuiltCliSpec {
11 pub(super) spec: CliSpec,
12}
13
14impl BuiltCliSpec {
15 pub fn spec(&self) -> &CliSpec {
16 &self.spec
17 }
18
19 pub fn resolve_from<I, S>(&self, args: I) -> Result<CliOutcome, CliError>
20 where
21 I: IntoIterator<Item = S>,
22 S: Into<OsString>,
23 {
24 let raw: Vec<OsString> = args.into_iter().map(Into::into).collect();
25 self.resolve_os(&raw)
26 }
27
28 pub fn synthetic_invocations(&self) -> Vec<SyntheticInvocation> {
32 let mut fixtures = Vec::new();
33 for command in &self.spec.commands {
34 for combination in &command.combinations {
35 let mut variants = vec![vec![self.spec.name.clone()]];
36 for part in &command.command_path {
37 for argv in &mut variants {
38 argv.push(part.clone());
39 }
40 }
41 for argument in &command.arguments {
42 let values: Vec<String> =
43 if let Some(fixed) = combination.fixed.get(&argument.argument_id) {
44 fixed.values().to_vec()
45 } else if combination
46 .required
47 .iter()
48 .any(|id| id == &argument.argument_id)
49 {
50 vec![synthetic_value(argument)]
51 } else {
52 continue;
53 };
54 let mut expanded = Vec::new();
55 for argv in variants {
56 for value in &values {
57 let mut candidate = argv.clone();
58 append_synthetic_argument(&mut candidate, argument, value);
59 expanded.push(candidate);
60 }
61 }
62 variants = expanded;
63 }
64 fixtures.extend(variants.into_iter().map(|argv| SyntheticInvocation {
65 command_path: command.command_path.clone(),
66 combination_id: combination.combination_id.clone(),
67 argv,
68 }));
69 }
70 }
71 fixtures
72 }
73
74 pub fn help(&self, command_path: &[String]) -> Option<CliHelpV2> {
81 let command = self
82 .spec
83 .commands
84 .iter()
85 .find(|candidate| candidate.command_path == command_path)?;
86 Some(self.help_model(command))
87 }
88
89 pub fn bind_actions<R, I, S>(&self, handlers: I) -> Result<BoundCliSpec<R>, CliSpecError>
91 where
92 I: IntoIterator<Item = (S, fn(&ResolvedInvocation) -> R)>,
93 S: Into<String>,
94 {
95 let expected: BTreeSet<&str> = self
96 .spec
97 .commands
98 .iter()
99 .flat_map(|command| command.combinations.iter())
100 .map(|combination| combination.action_id.as_str())
101 .collect();
102 let mut actual = BTreeMap::new();
103 for (action_id, handler) in handlers {
104 let action_id = action_id.into();
105 if actual.insert(action_id.clone(), handler).is_some() {
106 return Err(CliSpecError::new(
107 "duplicate_action_handler",
108 format!("action `{action_id}` has more than one handler"),
109 ));
110 }
111 }
112 let actual_ids: BTreeSet<&str> = actual.keys().map(String::as_str).collect();
113 if expected != actual_ids {
114 let missing: Vec<&str> = expected.difference(&actual_ids).copied().collect();
115 let extra: Vec<&str> = actual_ids.difference(&expected).copied().collect();
116 return Err(CliSpecError::new(
117 "action_handler_coverage",
118 format!("handler coverage mismatch; missing={missing:?}, extra={extra:?}"),
119 ));
120 }
121 Ok(BoundCliSpec {
122 cli: self.clone(),
123 handlers: actual,
124 })
125 }
126
127 fn resolve_os(&self, raw: &[OsString]) -> Result<CliOutcome, CliError> {
128 let mut utf8 = Vec::with_capacity(raw.len());
129 for token in raw {
130 let Some(token) = token.to_str() else {
131 return Err(CliError::new(
132 CliErrorRule::InvalidUtf8,
133 self.spec.name.clone(),
134 Vec::new(),
135 "argv contains a token that is not valid UTF-8",
136 ));
137 };
138 utf8.push(token.to_string());
139 }
140 let argv = if utf8.is_empty() { &[][..] } else { &utf8[1..] };
141 let (command, consumed) = self.select_command(argv)?;
142 let command_path = self.display_command_path(command);
143 let parsed = tokenize(command, &argv[consumed..], &command_path)?;
144
145 if parsed.control_count() > 0 {
146 if parsed.control_count() != 1 || !parsed.application_values.is_empty() {
147 return Err(CliError::unregistered(
148 command_path,
149 parsed.explicit_names(),
150 ));
151 }
152 if parsed.docs {
157 if !command.command_path.is_empty() {
158 return Err(CliError::unregistered(
159 command_path,
160 vec!["--docs".to_string()],
161 ));
162 }
163 let contract = OutputSpec::raw()
164 .file_sinks(self.spec.lifecycle_output.file_sinks_ref().to_vec());
165 let output = resolve_output(
166 &contract,
167 &parsed.output,
168 &command_path,
169 parsed.explicit_names(),
170 )?;
171 return Ok(CliOutcome::Docs(ResolvedDocs { output }));
172 }
173 let output = resolve_output(
174 &self.spec.lifecycle_output,
175 &parsed.output,
176 &command_path,
177 parsed.explicit_names(),
178 )?;
179 if parsed.help {
180 return Ok(CliOutcome::Help(ResolvedHelp {
181 model: self.help_model(command),
182 output,
183 }));
184 }
185 if parsed.version {
186 if !command.command_path.is_empty() {
187 return Err(CliError::unregistered(
188 command_path,
189 vec!["--version".to_string()],
190 ));
191 }
192 return Ok(CliOutcome::Version(ResolvedVersion {
193 name: self.spec.name.clone(),
194 version: self.spec.version.clone(),
195 display_name: self.spec.display_name.clone(),
196 build: self.spec.build.clone(),
197 output,
198 }));
199 }
200 }
201
202 let matching: Vec<&Combination> = command
203 .combinations
204 .iter()
205 .filter(|combination| combination_matches(command, combination, &parsed))
206 .collect();
207 let Some(combination) = matching.first().copied() else {
208 return Err(CliError::unregistered(
209 command_path,
210 parsed.explicit_names(),
211 ));
212 };
213 if matching.len() != 1 {
214 return Err(CliError::new(
215 CliErrorRule::UnregisteredCombination,
216 command_path,
217 parsed.explicit_names(),
218 "arguments match more than one registered CLI combination",
219 ));
220 }
221 let output = resolve_output(
222 &combination.output,
223 &parsed.output,
224 &command_path,
225 parsed.explicit_names(),
226 )?;
227 let values = project_values(command, combination, &parsed);
228 Ok(CliOutcome::Run(ResolvedInvocation {
229 command_path: command.command_path.clone(),
230 action_id: combination.action_id.clone(),
231 combination_id: combination.combination_id.clone(),
232 values,
233 explicit_argument_ids: parsed.explicit_application_ids,
234 output,
235 }))
236 }
237
238 fn select_command<'a>(&'a self, argv: &[String]) -> Result<(&'a CommandSpec, usize), CliError> {
239 let mut commands: Vec<&CommandSpec> = self.spec.commands.iter().collect();
240 commands.sort_by_key(|command| std::cmp::Reverse(command.command_path.len()));
241 if let Some(command) = commands.iter().copied().find(|command| {
242 command.command_path.len() <= argv.len()
243 && command
244 .command_path
245 .iter()
246 .zip(argv)
247 .all(|(expected, actual)| expected == actual)
248 }) {
249 let remaining = &argv[command.command_path.len()..];
250 let has_children = self.spec.commands.iter().any(|candidate| {
251 candidate.command_path.len() > command.command_path.len()
252 && candidate.command_path.starts_with(&command.command_path)
253 });
254 if remaining
255 .first()
256 .is_some_and(|token| !token.starts_with('-'))
257 && has_children
258 {
259 return Err(CliError::new(
260 CliErrorRule::UnknownCommand,
261 self.display_command_path(command),
262 Vec::new(),
263 format!("unknown command `{}`", remaining[0]),
264 ));
265 }
266 return Ok((command, command.command_path.len()));
267 }
268 Err(CliError::new(
269 CliErrorRule::UnknownCommand,
270 self.spec.name.clone(),
271 Vec::new(),
272 "unknown command",
273 ))
274 }
275
276 fn display_command_path(&self, command: &CommandSpec) -> String {
277 std::iter::once(self.spec.name.as_str())
278 .chain(command.command_path.iter().map(String::as_str))
279 .collect::<Vec<_>>()
280 .join(" ")
281 }
282
283 fn child_help_commands(&self, command: &CommandSpec) -> Vec<String> {
284 let mut children: Vec<Vec<String>> = self
285 .spec
286 .commands
287 .iter()
288 .filter(|candidate| {
289 candidate.command_path.len() == command.command_path.len() + 1
290 && candidate.command_path.starts_with(&command.command_path)
291 })
292 .map(|candidate| candidate.command_path.clone())
293 .collect();
294 children.sort();
295 children
296 .into_iter()
297 .map(|path| format!("{} {} --help", self.spec.name, path.join(" ")))
298 .collect()
299 }
300
301 fn help_model(&self, command: &CommandSpec) -> CliHelpV2 {
302 let command_path = self.display_command_path(command);
303 let mut shapes = Vec::new();
304 let mut notes = BTreeMap::new();
305 let mut defaults = BTreeMap::new();
306 for combination in &command.combinations {
307 let (usage, shape_notes, shape_defaults) =
308 combination_usage(command, combination, &command_path, true);
309 shapes.push(CliShape {
310 id: combination.combination_id.clone(),
311 about: if command.combinations.len() == 1 {
314 None
315 } else {
316 combination.about.clone()
317 },
318 usage,
319 });
320 notes.extend(shape_notes);
324 defaults.extend(shape_defaults);
325 }
326 CliHelpV2 {
327 schema: "cli-help-v2".to_string(),
328 command_path,
329 about: command.about.clone().or_else(|| {
333 command
334 .command_path
335 .is_empty()
336 .then(|| self.spec.about.clone())
337 .flatten()
338 }),
339 shapes,
340 subcommands: self.child_help_commands(command),
341 notes,
342 defaults,
343 }
344 }
345}
346
347#[derive(Clone, Debug, PartialEq, Eq)]
349pub struct SyntheticInvocation {
350 pub command_path: Vec<String>,
351 pub combination_id: String,
352 pub argv: Vec<String>,
353}
354
355pub struct BoundCliSpec<R> {
357 cli: BuiltCliSpec,
358 handlers: BTreeMap<String, fn(&ResolvedInvocation) -> R>,
359}
360
361impl<R> BoundCliSpec<R> {
362 pub fn resolve_from<I, S>(&self, args: I) -> Result<CliOutcome, CliError>
363 where
364 I: IntoIterator<Item = S>,
365 S: Into<OsString>,
366 {
367 self.cli.resolve_from(args)
368 }
369
370 #[allow(clippy::expect_used)]
376 pub fn execute(&self, invocation: &ResolvedInvocation) -> R {
377 let handler = self
378 .handlers
379 .get(invocation.action_id())
380 .expect("bind_actions guarantees one handler per action id");
381 handler(invocation)
382 }
383}
384
385#[derive(Clone, Debug, PartialEq)]
387pub enum CliOutcome {
388 Run(ResolvedInvocation),
389 Help(ResolvedHelp),
390 Version(ResolvedVersion),
391 Docs(ResolvedDocs),
392}
393
394#[derive(Clone, Debug, PartialEq)]
396pub struct ResolvedInvocation {
397 pub(super) command_path: Vec<String>,
398 pub(super) action_id: String,
399 pub(super) combination_id: String,
400 pub(super) values: BTreeMap<String, CliValue>,
401 pub(super) explicit_argument_ids: BTreeSet<String>,
402 pub(super) output: OutputPlan,
403}
404
405const MISSING: CliValue = CliValue::Bool(false);
411
412impl ResolvedInvocation {
413 pub fn command_path(&self) -> &[String] {
414 &self.command_path
415 }
416
417 pub fn action_id(&self) -> &str {
418 &self.action_id
419 }
420
421 pub fn combination_id(&self) -> &str {
422 &self.combination_id
423 }
424
425 pub fn output_plan(&self) -> &OutputPlan {
426 &self.output
427 }
428
429 pub fn was_explicit(&self, argument_id: &str) -> bool {
432 self.explicit_argument_ids.contains(argument_id)
433 }
434
435 pub fn optional(&self, argument_id: &str) -> Option<&CliValue> {
436 self.values.get(argument_id)
437 }
438
439 pub fn required(&self, argument_id: &str) -> &CliValue {
440 self.values.get(argument_id).unwrap_or(&MISSING)
441 }
442
443 pub fn repeated(&self, argument_id: &str) -> &[CliValue] {
444 self.values
445 .get(argument_id)
446 .and_then(CliValue::as_list)
447 .unwrap_or(&[])
448 }
449}
450
451#[derive(Clone, Debug, PartialEq, Eq)]
453pub enum OutputPlan {
454 Raw {
455 stdout_file: Option<PathBuf>,
456 stderr_file: Option<PathBuf>,
457 },
458 Protocol {
459 lifecycle: OutputLifecycle,
460 format: String,
461 destination: String,
462 stdout_file: Option<PathBuf>,
463 stderr_file: Option<PathBuf>,
464 },
465}
466
467impl OutputPlan {
468 pub fn format(&self) -> Option<&str> {
469 match self {
470 Self::Raw { .. } => None,
471 Self::Protocol { format, .. } => Some(format),
472 }
473 }
474
475 pub fn destination(&self) -> Option<&str> {
476 match self {
477 Self::Raw { .. } => None,
478 Self::Protocol { destination, .. } => Some(destination),
479 }
480 }
481
482 pub fn stdout_file(&self) -> Option<&Path> {
483 match self {
484 Self::Raw { stdout_file, .. } | Self::Protocol { stdout_file, .. } => {
485 stdout_file.as_deref()
486 }
487 }
488 }
489
490 pub fn stderr_file(&self) -> Option<&Path> {
491 match self {
492 Self::Raw { stderr_file, .. } | Self::Protocol { stderr_file, .. } => {
493 stderr_file.as_deref()
494 }
495 }
496 }
497}
498
499#[derive(Default)]
500struct ParsedArgs {
501 application_values: BTreeMap<String, Vec<CliValue>>,
502 explicit_application_ids: BTreeSet<String>,
503 explicit_application_names: BTreeMap<String, String>,
504 output: ParsedOutput,
505 help: bool,
506 version: bool,
507 docs: bool,
508}
509
510impl ParsedArgs {
511 fn control_count(&self) -> usize {
512 usize::from(self.help) + usize::from(self.version) + usize::from(self.docs)
513 }
514
515 fn explicit_names(&self) -> Vec<String> {
516 let mut names: Vec<String> = self.explicit_application_names.values().cloned().collect();
517 names.extend(self.output.explicit_names());
518 if self.help {
519 names.push("--help".to_string());
520 }
521 if self.version {
522 names.push("--version".to_string());
523 }
524 if self.docs {
525 names.push("--docs".to_string());
526 }
527 names.sort();
528 names.dedup();
529 names
530 }
531}
532
533#[derive(Default)]
534struct ParsedOutput {
535 format: Option<String>,
536 destination: Option<String>,
537 stdout_file: Option<PathBuf>,
538 stderr_file: Option<PathBuf>,
539}
540
541impl ParsedOutput {
542 fn explicit_names(&self) -> Vec<String> {
543 let mut names = Vec::new();
544 if self.format.is_some() {
545 names.push("--output".to_string());
546 }
547 if self.destination.is_some() {
548 names.push("--output-to".to_string());
549 }
550 if self.stdout_file.is_some() {
551 names.push("--stdout-file".to_string());
552 }
553 if self.stderr_file.is_some() {
554 names.push("--stderr-file".to_string());
555 }
556 names
557 }
558}
559fn tokenize(
560 command: &CommandSpec,
561 tokens: &[String],
562 command_path: &str,
563) -> Result<ParsedArgs, CliError> {
564 let longs: BTreeMap<&str, &ArgSpec> = command
565 .arguments
566 .iter()
567 .filter_map(|argument| match &argument.syntax {
568 ArgSyntax::Long { name } => Some((name.as_str(), argument)),
569 ArgSyntax::Positional { .. } => None,
570 })
571 .collect();
572 let mut positionals: Vec<&ArgSpec> = command
573 .arguments
574 .iter()
575 .filter(|argument| matches!(argument.syntax, ArgSyntax::Positional { .. }))
576 .collect();
577 positionals.sort_by_key(|argument| match argument.syntax {
578 ArgSyntax::Positional { index } => index,
579 ArgSyntax::Long { .. } => usize::MAX,
580 });
581
582 let mut parsed = ParsedArgs::default();
583 let mut index = 0;
584 let mut positional_index = 0;
585 let mut options_done = false;
586 while index < tokens.len() {
587 let token = &tokens[index];
588 if !options_done && token == "--" {
589 options_done = true;
590 index += 1;
591 continue;
592 }
593 if !options_done && token.starts_with("--") {
594 let (name, inline_value) = token
595 .split_once('=')
596 .map_or((token.as_str(), None), |(name, value)| (name, Some(value)));
597 if let Some(argument) = longs.get(name).copied() {
598 let display = name.to_string();
599 let raw_value = if argument.value_type == ArgValueType::Flag {
600 if inline_value.is_some() {
601 return Err(invalid_value(
602 command_path,
603 display,
604 "flags do not accept values",
605 ));
606 }
607 None
608 } else {
609 Some(take_value(
610 tokens,
611 &mut index,
612 inline_value,
613 name,
614 command_path,
615 )?)
616 };
617 let value = match raw_value {
618 Some(value) => parse_value(argument, value).map_err(|message| {
619 invalid_value(command_path, display.clone(), &message)
620 })?,
621 None => CliValue::Bool(true),
622 };
623 insert_application(&mut parsed, argument, value, display, command_path)?;
624 index += 1;
625 continue;
626 }
627 match name {
628 "--help" => {
629 reject_inline_value(inline_value, name, command_path)?;
630 set_once(&mut parsed.help, name, command_path)?;
631 }
632 "--version" => {
633 reject_inline_value(inline_value, name, command_path)?;
634 set_once(&mut parsed.version, name, command_path)?;
635 }
636 "--docs" => {
637 reject_inline_value(inline_value, name, command_path)?;
638 set_once(&mut parsed.docs, name, command_path)?;
639 }
640 "--output" => {
641 parsed.output.format = Some(set_output_value(
642 parsed.output.format.as_ref(),
643 take_value(tokens, &mut index, inline_value, name, command_path)?,
644 name,
645 command_path,
646 )?);
647 }
648 "--output-to" => {
649 parsed.output.destination = Some(set_output_value(
650 parsed.output.destination.as_ref(),
651 take_value(tokens, &mut index, inline_value, name, command_path)?,
652 name,
653 command_path,
654 )?);
655 }
656 "--stdout-file" => {
657 parsed.output.stdout_file = Some(PathBuf::from(set_output_value(
658 parsed.output.stdout_file.as_ref(),
659 take_value(tokens, &mut index, inline_value, name, command_path)?,
660 name,
661 command_path,
662 )?));
663 }
664 "--stderr-file" => {
665 parsed.output.stderr_file = Some(PathBuf::from(set_output_value(
666 parsed.output.stderr_file.as_ref(),
667 take_value(tokens, &mut index, inline_value, name, command_path)?,
668 name,
669 command_path,
670 )?));
671 }
672 _ => {
673 return Err(CliError::new(
674 CliErrorRule::UnknownArgument,
675 command_path.to_string(),
676 vec![name.to_string()],
677 format!("unknown argument `{name}`"),
678 ));
679 }
680 }
681 index += 1;
682 continue;
683 }
684 if !options_done && token.starts_with('-') && token != "-" {
685 return Err(CliError::new(
686 CliErrorRule::UnknownArgument,
687 command_path.to_string(),
688 vec![token.to_string()],
689 format!("unknown argument `{token}`"),
690 ));
691 }
692 let Some(argument) = positionals.get(positional_index).copied() else {
693 return Err(CliError::new(
694 CliErrorRule::UnexpectedPositional,
695 command_path.to_string(),
696 Vec::new(),
697 "unexpected positional argument",
698 ));
699 };
700 let value = parse_value(argument, token).map_err(|message| {
701 invalid_value(command_path, argument.argument_id.clone(), &message)
702 })?;
703 insert_application(
704 &mut parsed,
705 argument,
706 value,
707 argument.argument_id.clone(),
708 command_path,
709 )?;
710 if !argument.repeatable {
711 positional_index += 1;
712 }
713 index += 1;
714 }
715 Ok(parsed)
716}
717
718fn take_value<'a>(
719 tokens: &'a [String],
720 index: &mut usize,
721 inline_value: Option<&'a str>,
722 name: &str,
723 command_path: &str,
724) -> Result<&'a str, CliError> {
725 if let Some(value) = inline_value {
726 if value.is_empty() {
727 return Err(missing_value(command_path, name));
728 }
729 return Ok(value);
730 }
731 let Some(value) = tokens.get(*index + 1) else {
732 return Err(missing_value(command_path, name));
733 };
734 if value.starts_with('-') && value != "-" {
735 return Err(missing_value(command_path, name));
736 }
737 *index += 1;
738 Ok(value)
739}
740
741fn reject_inline_value(
742 inline_value: Option<&str>,
743 name: &str,
744 command_path: &str,
745) -> Result<(), CliError> {
746 if inline_value.is_some() {
747 return Err(invalid_value(
748 command_path,
749 name.to_string(),
750 "control flags do not accept values",
751 ));
752 }
753 Ok(())
754}
755
756fn set_once(value: &mut bool, name: &str, command_path: &str) -> Result<(), CliError> {
757 if *value {
758 return Err(duplicate_error(command_path, name));
759 }
760 *value = true;
761 Ok(())
762}
763
764fn set_output_value<T>(
765 existing: Option<&T>,
766 value: &str,
767 name: &str,
768 command_path: &str,
769) -> Result<String, CliError> {
770 if existing.is_some() {
771 return Err(duplicate_error(command_path, name));
772 }
773 Ok(value.to_string())
774}
775
776fn insert_application(
777 parsed: &mut ParsedArgs,
778 argument: &ArgSpec,
779 value: CliValue,
780 display: String,
781 command_path: &str,
782) -> Result<(), CliError> {
783 let values = parsed
784 .application_values
785 .entry(argument.argument_id.clone())
786 .or_default();
787 if !argument.repeatable && !values.is_empty() {
788 return Err(duplicate_error(command_path, &display));
789 }
790 values.push(value);
791 parsed
792 .explicit_application_ids
793 .insert(argument.argument_id.clone());
794 parsed
795 .explicit_application_names
796 .insert(argument.argument_id.clone(), display);
797 Ok(())
798}
799
800fn parse_value(argument: &ArgSpec, raw: &str) -> Result<CliValue, String> {
801 match argument.value_type {
802 ArgValueType::Flag => Ok(CliValue::Bool(true)),
803 ArgValueType::String | ArgValueType::Enum => {
804 if argument.value_type == ArgValueType::Enum
805 && !argument.enum_values.iter().any(|value| value == raw)
806 {
807 return Err(format!(
808 "expected one of {}",
809 argument.enum_values.join(", ")
810 ));
811 }
812 Ok(CliValue::String(raw.to_string()))
813 }
814 ArgValueType::I64 => raw
815 .parse::<i64>()
816 .map(CliValue::I64)
817 .map_err(|_| "expected an i64 integer".to_string()),
818 ArgValueType::FiniteF64 => raw
819 .parse::<f64>()
820 .ok()
821 .filter(|value| value.is_finite())
822 .map(CliValue::FiniteF64)
823 .ok_or_else(|| "expected a finite f64 number".to_string()),
824 ArgValueType::Json => serde_json::from_str::<serde::de::IgnoredAny>(raw)
829 .map(|_| CliValue::Json(raw.to_string()))
830 .map_err(|_| "expected one valid JSON value".to_string()),
831 }
832}
833
834pub(super) fn validate_value_type(argument: &ArgSpec, value: &CliValue) -> Result<(), String> {
835 match (&argument.value_type, value) {
836 (ArgValueType::Flag, CliValue::Bool(_))
837 | (ArgValueType::String, CliValue::String(_))
838 | (ArgValueType::I64, CliValue::I64(_))
839 | (ArgValueType::Json, CliValue::Json(_)) => Ok(()),
840 (ArgValueType::FiniteF64, CliValue::FiniteF64(value)) if value.is_finite() => Ok(()),
841 (ArgValueType::Enum, CliValue::String(value)) if argument.enum_values.contains(value) => {
842 Ok(())
843 }
844 _ => Err("value type does not match the argument".to_string()),
845 }
846}
847
848fn combination_matches(
849 command: &CommandSpec,
850 combination: &Combination,
851 parsed: &ParsedArgs,
852) -> bool {
853 let allowed: BTreeSet<&str> = combination
854 .fixed
855 .keys()
856 .map(String::as_str)
857 .chain(combination.required.iter().map(String::as_str))
858 .chain(combination.optional.iter().map(String::as_str))
859 .collect();
860 if parsed
861 .explicit_application_ids
862 .iter()
863 .any(|id| !allowed.contains(id.as_str()))
864 || combination
865 .required
866 .iter()
867 .any(|id| !parsed.explicit_application_ids.contains(id))
868 {
869 return false;
870 }
871 combination.fixed.iter().all(|(id, fixed)| {
872 let argument = command
873 .arguments
874 .iter()
875 .find(|argument| argument.argument_id == *id);
876 let effective = parsed
877 .application_values
878 .get(id)
879 .and_then(|values| values.first())
880 .or_else(|| argument.and_then(|argument| argument.default.as_ref()));
881 effective
882 .and_then(CliValue::as_str)
883 .is_some_and(|value| fixed.values().iter().any(|fixed| fixed == value))
884 })
885}
886
887fn project_values(
888 command: &CommandSpec,
889 combination: &Combination,
890 parsed: &ParsedArgs,
891) -> BTreeMap<String, CliValue> {
892 let allowed: BTreeSet<&str> = combination
893 .fixed
894 .keys()
895 .map(String::as_str)
896 .chain(combination.required.iter().map(String::as_str))
897 .chain(combination.optional.iter().map(String::as_str))
898 .collect();
899 command
900 .arguments
901 .iter()
902 .filter(|argument| allowed.contains(argument.argument_id.as_str()))
903 .filter_map(|argument| {
904 let value = parsed
905 .application_values
906 .get(&argument.argument_id)
907 .map(|values| {
908 if argument.repeatable {
909 CliValue::List(values.clone())
910 } else {
911 values[0].clone()
912 }
913 })
914 .or_else(|| argument.default.clone())
915 .or_else(|| {
916 (argument.value_type == ArgValueType::Flag).then_some(CliValue::Bool(false))
917 });
918 value.map(|value| (argument.argument_id.clone(), value))
919 })
920 .collect()
921}
922
923fn resolve_output(
924 spec: &OutputSpec,
925 parsed: &ParsedOutput,
926 command_path: &str,
927 argument_names: Vec<String>,
928) -> Result<OutputPlan, CliError> {
929 match spec {
930 OutputSpec::Raw { file_sinks } => {
931 if parsed.format.is_some() || parsed.destination.is_some() {
932 return Err(CliError::unregistered(
933 command_path.to_string(),
934 argument_names,
935 ));
936 }
937 ensure_sinks(file_sinks, parsed, command_path, argument_names)?;
938 Ok(OutputPlan::Raw {
939 stdout_file: parsed.stdout_file.clone(),
940 stderr_file: parsed.stderr_file.clone(),
941 })
942 }
943 OutputSpec::Protocol {
944 lifecycle,
945 formats,
946 destinations,
947 default_format,
948 default_destination,
949 file_sinks,
950 } => {
951 ensure_sinks(file_sinks, parsed, command_path, argument_names.clone())?;
952 let format = parsed.format.as_ref().unwrap_or(default_format);
953 if !formats.contains(format) {
954 return Err(invalid_value(
955 command_path,
956 "--output".to_string(),
957 &format!("expected one of {}", formats.join(", ")),
958 ));
959 }
960 let destination = parsed.destination.as_ref().unwrap_or(default_destination);
961 if !destinations.contains(destination) {
962 return Err(invalid_value(
963 command_path,
964 "--output-to".to_string(),
965 &format!("expected one of {}", destinations.join(", ")),
966 ));
967 }
968 Ok(OutputPlan::Protocol {
969 lifecycle: *lifecycle,
970 format: format.clone(),
971 destination: destination.clone(),
972 stdout_file: parsed.stdout_file.clone(),
973 stderr_file: parsed.stderr_file.clone(),
974 })
975 }
976 }
977}
978
979fn ensure_sinks(
980 allowed: &[String],
981 parsed: &ParsedOutput,
982 command_path: &str,
983 argument_names: Vec<String>,
984) -> Result<(), CliError> {
985 if (parsed.stdout_file.is_some() && !allowed.iter().any(|sink| sink == "stdout"))
986 || (parsed.stderr_file.is_some() && !allowed.iter().any(|sink| sink == "stderr"))
987 {
988 return Err(CliError::unregistered(
989 command_path.to_string(),
990 argument_names,
991 ));
992 }
993 Ok(())
994}
995
996fn synthetic_value(argument: &ArgSpec) -> String {
997 match argument.value_type {
998 ArgValueType::Flag => String::new(),
999 ArgValueType::String => {
1000 if argument.sensitive {
1001 "synthetic-sensitive".to_string()
1002 } else {
1003 "value".to_string()
1004 }
1005 }
1006 ArgValueType::I64 => "1".to_string(),
1007 ArgValueType::FiniteF64 => "1.5".to_string(),
1008 ArgValueType::Enum => argument.enum_values.first().cloned().unwrap_or_default(),
1009 ArgValueType::Json => "{}".to_string(),
1010 }
1011}
1012
1013fn append_synthetic_argument(argv: &mut Vec<String>, argument: &ArgSpec, value: &str) {
1014 match &argument.syntax {
1015 ArgSyntax::Long { name } => {
1016 argv.push(name.clone());
1017 if argument.value_type != ArgValueType::Flag {
1018 argv.push(value.to_string());
1019 }
1020 }
1021 ArgSyntax::Positional { .. } => argv.push(value.to_string()),
1022 }
1023}