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 "argv contains a token that is not valid UTF-8",
135 ));
136 };
137 utf8.push(token.to_string());
138 }
139 let argv = if utf8.is_empty() { &[][..] } else { &utf8[1..] };
140 let (command, consumed) = self.select_command(argv)?;
141 let command_path = self.display_command_path(command);
142 let parsed = tokenize(
143 command,
144 &argv[consumed..],
145 &command_path,
146 &self.spec.commands,
147 )?;
148
149 if parsed.control_count() > 0 {
150 if parsed.control_count() != 1 || !parsed.application_values.is_empty() {
151 return Err(CliError::unregistered(command_path));
152 }
153 if parsed.docs {
158 if !command.command_path.is_empty() {
163 return Err(CliError::unregistered(command_path));
164 }
165 let contract = OutputSpec::raw()
166 .file_sinks(self.spec.lifecycle_output.file_sinks_ref().to_vec());
167 let output = resolve_output(&contract, &parsed.output, &command_path)?;
168 return Ok(CliOutcome::Docs(ResolvedDocs { output }));
169 }
170 let output =
171 resolve_output(&self.spec.lifecycle_output, &parsed.output, &command_path)?;
172 if parsed.help {
173 return Ok(CliOutcome::Help(ResolvedHelp {
174 model: self.help_model(command),
175 output,
176 }));
177 }
178 if parsed.version {
179 if !command.command_path.is_empty() {
180 return Err(CliError::unregistered(command_path));
181 }
182 return Ok(CliOutcome::Version(ResolvedVersion {
183 name: self.spec.name.clone(),
184 version: self.spec.version.clone(),
185 display_name: self.spec.display_name.clone(),
186 build: self.spec.build.clone(),
187 output,
188 }));
189 }
190 }
191
192 let matching: Vec<&Combination> = command
193 .combinations
194 .iter()
195 .filter(|combination| combination_matches(command, combination, &parsed))
196 .collect();
197 let Some(combination) = matching.first().copied() else {
198 return Err(CliError::unregistered(command_path));
199 };
200 if matching.len() != 1 {
201 return Err(CliError::new(
202 CliErrorRule::UnregisteredCombination,
203 command_path,
204 "arguments match more than one registered CLI combination",
205 ));
206 }
207 let output = resolve_output(&combination.output, &parsed.output, &command_path)?;
208 let values = project_values(command, combination, &parsed);
209 Ok(CliOutcome::Run(ResolvedInvocation {
210 command_path: command.command_path.clone(),
211 action_id: combination.action_id.clone(),
212 combination_id: combination.combination_id.clone(),
213 values,
214 explicit_argument_ids: parsed.explicit_application_ids,
215 output,
216 strict_reads: false,
217 }))
218 }
219
220 fn select_command<'a>(&'a self, argv: &[String]) -> Result<(&'a CommandSpec, usize), CliError> {
221 let mut commands: Vec<&CommandSpec> = self.spec.commands.iter().collect();
222 commands.sort_by_key(|command| std::cmp::Reverse(command.command_path.len()));
223 if let Some(command) = commands.iter().copied().find(|command| {
224 command.command_path.len() <= argv.len()
225 && command
226 .command_path
227 .iter()
228 .zip(argv)
229 .all(|(expected, actual)| expected == actual)
230 }) {
231 let remaining = &argv[command.command_path.len()..];
232 let has_children = self.spec.commands.iter().any(|candidate| {
233 candidate.command_path.len() > command.command_path.len()
234 && candidate.command_path.starts_with(&command.command_path)
235 });
236 if remaining
237 .first()
238 .is_some_and(|token| !token.starts_with('-'))
239 && has_children
240 {
241 return Err(CliError::new(
242 CliErrorRule::UnknownCommand,
243 self.display_command_path(command),
244 "unknown command",
245 ));
246 }
247 return Ok((command, command.command_path.len()));
248 }
249 Err(CliError::new(
250 CliErrorRule::UnknownCommand,
251 self.spec.name.clone(),
252 "unknown command",
253 ))
254 }
255
256 fn display_command_path(&self, command: &CommandSpec) -> String {
257 std::iter::once(self.spec.name.as_str())
258 .chain(command.command_path.iter().map(String::as_str))
259 .collect::<Vec<_>>()
260 .join(" ")
261 }
262
263 fn child_help_commands(&self, command: &CommandSpec) -> Vec<String> {
264 let mut children: Vec<Vec<String>> = self
265 .spec
266 .commands
267 .iter()
268 .filter(|candidate| {
269 candidate.command_path.len() == command.command_path.len() + 1
270 && candidate.command_path.starts_with(&command.command_path)
271 })
272 .map(|candidate| candidate.command_path.clone())
273 .collect();
274 children.sort();
275 children
276 .into_iter()
277 .map(|path| format!("{} {} --help", self.spec.name, path.join(" ")))
278 .collect()
279 }
280
281 fn help_model(&self, command: &CommandSpec) -> CliHelpV2 {
282 let command_path = self.display_command_path(command);
283 let mut shapes = Vec::new();
284 let mut notes = BTreeMap::new();
285 let mut defaults = BTreeMap::new();
286 for combination in &command.combinations {
287 let (usage, shape_notes, shape_defaults) =
288 combination_usage(command, combination, &command_path, true);
289 shapes.push(CliShape {
290 id: combination.combination_id.clone(),
291 about: if command.combinations.len() == 1 {
294 None
295 } else {
296 combination.about.clone()
297 },
298 usage,
299 });
300 notes.extend(shape_notes);
304 defaults.extend(shape_defaults);
305 }
306 CliHelpV2 {
307 schema: "cli-help-v2".to_string(),
308 command_path,
309 about: command.about.clone().or_else(|| {
313 command
314 .command_path
315 .is_empty()
316 .then(|| self.spec.about.clone())
317 .flatten()
318 }),
319 shapes,
320 subcommands: self.child_help_commands(command),
321 notes,
322 defaults,
323 }
324 }
325}
326
327#[derive(Clone, Debug, PartialEq, Eq)]
329pub struct SyntheticInvocation {
330 pub command_path: Vec<String>,
331 pub combination_id: String,
332 pub argv: Vec<String>,
333}
334
335pub struct BoundCliSpec<R> {
337 cli: BuiltCliSpec,
338 handlers: BTreeMap<String, fn(&ResolvedInvocation) -> R>,
339}
340
341impl<R> BoundCliSpec<R> {
342 pub fn resolve_from<I, S>(&self, args: I) -> Result<BoundOutcome<R>, CliError>
352 where
353 I: IntoIterator<Item = S>,
354 S: Into<OsString>,
355 {
356 Ok(match self.cli.resolve_from(args)? {
357 CliOutcome::Run(invocation) => BoundOutcome::Run(self.bind(invocation)),
358 CliOutcome::Help(help) => BoundOutcome::Help(help),
359 CliOutcome::Version(version) => BoundOutcome::Version(version),
360 CliOutcome::Docs(docs) => BoundOutcome::Docs(docs),
361 })
362 }
363
364 #[allow(clippy::panic)]
398 pub fn call_every_combination(&self) -> Vec<(String, R)> {
399 let mut results = Vec::new();
400 for fixture in self.cli.synthetic_invocations() {
401 let outcome = self.cli.resolve_from(fixture.argv.clone());
405 let Ok(CliOutcome::Run(mut invocation)) = outcome else {
406 panic!(
407 "combination `{}` generated argv {:?}, which does not resolve to a run",
408 fixture.combination_id, fixture.argv
409 );
410 };
411 invocation.strict_reads = true;
412 let combination = invocation.combination_id.clone();
413 results.push((combination, self.bind(invocation).run()));
414 }
415 assert!(
416 !results.is_empty(),
417 "no combination was called; a registry with no runnable combination cannot be verified"
418 );
419 results
420 }
421
422 fn bind(&self, invocation: ResolvedInvocation) -> BoundInvocation<R> {
423 #[allow(clippy::expect_used)]
428 let handler = *self
429 .handlers
430 .get(invocation.action_id())
431 .expect("bind_actions guarantees one handler per action id");
432 BoundInvocation {
433 invocation,
434 handler,
435 }
436 }
437}
438
439pub enum BoundOutcome<R> {
441 Run(BoundInvocation<R>),
442 Help(ResolvedHelp),
443 Version(ResolvedVersion),
444 Docs(ResolvedDocs),
445}
446
447impl<R> std::fmt::Debug for BoundOutcome<R> {
448 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452 match self {
453 Self::Run(invocation) => formatter.debug_tuple("Run").field(invocation).finish(),
454 Self::Help(help) => formatter.debug_tuple("Help").field(help).finish(),
455 Self::Version(version) => formatter.debug_tuple("Version").field(version).finish(),
456 Self::Docs(docs) => formatter.debug_tuple("Docs").field(docs).finish(),
457 }
458 }
459}
460
461pub struct BoundInvocation<R> {
466 invocation: ResolvedInvocation,
467 handler: fn(&ResolvedInvocation) -> R,
468}
469
470impl<R> BoundInvocation<R> {
471 pub fn output_plan(&self) -> &OutputPlan {
474 self.invocation.output_plan()
475 }
476
477 pub fn invocation(&self) -> &ResolvedInvocation {
479 &self.invocation
480 }
481
482 pub fn run(self) -> R {
484 (self.handler)(&self.invocation)
485 }
486
487 pub fn run_with_invocation(self) -> (R, ResolvedInvocation) {
494 let result = (self.handler)(&self.invocation);
495 (result, self.invocation)
496 }
497}
498
499impl<R> std::fmt::Debug for BoundInvocation<R> {
500 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
501 formatter
502 .debug_struct("BoundInvocation")
503 .field("invocation", &self.invocation)
504 .finish_non_exhaustive()
505 }
506}
507
508#[derive(Clone, Debug, PartialEq)]
510pub enum CliOutcome {
511 Run(ResolvedInvocation),
512 Help(ResolvedHelp),
513 Version(ResolvedVersion),
514 Docs(ResolvedDocs),
515}
516
517#[derive(Clone, Debug, PartialEq)]
519pub struct ResolvedInvocation {
520 pub(super) command_path: Vec<String>,
521 pub(super) action_id: String,
522 pub(super) combination_id: String,
523 pub(super) values: BTreeMap<String, CliValue>,
524 pub(super) explicit_argument_ids: BTreeSet<String>,
525 pub(super) output: OutputPlan,
526 pub(super) strict_reads: bool,
530}
531
532const MISSING: CliValue = CliValue::Bool(false);
537
538impl ResolvedInvocation {
539 pub fn command_path(&self) -> &[String] {
540 &self.command_path
541 }
542
543 pub fn action_id(&self) -> &str {
544 &self.action_id
545 }
546
547 pub fn combination_id(&self) -> &str {
548 &self.combination_id
549 }
550
551 pub fn output_plan(&self) -> &OutputPlan {
552 &self.output
553 }
554
555 pub fn was_explicit(&self, argument_id: &str) -> bool {
558 self.explicit_argument_ids.contains(argument_id)
559 }
560
561 pub fn optional(&self, argument_id: &str) -> Option<&CliValue> {
562 self.values.get(argument_id)
563 }
564
565 pub fn required(&self, argument_id: &str) -> &CliValue {
574 match self.values.get(argument_id) {
575 Some(value) => value,
576 None => {
577 assert!(
578 !self.strict_reads,
579 "combination `{}` does not declare argument id `{argument_id}`",
580 self.combination_id
581 );
582 &MISSING
586 }
587 }
588 }
589
590 pub fn repeated(&self, argument_id: &str) -> &[CliValue] {
591 self.values
592 .get(argument_id)
593 .and_then(CliValue::as_list)
594 .unwrap_or(&[])
595 }
596}
597
598#[derive(Clone, Debug, PartialEq, Eq)]
600pub enum OutputPlan {
601 Raw {
602 stdout_file: Option<PathBuf>,
603 stderr_file: Option<PathBuf>,
604 },
605 Protocol {
606 lifecycle: OutputLifecycle,
607 format: OutputFormat,
608 destination: OutputTo,
609 stdout_file: Option<PathBuf>,
610 stderr_file: Option<PathBuf>,
611 },
612}
613
614impl OutputPlan {
615 pub const fn output_format(&self) -> Option<OutputFormat> {
617 match self {
618 Self::Raw { .. } => None,
619 Self::Protocol { format, .. } => Some(*format),
620 }
621 }
622
623 pub const fn output_to(&self) -> Option<OutputTo> {
625 match self {
626 Self::Raw { .. } => None,
627 Self::Protocol { destination, .. } => Some(*destination),
628 }
629 }
630
631 pub fn format(&self) -> Option<&str> {
633 self.output_format().map(OutputFormat::as_str)
634 }
635
636 pub fn destination(&self) -> Option<&str> {
638 self.output_to().map(OutputTo::as_str)
639 }
640
641 pub fn stdout_file(&self) -> Option<&Path> {
642 match self {
643 Self::Raw { stdout_file, .. } | Self::Protocol { stdout_file, .. } => {
644 stdout_file.as_deref()
645 }
646 }
647 }
648
649 pub fn stderr_file(&self) -> Option<&Path> {
650 match self {
651 Self::Raw { stderr_file, .. } | Self::Protocol { stderr_file, .. } => {
652 stderr_file.as_deref()
653 }
654 }
655 }
656}
657
658#[derive(Default)]
659struct ParsedArgs {
660 application_values: BTreeMap<String, Vec<CliValue>>,
661 explicit_application_ids: BTreeSet<String>,
662 output: ParsedOutput,
663 help: bool,
664 version: bool,
665 docs: bool,
666}
667
668impl ParsedArgs {
669 fn control_count(&self) -> usize {
670 usize::from(self.help) + usize::from(self.version) + usize::from(self.docs)
671 }
672}
673
674#[derive(Default)]
675struct ParsedOutput {
676 format: Option<String>,
677 destination: Option<String>,
678 stdout_file: Option<PathBuf>,
679 stderr_file: Option<PathBuf>,
680}
681
682fn tokenize(
683 command: &CommandSpec,
684 tokens: &[String],
685 command_path: &str,
686 all_commands: &[CommandSpec],
687) -> Result<ParsedArgs, CliError> {
688 let longs: BTreeMap<&str, &ArgSpec> = command
689 .arguments
690 .iter()
691 .filter_map(|argument| match &argument.syntax {
692 ArgSyntax::Long { name } => Some((name.as_str(), argument)),
693 ArgSyntax::Positional { .. } => None,
694 })
695 .collect();
696 let mut positionals: Vec<&ArgSpec> = command
697 .arguments
698 .iter()
699 .filter(|argument| matches!(argument.syntax, ArgSyntax::Positional { .. }))
700 .collect();
701 positionals.sort_by_key(|argument| match argument.syntax {
702 ArgSyntax::Positional { index } => index,
703 ArgSyntax::Long { .. } => usize::MAX,
704 });
705
706 let mut parsed = ParsedArgs::default();
707 let mut index = 0;
708 let mut positional_index = 0;
709 let mut options_done = false;
710 while index < tokens.len() {
711 let token = &tokens[index];
712 if !options_done && token == "--" {
713 options_done = true;
714 index += 1;
715 continue;
716 }
717 if !options_done && token.starts_with("--") {
718 let (name, inline_value) = token
719 .split_once('=')
720 .map_or((token.as_str(), None), |(name, value)| (name, Some(value)));
721 if let Some(argument) = longs.get(name).copied() {
722 let display = name.to_string();
723 let raw_value = if argument.value_type == ArgValueType::Flag {
724 if inline_value.is_some() {
725 return Err(invalid_value(
726 command_path,
727 display,
728 "flags do not accept values",
729 ));
730 }
731 None
732 } else {
733 Some(take_value(
734 tokens,
735 &mut index,
736 inline_value,
737 Some(argument),
738 name,
739 command_path,
740 )?)
741 };
742 let value = match raw_value {
743 Some(value) => parse_value(argument, value).map_err(|message| {
744 invalid_value(command_path, display.clone(), &message)
745 })?,
746 None => CliValue::Bool(true),
747 };
748 insert_application(&mut parsed, argument, value, display, command_path)?;
749 index += 1;
750 continue;
751 }
752 match name {
753 "--help" => {
754 reject_inline_value(inline_value, name, command_path)?;
755 set_once(&mut parsed.help, name, command_path)?;
756 }
757 "--version" => {
758 reject_inline_value(inline_value, name, command_path)?;
759 set_once(&mut parsed.version, name, command_path)?;
760 }
761 "--docs" => {
762 reject_inline_value(inline_value, name, command_path)?;
763 set_once(&mut parsed.docs, name, command_path)?;
764 }
765 "--output" => {
766 parsed.output.format = Some(set_output_value(
767 parsed.output.format.as_ref(),
768 take_value(tokens, &mut index, inline_value, None, name, command_path)?,
769 name,
770 command_path,
771 )?);
772 }
773 "--output-to" => {
774 parsed.output.destination = Some(set_output_value(
775 parsed.output.destination.as_ref(),
776 take_value(tokens, &mut index, inline_value, None, name, command_path)?,
777 name,
778 command_path,
779 )?);
780 }
781 "--stdout-file" => {
782 parsed.output.stdout_file = Some(PathBuf::from(set_output_value(
783 parsed.output.stdout_file.as_ref(),
784 take_value(tokens, &mut index, inline_value, None, name, command_path)?,
785 name,
786 command_path,
787 )?));
788 }
789 "--stderr-file" => {
790 parsed.output.stderr_file = Some(PathBuf::from(set_output_value(
791 parsed.output.stderr_file.as_ref(),
792 take_value(tokens, &mut index, inline_value, None, name, command_path)?,
793 name,
794 command_path,
795 )?));
796 }
797 _ => {
798 return Err(CliError::new(
799 CliErrorRule::UnknownArgument,
800 command_path.to_string(),
801 format!("unknown argument `{name}`"),
802 ));
803 }
804 }
805 index += 1;
806 continue;
807 }
808 if !options_done && token.starts_with('-') && token != "-" {
809 let positional_accepts_value = positionals
810 .get(positional_index)
811 .is_some_and(|argument| accepts_hyphen_prefixed_value(argument, token));
812 if !positional_accepts_value {
813 return Err(CliError::new(
814 CliErrorRule::UnknownArgument,
815 command_path.to_string(),
816 "unknown short argument",
817 ));
818 }
819 }
820 let Some(argument) = positionals.get(positional_index).copied() else {
821 let message = if is_registered_command_segment(token, all_commands) {
826 "command name must come before its arguments"
827 } else {
828 "unexpected positional argument"
829 };
830 return Err(CliError::new(
831 CliErrorRule::UnexpectedPositional,
832 command_path.to_string(),
833 message,
834 ));
835 };
836 let value = parse_value(argument, token).map_err(|message| {
837 invalid_value(command_path, argument.argument_id.clone(), &message)
838 })?;
839 insert_application(
840 &mut parsed,
841 argument,
842 value,
843 argument.argument_id.clone(),
844 command_path,
845 )?;
846 if !argument.repeatable {
847 positional_index += 1;
848 }
849 index += 1;
850 }
851 Ok(parsed)
852}
853
854fn take_value<'a>(
855 tokens: &'a [String],
856 index: &mut usize,
857 inline_value: Option<&'a str>,
858 argument: Option<&ArgSpec>,
859 name: &str,
860 command_path: &str,
861) -> Result<&'a str, CliError> {
862 if let Some(value) = inline_value {
863 if value.is_empty() {
864 return Err(missing_value(command_path, name));
865 }
866 return Ok(value);
867 }
868 let Some(value) = tokens.get(*index + 1) else {
869 return Err(missing_value(command_path, name));
870 };
871 if value.starts_with('-')
872 && value != "-"
873 && !argument.is_some_and(|argument| accepts_hyphen_prefixed_value(argument, value))
874 {
875 return Err(missing_value(command_path, name));
876 }
877 *index += 1;
878 Ok(value)
879}
880
881fn accepts_hyphen_prefixed_value(argument: &ArgSpec, value: &str) -> bool {
889 match argument.value_type {
890 ArgValueType::I64 => value.parse::<i64>().is_ok(),
891 ArgValueType::FiniteF64 => value.parse::<f64>().is_ok_and(f64::is_finite),
892 ArgValueType::Json => serde_json::from_str::<serde::de::IgnoredAny>(value).is_ok(),
893 _ => false,
894 }
895}
896
897fn reject_inline_value(
898 inline_value: Option<&str>,
899 name: &str,
900 command_path: &str,
901) -> Result<(), CliError> {
902 if inline_value.is_some() {
903 return Err(invalid_value(
904 command_path,
905 name.to_string(),
906 "control flags do not accept values",
907 ));
908 }
909 Ok(())
910}
911
912fn set_once(value: &mut bool, name: &str, command_path: &str) -> Result<(), CliError> {
913 if *value {
914 return Err(duplicate_error(command_path, name));
915 }
916 *value = true;
917 Ok(())
918}
919
920fn set_output_value<T>(
921 existing: Option<&T>,
922 value: &str,
923 name: &str,
924 command_path: &str,
925) -> Result<String, CliError> {
926 if existing.is_some() {
927 return Err(duplicate_error(command_path, name));
928 }
929 Ok(value.to_string())
930}
931
932fn insert_application(
933 parsed: &mut ParsedArgs,
934 argument: &ArgSpec,
935 value: CliValue,
936 display: String,
937 command_path: &str,
938) -> Result<(), CliError> {
939 let values = parsed
940 .application_values
941 .entry(argument.argument_id.clone())
942 .or_default();
943 if !argument.repeatable && !values.is_empty() {
944 return Err(duplicate_error(command_path, &display));
945 }
946 values.push(value);
947 parsed
948 .explicit_application_ids
949 .insert(argument.argument_id.clone());
950 Ok(())
951}
952
953fn parse_value(argument: &ArgSpec, raw: &str) -> Result<CliValue, String> {
954 match argument.value_type {
955 ArgValueType::Flag => Ok(CliValue::Bool(true)),
956 ArgValueType::String | ArgValueType::Enum => {
957 if argument.value_type == ArgValueType::Enum
958 && !argument.enum_values.iter().any(|value| value == raw)
959 {
960 return Err(format!(
961 "expected one of {}",
962 argument.enum_values.join(", ")
963 ));
964 }
965 Ok(CliValue::String(raw.to_string()))
966 }
967 ArgValueType::I64 => {
968 let value = raw
969 .parse::<i64>()
970 .map_err(|_| "expected an i64 integer".to_string())?;
971 if let Some([minimum, maximum]) = argument.range
972 && !(minimum..=maximum).contains(&value)
973 {
974 return Err(format!("expected an integer in {minimum}..={maximum}"));
975 }
976 Ok(CliValue::I64(value))
977 }
978 ArgValueType::Uuid => {
979 if is_canonical_uuid(raw) {
980 Ok(CliValue::String(raw.to_string()))
981 } else {
982 Err("expected a UUID (8-4-4-4-12 hexadecimal digits)".to_string())
983 }
984 }
985 ArgValueType::FiniteF64 => raw
986 .parse::<f64>()
987 .ok()
988 .filter(|value| value.is_finite())
989 .map(CliValue::FiniteF64)
990 .ok_or_else(|| "expected a finite f64 number".to_string()),
991 ArgValueType::Json => serde_json::from_str::<serde::de::IgnoredAny>(raw)
996 .map(|_| CliValue::Json(raw.to_string()))
997 .map_err(|_| "expected one valid JSON value".to_string()),
998 }
999}
1000
1001pub(super) fn validate_value_type(argument: &ArgSpec, value: &CliValue) -> Result<(), String> {
1002 match (&argument.value_type, value) {
1003 (ArgValueType::Flag, CliValue::Bool(_))
1004 | (ArgValueType::String, CliValue::String(_))
1005 | (ArgValueType::Json, CliValue::Json(_)) => Ok(()),
1006 (ArgValueType::I64, CliValue::I64(number)) => match argument.range {
1011 Some([minimum, maximum]) if !(minimum..=maximum).contains(number) => Err(format!(
1012 "value {number} is outside the argument's {minimum}..={maximum} range"
1013 )),
1014 _ => Ok(()),
1015 },
1016 (ArgValueType::Uuid, CliValue::String(text)) if is_canonical_uuid(text) => Ok(()),
1017 (ArgValueType::Uuid, CliValue::String(_)) => {
1018 Err("value is not a UUID (8-4-4-4-12 hexadecimal digits)".to_string())
1019 }
1020 (ArgValueType::FiniteF64, CliValue::FiniteF64(value)) if value.is_finite() => Ok(()),
1021 (ArgValueType::Enum, CliValue::String(value)) if argument.enum_values.contains(value) => {
1022 Ok(())
1023 }
1024 _ => Err("value type does not match the argument".to_string()),
1025 }
1026}
1027
1028fn combination_matches(
1029 command: &CommandSpec,
1030 combination: &Combination,
1031 parsed: &ParsedArgs,
1032) -> bool {
1033 let allowed: BTreeSet<&str> = combination
1034 .fixed
1035 .keys()
1036 .map(String::as_str)
1037 .chain(combination.required.iter().map(String::as_str))
1038 .chain(combination.optional.iter().map(String::as_str))
1039 .collect();
1040 if parsed
1041 .explicit_application_ids
1042 .iter()
1043 .any(|id| !allowed.contains(id.as_str()))
1044 || combination
1045 .required
1046 .iter()
1047 .any(|id| !parsed.explicit_application_ids.contains(id))
1048 {
1049 return false;
1050 }
1051 combination.fixed.iter().all(|(id, fixed)| {
1052 let argument = command
1053 .arguments
1054 .iter()
1055 .find(|argument| argument.argument_id == *id);
1056 let effective = parsed
1057 .application_values
1058 .get(id)
1059 .and_then(|values| values.first())
1060 .or_else(|| argument.and_then(|argument| argument.default.as_ref()));
1061 effective
1062 .and_then(CliValue::as_str)
1063 .is_some_and(|value| fixed.values().iter().any(|fixed| fixed == value))
1064 })
1065}
1066
1067fn project_values(
1068 command: &CommandSpec,
1069 combination: &Combination,
1070 parsed: &ParsedArgs,
1071) -> BTreeMap<String, CliValue> {
1072 let allowed: BTreeSet<&str> = combination
1073 .fixed
1074 .keys()
1075 .map(String::as_str)
1076 .chain(combination.required.iter().map(String::as_str))
1077 .chain(combination.optional.iter().map(String::as_str))
1078 .collect();
1079 command
1080 .arguments
1081 .iter()
1082 .filter(|argument| allowed.contains(argument.argument_id.as_str()))
1083 .filter_map(|argument| {
1084 let value = parsed
1085 .application_values
1086 .get(&argument.argument_id)
1087 .map(|values| {
1088 if argument.repeatable {
1089 CliValue::List(values.clone())
1090 } else {
1091 values[0].clone()
1092 }
1093 })
1094 .or_else(|| argument.default.clone())
1095 .or_else(|| {
1096 (argument.value_type == ArgValueType::Flag).then_some(CliValue::Bool(false))
1097 });
1098 value.map(|value| (argument.argument_id.clone(), value))
1099 })
1100 .collect()
1101}
1102
1103fn resolve_output(
1104 spec: &OutputSpec,
1105 parsed: &ParsedOutput,
1106 command_path: &str,
1107) -> Result<OutputPlan, CliError> {
1108 match spec {
1109 OutputSpec::Raw { file_sinks } => {
1110 if parsed.format.is_some() || parsed.destination.is_some() {
1111 return Err(CliError::unregistered(command_path.to_string()));
1112 }
1113 ensure_sinks(file_sinks, parsed, command_path)?;
1114 Ok(OutputPlan::Raw {
1115 stdout_file: parsed.stdout_file.clone(),
1116 stderr_file: parsed.stderr_file.clone(),
1117 })
1118 }
1119 OutputSpec::Protocol {
1120 lifecycle,
1121 formats,
1122 destinations,
1123 default_format,
1124 default_destination,
1125 file_sinks,
1126 } => {
1127 ensure_sinks(file_sinks, parsed, command_path)?;
1128 let format = parsed.format.as_ref().unwrap_or(default_format);
1129 if !formats.contains(format) {
1130 return Err(invalid_value(
1131 command_path,
1132 "--output".to_string(),
1133 &format!("expected one of {}", formats.join(", ")),
1134 ));
1135 }
1136 let destination = parsed.destination.as_ref().unwrap_or(default_destination);
1137 if !destinations.contains(destination) {
1138 return Err(invalid_value(
1139 command_path,
1140 "--output-to".to_string(),
1141 &format!("expected one of {}", destinations.join(", ")),
1142 ));
1143 }
1144 let output_format = format.parse::<OutputFormat>().map_err(|_| {
1145 invalid_value(
1146 command_path,
1147 "--output".to_string(),
1148 "expected one of json, yaml, plain",
1149 )
1150 })?;
1151 let output_to = destination.parse::<OutputTo>().map_err(|_| {
1152 invalid_value(
1153 command_path,
1154 "--output-to".to_string(),
1155 "expected one of split, stdout, stderr",
1156 )
1157 })?;
1158 Ok(OutputPlan::Protocol {
1159 lifecycle: *lifecycle,
1160 format: output_format,
1161 destination: output_to,
1162 stdout_file: parsed.stdout_file.clone(),
1163 stderr_file: parsed.stderr_file.clone(),
1164 })
1165 }
1166 }
1167}
1168
1169fn ensure_sinks(
1170 allowed: &[String],
1171 parsed: &ParsedOutput,
1172 command_path: &str,
1173) -> Result<(), CliError> {
1174 if (parsed.stdout_file.is_some() && !allowed.iter().any(|sink| sink == "stdout"))
1175 || (parsed.stderr_file.is_some() && !allowed.iter().any(|sink| sink == "stderr"))
1176 {
1177 return Err(CliError::unregistered(command_path.to_string()));
1178 }
1179 Ok(())
1180}
1181
1182fn is_registered_command_segment(token: &str, all_commands: &[CommandSpec]) -> bool {
1188 all_commands
1189 .iter()
1190 .any(|command| command.command_path.iter().any(|segment| segment == token))
1191}
1192
1193fn is_canonical_uuid(raw: &str) -> bool {
1199 const GROUPS: [usize; 5] = [8, 4, 4, 4, 12];
1200 let mut parts = raw.split('-');
1201 for width in GROUPS {
1202 let Some(part) = parts.next() else {
1203 return false;
1204 };
1205 if part.len() != width || !part.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1206 return false;
1207 }
1208 }
1209 parts.next().is_none()
1210}
1211
1212fn synthetic_value(argument: &ArgSpec) -> String {
1213 match argument.value_type {
1214 ArgValueType::Flag => String::new(),
1215 ArgValueType::String => {
1216 if argument.sensitive {
1217 "synthetic-sensitive".to_string()
1218 } else {
1219 "value".to_string()
1220 }
1221 }
1222 ArgValueType::I64 => argument
1223 .range
1224 .map_or_else(|| "1".to_string(), |[minimum, _]| minimum.to_string()),
1225 ArgValueType::Uuid => "00000000-0000-0000-0000-000000000000".to_string(),
1226 ArgValueType::FiniteF64 => "1.5".to_string(),
1227 ArgValueType::Enum => argument.enum_values.first().cloned().unwrap_or_default(),
1228 ArgValueType::Json => "{}".to_string(),
1229 }
1230}
1231
1232fn append_synthetic_argument(argv: &mut Vec<String>, argument: &ArgSpec, value: &str) {
1233 match &argument.syntax {
1234 ArgSyntax::Long { name } => {
1235 argv.push(name.clone());
1236 if argument.value_type != ArgValueType::Flag {
1237 argv.push(value.to_string());
1238 }
1239 }
1240 ArgSyntax::Positional { .. } => argv.push(value.to_string()),
1241 }
1242}