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(command, &argv[consumed..], &command_path)?;
143
144 if parsed.control_count() > 0 {
145 if parsed.control_count() != 1 || !parsed.application_values.is_empty() {
146 return Err(CliError::unregistered(command_path));
147 }
148 if parsed.docs {
153 if !command.command_path.is_empty() {
158 return Err(CliError::unregistered(command_path));
159 }
160 let contract = OutputSpec::raw()
161 .file_sinks(self.spec.lifecycle_output.file_sinks_ref().to_vec());
162 let output = resolve_output(&contract, &parsed.output, &command_path)?;
163 return Ok(CliOutcome::Docs(ResolvedDocs { output }));
164 }
165 let output =
166 resolve_output(&self.spec.lifecycle_output, &parsed.output, &command_path)?;
167 if parsed.help {
168 return Ok(CliOutcome::Help(ResolvedHelp {
169 model: self.help_model(command),
170 output,
171 }));
172 }
173 if parsed.version {
174 if !command.command_path.is_empty() {
175 return Err(CliError::unregistered(command_path));
176 }
177 return Ok(CliOutcome::Version(ResolvedVersion {
178 name: self.spec.name.clone(),
179 version: self.spec.version.clone(),
180 display_name: self.spec.display_name.clone(),
181 build: self.spec.build.clone(),
182 output,
183 }));
184 }
185 }
186
187 let matching: Vec<&Combination> = command
188 .combinations
189 .iter()
190 .filter(|combination| combination_matches(command, combination, &parsed))
191 .collect();
192 let Some(combination) = matching.first().copied() else {
193 return Err(CliError::unregistered(command_path));
194 };
195 if matching.len() != 1 {
196 return Err(CliError::new(
197 CliErrorRule::UnregisteredCombination,
198 command_path,
199 "arguments match more than one registered CLI combination",
200 ));
201 }
202 let output = resolve_output(&combination.output, &parsed.output, &command_path)?;
203 let values = project_values(command, combination, &parsed);
204 Ok(CliOutcome::Run(ResolvedInvocation {
205 command_path: command.command_path.clone(),
206 action_id: combination.action_id.clone(),
207 combination_id: combination.combination_id.clone(),
208 values,
209 explicit_argument_ids: parsed.explicit_application_ids,
210 output,
211 }))
212 }
213
214 fn select_command<'a>(&'a self, argv: &[String]) -> Result<(&'a CommandSpec, usize), CliError> {
215 let mut commands: Vec<&CommandSpec> = self.spec.commands.iter().collect();
216 commands.sort_by_key(|command| std::cmp::Reverse(command.command_path.len()));
217 if let Some(command) = commands.iter().copied().find(|command| {
218 command.command_path.len() <= argv.len()
219 && command
220 .command_path
221 .iter()
222 .zip(argv)
223 .all(|(expected, actual)| expected == actual)
224 }) {
225 let remaining = &argv[command.command_path.len()..];
226 let has_children = self.spec.commands.iter().any(|candidate| {
227 candidate.command_path.len() > command.command_path.len()
228 && candidate.command_path.starts_with(&command.command_path)
229 });
230 if remaining
231 .first()
232 .is_some_and(|token| !token.starts_with('-'))
233 && has_children
234 {
235 return Err(CliError::new(
236 CliErrorRule::UnknownCommand,
237 self.display_command_path(command),
238 format!("unknown command `{}`", remaining[0]),
239 ));
240 }
241 return Ok((command, command.command_path.len()));
242 }
243 Err(CliError::new(
244 CliErrorRule::UnknownCommand,
245 self.spec.name.clone(),
246 "unknown command",
247 ))
248 }
249
250 fn display_command_path(&self, command: &CommandSpec) -> String {
251 std::iter::once(self.spec.name.as_str())
252 .chain(command.command_path.iter().map(String::as_str))
253 .collect::<Vec<_>>()
254 .join(" ")
255 }
256
257 fn child_help_commands(&self, command: &CommandSpec) -> Vec<String> {
258 let mut children: Vec<Vec<String>> = self
259 .spec
260 .commands
261 .iter()
262 .filter(|candidate| {
263 candidate.command_path.len() == command.command_path.len() + 1
264 && candidate.command_path.starts_with(&command.command_path)
265 })
266 .map(|candidate| candidate.command_path.clone())
267 .collect();
268 children.sort();
269 children
270 .into_iter()
271 .map(|path| format!("{} {} --help", self.spec.name, path.join(" ")))
272 .collect()
273 }
274
275 fn help_model(&self, command: &CommandSpec) -> CliHelpV2 {
276 let command_path = self.display_command_path(command);
277 let mut shapes = Vec::new();
278 let mut notes = BTreeMap::new();
279 let mut defaults = BTreeMap::new();
280 for combination in &command.combinations {
281 let (usage, shape_notes, shape_defaults) =
282 combination_usage(command, combination, &command_path, true);
283 shapes.push(CliShape {
284 id: combination.combination_id.clone(),
285 about: if command.combinations.len() == 1 {
288 None
289 } else {
290 combination.about.clone()
291 },
292 usage,
293 });
294 notes.extend(shape_notes);
298 defaults.extend(shape_defaults);
299 }
300 CliHelpV2 {
301 schema: "cli-help-v2".to_string(),
302 command_path,
303 about: command.about.clone().or_else(|| {
307 command
308 .command_path
309 .is_empty()
310 .then(|| self.spec.about.clone())
311 .flatten()
312 }),
313 shapes,
314 subcommands: self.child_help_commands(command),
315 notes,
316 defaults,
317 }
318 }
319}
320
321#[derive(Clone, Debug, PartialEq, Eq)]
323pub struct SyntheticInvocation {
324 pub command_path: Vec<String>,
325 pub combination_id: String,
326 pub argv: Vec<String>,
327}
328
329pub struct BoundCliSpec<R> {
331 cli: BuiltCliSpec,
332 handlers: BTreeMap<String, fn(&ResolvedInvocation) -> R>,
333}
334
335impl<R> BoundCliSpec<R> {
336 pub fn resolve_from<I, S>(&self, args: I) -> Result<CliOutcome, CliError>
337 where
338 I: IntoIterator<Item = S>,
339 S: Into<OsString>,
340 {
341 self.cli.resolve_from(args)
342 }
343
344 #[allow(clippy::expect_used)]
350 pub fn execute(&self, invocation: &ResolvedInvocation) -> R {
351 let handler = self
352 .handlers
353 .get(invocation.action_id())
354 .expect("bind_actions guarantees one handler per action id");
355 handler(invocation)
356 }
357}
358
359#[derive(Clone, Debug, PartialEq)]
361pub enum CliOutcome {
362 Run(ResolvedInvocation),
363 Help(ResolvedHelp),
364 Version(ResolvedVersion),
365 Docs(ResolvedDocs),
366}
367
368#[derive(Clone, Debug, PartialEq)]
370pub struct ResolvedInvocation {
371 pub(super) command_path: Vec<String>,
372 pub(super) action_id: String,
373 pub(super) combination_id: String,
374 pub(super) values: BTreeMap<String, CliValue>,
375 pub(super) explicit_argument_ids: BTreeSet<String>,
376 pub(super) output: OutputPlan,
377}
378
379const MISSING: CliValue = CliValue::Bool(false);
385
386impl ResolvedInvocation {
387 pub fn command_path(&self) -> &[String] {
388 &self.command_path
389 }
390
391 pub fn action_id(&self) -> &str {
392 &self.action_id
393 }
394
395 pub fn combination_id(&self) -> &str {
396 &self.combination_id
397 }
398
399 pub fn output_plan(&self) -> &OutputPlan {
400 &self.output
401 }
402
403 pub fn was_explicit(&self, argument_id: &str) -> bool {
406 self.explicit_argument_ids.contains(argument_id)
407 }
408
409 pub fn optional(&self, argument_id: &str) -> Option<&CliValue> {
410 self.values.get(argument_id)
411 }
412
413 pub fn required(&self, argument_id: &str) -> &CliValue {
414 self.values.get(argument_id).unwrap_or(&MISSING)
415 }
416
417 pub fn repeated(&self, argument_id: &str) -> &[CliValue] {
418 self.values
419 .get(argument_id)
420 .and_then(CliValue::as_list)
421 .unwrap_or(&[])
422 }
423}
424
425#[derive(Clone, Debug, PartialEq, Eq)]
427pub enum OutputPlan {
428 Raw {
429 stdout_file: Option<PathBuf>,
430 stderr_file: Option<PathBuf>,
431 },
432 Protocol {
433 lifecycle: OutputLifecycle,
434 format: String,
435 destination: String,
436 stdout_file: Option<PathBuf>,
437 stderr_file: Option<PathBuf>,
438 },
439}
440
441impl OutputPlan {
442 pub fn format(&self) -> Option<&str> {
443 match self {
444 Self::Raw { .. } => None,
445 Self::Protocol { format, .. } => Some(format),
446 }
447 }
448
449 pub fn destination(&self) -> Option<&str> {
450 match self {
451 Self::Raw { .. } => None,
452 Self::Protocol { destination, .. } => Some(destination),
453 }
454 }
455
456 pub fn stdout_file(&self) -> Option<&Path> {
457 match self {
458 Self::Raw { stdout_file, .. } | Self::Protocol { stdout_file, .. } => {
459 stdout_file.as_deref()
460 }
461 }
462 }
463
464 pub fn stderr_file(&self) -> Option<&Path> {
465 match self {
466 Self::Raw { stderr_file, .. } | Self::Protocol { stderr_file, .. } => {
467 stderr_file.as_deref()
468 }
469 }
470 }
471}
472
473#[derive(Default)]
474struct ParsedArgs {
475 application_values: BTreeMap<String, Vec<CliValue>>,
476 explicit_application_ids: BTreeSet<String>,
477 output: ParsedOutput,
478 help: bool,
479 version: bool,
480 docs: bool,
481}
482
483impl ParsedArgs {
484 fn control_count(&self) -> usize {
485 usize::from(self.help) + usize::from(self.version) + usize::from(self.docs)
486 }
487}
488
489#[derive(Default)]
490struct ParsedOutput {
491 format: Option<String>,
492 destination: Option<String>,
493 stdout_file: Option<PathBuf>,
494 stderr_file: Option<PathBuf>,
495}
496
497fn tokenize(
498 command: &CommandSpec,
499 tokens: &[String],
500 command_path: &str,
501) -> Result<ParsedArgs, CliError> {
502 let longs: BTreeMap<&str, &ArgSpec> = command
503 .arguments
504 .iter()
505 .filter_map(|argument| match &argument.syntax {
506 ArgSyntax::Long { name } => Some((name.as_str(), argument)),
507 ArgSyntax::Positional { .. } => None,
508 })
509 .collect();
510 let mut positionals: Vec<&ArgSpec> = command
511 .arguments
512 .iter()
513 .filter(|argument| matches!(argument.syntax, ArgSyntax::Positional { .. }))
514 .collect();
515 positionals.sort_by_key(|argument| match argument.syntax {
516 ArgSyntax::Positional { index } => index,
517 ArgSyntax::Long { .. } => usize::MAX,
518 });
519
520 let mut parsed = ParsedArgs::default();
521 let mut index = 0;
522 let mut positional_index = 0;
523 let mut options_done = false;
524 while index < tokens.len() {
525 let token = &tokens[index];
526 if !options_done && token == "--" {
527 options_done = true;
528 index += 1;
529 continue;
530 }
531 if !options_done && token.starts_with("--") {
532 let (name, inline_value) = token
533 .split_once('=')
534 .map_or((token.as_str(), None), |(name, value)| (name, Some(value)));
535 if let Some(argument) = longs.get(name).copied() {
536 let display = name.to_string();
537 let raw_value = if argument.value_type == ArgValueType::Flag {
538 if inline_value.is_some() {
539 return Err(invalid_value(
540 command_path,
541 display,
542 "flags do not accept values",
543 ));
544 }
545 None
546 } else {
547 Some(take_value(
548 tokens,
549 &mut index,
550 inline_value,
551 name,
552 command_path,
553 )?)
554 };
555 let value = match raw_value {
556 Some(value) => parse_value(argument, value).map_err(|message| {
557 invalid_value(command_path, display.clone(), &message)
558 })?,
559 None => CliValue::Bool(true),
560 };
561 insert_application(&mut parsed, argument, value, display, command_path)?;
562 index += 1;
563 continue;
564 }
565 match name {
566 "--help" => {
567 reject_inline_value(inline_value, name, command_path)?;
568 set_once(&mut parsed.help, name, command_path)?;
569 }
570 "--version" => {
571 reject_inline_value(inline_value, name, command_path)?;
572 set_once(&mut parsed.version, name, command_path)?;
573 }
574 "--docs" => {
575 reject_inline_value(inline_value, name, command_path)?;
576 set_once(&mut parsed.docs, name, command_path)?;
577 }
578 "--output" => {
579 parsed.output.format = Some(set_output_value(
580 parsed.output.format.as_ref(),
581 take_value(tokens, &mut index, inline_value, name, command_path)?,
582 name,
583 command_path,
584 )?);
585 }
586 "--output-to" => {
587 parsed.output.destination = Some(set_output_value(
588 parsed.output.destination.as_ref(),
589 take_value(tokens, &mut index, inline_value, name, command_path)?,
590 name,
591 command_path,
592 )?);
593 }
594 "--stdout-file" => {
595 parsed.output.stdout_file = Some(PathBuf::from(set_output_value(
596 parsed.output.stdout_file.as_ref(),
597 take_value(tokens, &mut index, inline_value, name, command_path)?,
598 name,
599 command_path,
600 )?));
601 }
602 "--stderr-file" => {
603 parsed.output.stderr_file = Some(PathBuf::from(set_output_value(
604 parsed.output.stderr_file.as_ref(),
605 take_value(tokens, &mut index, inline_value, name, command_path)?,
606 name,
607 command_path,
608 )?));
609 }
610 _ => {
611 return Err(CliError::new(
612 CliErrorRule::UnknownArgument,
613 command_path.to_string(),
614 format!("unknown argument `{name}`"),
615 ));
616 }
617 }
618 index += 1;
619 continue;
620 }
621 if !options_done && token.starts_with('-') && token != "-" {
622 return Err(CliError::new(
623 CliErrorRule::UnknownArgument,
624 command_path.to_string(),
625 format!("unknown argument `{token}`"),
626 ));
627 }
628 let Some(argument) = positionals.get(positional_index).copied() else {
629 return Err(CliError::new(
630 CliErrorRule::UnexpectedPositional,
631 command_path.to_string(),
632 "unexpected positional argument",
633 ));
634 };
635 let value = parse_value(argument, token).map_err(|message| {
636 invalid_value(command_path, argument.argument_id.clone(), &message)
637 })?;
638 insert_application(
639 &mut parsed,
640 argument,
641 value,
642 argument.argument_id.clone(),
643 command_path,
644 )?;
645 if !argument.repeatable {
646 positional_index += 1;
647 }
648 index += 1;
649 }
650 Ok(parsed)
651}
652
653fn take_value<'a>(
654 tokens: &'a [String],
655 index: &mut usize,
656 inline_value: Option<&'a str>,
657 name: &str,
658 command_path: &str,
659) -> Result<&'a str, CliError> {
660 if let Some(value) = inline_value {
661 if value.is_empty() {
662 return Err(missing_value(command_path, name));
663 }
664 return Ok(value);
665 }
666 let Some(value) = tokens.get(*index + 1) else {
667 return Err(missing_value(command_path, name));
668 };
669 if value.starts_with('-') && value != "-" {
670 return Err(missing_value(command_path, name));
671 }
672 *index += 1;
673 Ok(value)
674}
675
676fn reject_inline_value(
677 inline_value: Option<&str>,
678 name: &str,
679 command_path: &str,
680) -> Result<(), CliError> {
681 if inline_value.is_some() {
682 return Err(invalid_value(
683 command_path,
684 name.to_string(),
685 "control flags do not accept values",
686 ));
687 }
688 Ok(())
689}
690
691fn set_once(value: &mut bool, name: &str, command_path: &str) -> Result<(), CliError> {
692 if *value {
693 return Err(duplicate_error(command_path, name));
694 }
695 *value = true;
696 Ok(())
697}
698
699fn set_output_value<T>(
700 existing: Option<&T>,
701 value: &str,
702 name: &str,
703 command_path: &str,
704) -> Result<String, CliError> {
705 if existing.is_some() {
706 return Err(duplicate_error(command_path, name));
707 }
708 Ok(value.to_string())
709}
710
711fn insert_application(
712 parsed: &mut ParsedArgs,
713 argument: &ArgSpec,
714 value: CliValue,
715 display: String,
716 command_path: &str,
717) -> Result<(), CliError> {
718 let values = parsed
719 .application_values
720 .entry(argument.argument_id.clone())
721 .or_default();
722 if !argument.repeatable && !values.is_empty() {
723 return Err(duplicate_error(command_path, &display));
724 }
725 values.push(value);
726 parsed
727 .explicit_application_ids
728 .insert(argument.argument_id.clone());
729 Ok(())
730}
731
732fn parse_value(argument: &ArgSpec, raw: &str) -> Result<CliValue, String> {
733 match argument.value_type {
734 ArgValueType::Flag => Ok(CliValue::Bool(true)),
735 ArgValueType::String | ArgValueType::Enum => {
736 if argument.value_type == ArgValueType::Enum
737 && !argument.enum_values.iter().any(|value| value == raw)
738 {
739 return Err(format!(
740 "expected one of {}",
741 argument.enum_values.join(", ")
742 ));
743 }
744 Ok(CliValue::String(raw.to_string()))
745 }
746 ArgValueType::I64 => raw
747 .parse::<i64>()
748 .map(CliValue::I64)
749 .map_err(|_| "expected an i64 integer".to_string()),
750 ArgValueType::FiniteF64 => raw
751 .parse::<f64>()
752 .ok()
753 .filter(|value| value.is_finite())
754 .map(CliValue::FiniteF64)
755 .ok_or_else(|| "expected a finite f64 number".to_string()),
756 ArgValueType::Json => serde_json::from_str::<serde::de::IgnoredAny>(raw)
761 .map(|_| CliValue::Json(raw.to_string()))
762 .map_err(|_| "expected one valid JSON value".to_string()),
763 }
764}
765
766pub(super) fn validate_value_type(argument: &ArgSpec, value: &CliValue) -> Result<(), String> {
767 match (&argument.value_type, value) {
768 (ArgValueType::Flag, CliValue::Bool(_))
769 | (ArgValueType::String, CliValue::String(_))
770 | (ArgValueType::I64, CliValue::I64(_))
771 | (ArgValueType::Json, CliValue::Json(_)) => Ok(()),
772 (ArgValueType::FiniteF64, CliValue::FiniteF64(value)) if value.is_finite() => Ok(()),
773 (ArgValueType::Enum, CliValue::String(value)) if argument.enum_values.contains(value) => {
774 Ok(())
775 }
776 _ => Err("value type does not match the argument".to_string()),
777 }
778}
779
780fn combination_matches(
781 command: &CommandSpec,
782 combination: &Combination,
783 parsed: &ParsedArgs,
784) -> bool {
785 let allowed: BTreeSet<&str> = combination
786 .fixed
787 .keys()
788 .map(String::as_str)
789 .chain(combination.required.iter().map(String::as_str))
790 .chain(combination.optional.iter().map(String::as_str))
791 .collect();
792 if parsed
793 .explicit_application_ids
794 .iter()
795 .any(|id| !allowed.contains(id.as_str()))
796 || combination
797 .required
798 .iter()
799 .any(|id| !parsed.explicit_application_ids.contains(id))
800 {
801 return false;
802 }
803 combination.fixed.iter().all(|(id, fixed)| {
804 let argument = command
805 .arguments
806 .iter()
807 .find(|argument| argument.argument_id == *id);
808 let effective = parsed
809 .application_values
810 .get(id)
811 .and_then(|values| values.first())
812 .or_else(|| argument.and_then(|argument| argument.default.as_ref()));
813 effective
814 .and_then(CliValue::as_str)
815 .is_some_and(|value| fixed.values().iter().any(|fixed| fixed == value))
816 })
817}
818
819fn project_values(
820 command: &CommandSpec,
821 combination: &Combination,
822 parsed: &ParsedArgs,
823) -> BTreeMap<String, CliValue> {
824 let allowed: BTreeSet<&str> = combination
825 .fixed
826 .keys()
827 .map(String::as_str)
828 .chain(combination.required.iter().map(String::as_str))
829 .chain(combination.optional.iter().map(String::as_str))
830 .collect();
831 command
832 .arguments
833 .iter()
834 .filter(|argument| allowed.contains(argument.argument_id.as_str()))
835 .filter_map(|argument| {
836 let value = parsed
837 .application_values
838 .get(&argument.argument_id)
839 .map(|values| {
840 if argument.repeatable {
841 CliValue::List(values.clone())
842 } else {
843 values[0].clone()
844 }
845 })
846 .or_else(|| argument.default.clone())
847 .or_else(|| {
848 (argument.value_type == ArgValueType::Flag).then_some(CliValue::Bool(false))
849 });
850 value.map(|value| (argument.argument_id.clone(), value))
851 })
852 .collect()
853}
854
855fn resolve_output(
856 spec: &OutputSpec,
857 parsed: &ParsedOutput,
858 command_path: &str,
859) -> Result<OutputPlan, CliError> {
860 match spec {
861 OutputSpec::Raw { file_sinks } => {
862 if parsed.format.is_some() || parsed.destination.is_some() {
863 return Err(CliError::unregistered(command_path.to_string()));
864 }
865 ensure_sinks(file_sinks, parsed, command_path)?;
866 Ok(OutputPlan::Raw {
867 stdout_file: parsed.stdout_file.clone(),
868 stderr_file: parsed.stderr_file.clone(),
869 })
870 }
871 OutputSpec::Protocol {
872 lifecycle,
873 formats,
874 destinations,
875 default_format,
876 default_destination,
877 file_sinks,
878 } => {
879 ensure_sinks(file_sinks, parsed, command_path)?;
880 let format = parsed.format.as_ref().unwrap_or(default_format);
881 if !formats.contains(format) {
882 return Err(invalid_value(
883 command_path,
884 "--output".to_string(),
885 &format!("expected one of {}", formats.join(", ")),
886 ));
887 }
888 let destination = parsed.destination.as_ref().unwrap_or(default_destination);
889 if !destinations.contains(destination) {
890 return Err(invalid_value(
891 command_path,
892 "--output-to".to_string(),
893 &format!("expected one of {}", destinations.join(", ")),
894 ));
895 }
896 Ok(OutputPlan::Protocol {
897 lifecycle: *lifecycle,
898 format: format.clone(),
899 destination: destination.clone(),
900 stdout_file: parsed.stdout_file.clone(),
901 stderr_file: parsed.stderr_file.clone(),
902 })
903 }
904 }
905}
906
907fn ensure_sinks(
908 allowed: &[String],
909 parsed: &ParsedOutput,
910 command_path: &str,
911) -> Result<(), CliError> {
912 if (parsed.stdout_file.is_some() && !allowed.iter().any(|sink| sink == "stdout"))
913 || (parsed.stderr_file.is_some() && !allowed.iter().any(|sink| sink == "stderr"))
914 {
915 return Err(CliError::unregistered(command_path.to_string()));
916 }
917 Ok(())
918}
919
920fn synthetic_value(argument: &ArgSpec) -> String {
921 match argument.value_type {
922 ArgValueType::Flag => String::new(),
923 ArgValueType::String => {
924 if argument.sensitive {
925 "synthetic-sensitive".to_string()
926 } else {
927 "value".to_string()
928 }
929 }
930 ArgValueType::I64 => "1".to_string(),
931 ArgValueType::FiniteF64 => "1.5".to_string(),
932 ArgValueType::Enum => argument.enum_values.first().cloned().unwrap_or_default(),
933 ArgValueType::Json => "{}".to_string(),
934 }
935}
936
937fn append_synthetic_argument(argv: &mut Vec<String>, argument: &ArgSpec, value: &str) {
938 match &argument.syntax {
939 ArgSyntax::Long { name } => {
940 argv.push(name.clone());
941 if argument.value_type != ArgValueType::Flag {
942 argv.push(value.to_string());
943 }
944 }
945 ArgSyntax::Positional { .. } => argv.push(value.to_string()),
946 }
947}