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