1use std::fmt;
14use std::path::PathBuf;
15
16use crate::command::Command;
17use crate::exit::ExitCategory;
18
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
21pub enum OutputForm {
22 #[default]
24 Human,
25 Json,
27}
28
29impl OutputForm {
30 fn parse(value: &str) -> Option<Self> {
32 match value {
33 "human" => Some(Self::Human),
34 "json" => Some(Self::Json),
35 _ => None,
36 }
37 }
38
39 #[must_use]
41 pub const fn as_str(self) -> &'static str {
42 match self {
43 Self::Human => "human",
44 Self::Json => "json",
45 }
46 }
47}
48
49#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
55pub enum RecordArg {
56 #[default]
58 Operator,
59 Recovery,
61 Flow,
63}
64
65impl RecordArg {
66 fn parse(value: &str) -> Option<Self> {
67 match value {
68 "operator" => Some(Self::Operator),
69 "recovery" => Some(Self::Recovery),
70 "flow" => Some(Self::Flow),
71 _ => None,
72 }
73 }
74
75 #[must_use]
77 pub const fn as_str(self) -> &'static str {
78 match self {
79 Self::Operator => "operator",
80 Self::Recovery => "recovery",
81 Self::Flow => "flow",
82 }
83 }
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum DirectiveArg {
89 MarkFailed,
91 Abandon,
93}
94
95impl DirectiveArg {
96 fn parse(value: &str) -> Option<Self> {
97 match value {
98 "mark-failed" => Some(Self::MarkFailed),
99 "abandon" => Some(Self::Abandon),
100 _ => None,
101 }
102 }
103}
104
105#[derive(Clone, Debug, Default)]
111pub struct Arguments {
112 pub config: Option<PathBuf>,
114 pub output: Option<String>,
116 pub page_size: Option<String>,
118 pub cursor: Option<String>,
120 pub operation_id: Option<String>,
122 pub actor: Option<String>,
124 pub reason: Option<String>,
126 pub expected_version: Option<u64>,
128 pub timeout: Option<String>,
130 pub dry_run: bool,
132 pub yes: bool,
134 pub no_color: bool,
136 pub job: Option<String>,
138 pub instance: Option<u64>,
140 pub execution: Option<u64>,
142 pub step: Option<u64>,
144 pub unresolved_age: Option<String>,
146 pub record: Option<RecordArg>,
148 pub directive: Option<DirectiveArg>,
150 pub failure_category: Option<String>,
152 pub failure_id: Option<u64>,
154 pub evidence_digest: Option<String>,
156 pub older_than: Option<String>,
158 pub batch: Option<String>,
160 pub status: Vec<String>,
162 pub plan_digest: Option<String>,
164 pub parameters: Vec<(String, String)>,
166 pub parameters_file: Option<PathBuf>,
168 pub out: Option<PathBuf>,
170}
171
172#[derive(Clone, Debug, Eq, PartialEq)]
174#[non_exhaustive]
175pub enum ArgumentError {
176 MissingCommand,
178 UnknownCommand,
180 UnknownOption {
182 option: String,
184 },
185 OptionNotAccepted {
187 option: String,
189 command: Command,
191 },
192 MissingValue {
194 option: String,
196 },
197 RepeatedOption {
199 option: String,
201 },
202 InvalidValue {
204 option: String,
206 },
207 MissingRequiredOption {
209 option: String,
211 command: Command,
213 },
214 ContradictoryOptions {
216 first: String,
218 second: String,
220 },
221 InvalidConfigurationValue {
226 option: String,
228 },
229}
230
231impl ArgumentError {
232 #[must_use]
234 pub const fn category(&self) -> ExitCategory {
235 match self {
236 Self::InvalidConfigurationValue { .. } => ExitCategory::ConfigurationInvalid,
237 _ => ExitCategory::Usage,
238 }
239 }
240
241 #[must_use]
243 pub const fn code(&self) -> &'static str {
244 match self {
245 Self::MissingCommand => "MISSING_COMMAND",
246 Self::UnknownCommand => "UNKNOWN_COMMAND",
247 Self::UnknownOption { .. } => "UNKNOWN_OPTION",
248 Self::OptionNotAccepted { .. } => "OPTION_NOT_ACCEPTED",
249 Self::MissingValue { .. } => "MISSING_VALUE",
250 Self::RepeatedOption { .. } => "REPEATED_OPTION",
251 Self::InvalidValue { .. } => "INVALID_VALUE",
252 Self::MissingRequiredOption { .. } => "MISSING_REQUIRED_OPTION",
253 Self::ContradictoryOptions { .. } => "CONTRADICTORY_OPTIONS",
254 Self::InvalidConfigurationValue { .. } => "INVALID_CONFIGURATION_VALUE",
255 }
256 }
257}
258
259impl fmt::Display for ArgumentError {
260 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
261 match self {
262 Self::MissingCommand => formatter.write_str("no command was supplied"),
263 Self::UnknownCommand => formatter.write_str("unknown command"),
264 Self::UnknownOption { option } => write!(formatter, "unknown option {option}"),
265 Self::OptionNotAccepted { option, command } => {
266 write!(formatter, "{command} does not accept {option}")
267 }
268 Self::MissingValue { option } => write!(formatter, "{option} requires a value"),
269 Self::RepeatedOption { option } => {
270 write!(formatter, "{option} was supplied more than once")
271 }
272 Self::InvalidValue { option } => write!(formatter, "{option} has an invalid value"),
273 Self::MissingRequiredOption { option, command } => {
274 write!(formatter, "{command} requires {option}")
275 }
276 Self::ContradictoryOptions { first, second } => {
277 write!(formatter, "{first} cannot be combined with {second}")
278 }
279 Self::InvalidConfigurationValue { option } => {
280 write!(formatter, "{option} has an invalid value")
281 }
282 }
283 }
284}
285
286impl std::error::Error for ArgumentError {}
287
288#[derive(Clone, Copy, Debug, Eq, PartialEq)]
290enum Opt {
291 Config,
292 Output,
293 PageSize,
294 Cursor,
295 OperationId,
296 Actor,
297 Reason,
298 ExpectedVersion,
299 Timeout,
300 DryRun,
301 Yes,
302 NoColor,
303 Job,
304 Instance,
305 Execution,
306 Step,
307 UnresolvedAge,
308 Record,
309 Directive,
310 FailureCategory,
311 FailureId,
312 EvidenceDigest,
313 OlderThan,
314 Batch,
315 Status,
316 PlanDigest,
317 Parameter,
318 ParametersFile,
319 Out,
320}
321
322impl Opt {
323 fn resolve(name: &str) -> Option<Self> {
325 match name {
326 "--config" => Some(Self::Config),
327 "--output" => Some(Self::Output),
328 "--page-size" => Some(Self::PageSize),
329 "--cursor" => Some(Self::Cursor),
330 "--operation-id" => Some(Self::OperationId),
331 "--actor" => Some(Self::Actor),
332 "--reason" => Some(Self::Reason),
333 "--expected-version" => Some(Self::ExpectedVersion),
334 "--timeout" => Some(Self::Timeout),
335 "--dry-run" => Some(Self::DryRun),
336 "--yes" => Some(Self::Yes),
337 "--no-color" => Some(Self::NoColor),
338 "--job" => Some(Self::Job),
339 "--instance" => Some(Self::Instance),
340 "--execution" => Some(Self::Execution),
341 "--step" => Some(Self::Step),
342 "--unresolved-age" => Some(Self::UnresolvedAge),
343 "--record" => Some(Self::Record),
344 "--directive" => Some(Self::Directive),
345 "--failure-category" => Some(Self::FailureCategory),
346 "--failure-id" => Some(Self::FailureId),
347 "--evidence-digest" => Some(Self::EvidenceDigest),
348 "--older-than" => Some(Self::OlderThan),
349 "--batch" => Some(Self::Batch),
350 "--status" => Some(Self::Status),
351 "--plan-digest" => Some(Self::PlanDigest),
352 "--parameter" => Some(Self::Parameter),
353 "--parameters-file" => Some(Self::ParametersFile),
354 "--out" => Some(Self::Out),
355 _ => None,
356 }
357 }
358
359 const fn is_flag(self) -> bool {
361 matches!(self, Self::DryRun | Self::Yes | Self::NoColor)
362 }
363
364 const fn is_repeatable(self) -> bool {
366 matches!(self, Self::Status | Self::Parameter)
367 }
368
369 const fn as_str(self) -> &'static str {
371 match self {
372 Self::Config => "--config",
373 Self::Output => "--output",
374 Self::PageSize => "--page-size",
375 Self::Cursor => "--cursor",
376 Self::OperationId => "--operation-id",
377 Self::Actor => "--actor",
378 Self::Reason => "--reason",
379 Self::ExpectedVersion => "--expected-version",
380 Self::Timeout => "--timeout",
381 Self::DryRun => "--dry-run",
382 Self::Yes => "--yes",
383 Self::NoColor => "--no-color",
384 Self::Job => "--job",
385 Self::Instance => "--instance",
386 Self::Execution => "--execution",
387 Self::Step => "--step",
388 Self::UnresolvedAge => "--unresolved-age",
389 Self::Record => "--record",
390 Self::Directive => "--directive",
391 Self::FailureCategory => "--failure-category",
392 Self::FailureId => "--failure-id",
393 Self::EvidenceDigest => "--evidence-digest",
394 Self::OlderThan => "--older-than",
395 Self::Batch => "--batch",
396 Self::Status => "--status",
397 Self::PlanDigest => "--plan-digest",
398 Self::Parameter => "--parameter",
399 Self::ParametersFile => "--parameters-file",
400 Self::Out => "--out",
401 }
402 }
403
404 const fn is_global(self) -> bool {
406 matches!(
407 self,
408 Self::Config | Self::Output | Self::Timeout | Self::NoColor | Self::PageSize
409 )
410 }
411
412 fn accepted_by(self, command: Command) -> bool {
414 if self.is_global() {
415 return true;
416 }
417 match self {
418 Self::Cursor => command.is_paginated(),
419 Self::DryRun => command.supports_dry_run(),
420 Self::Yes => command.class().requires_confirmation(),
421 Self::OperationId | Self::Actor => command.is_mutating(),
422 Self::Reason => matches!(
423 command,
424 Command::ExecutionAbandon
425 | Command::ExecutionRecover
426 | Command::RetentionApply
427 | Command::RetentionHold
428 | Command::RetentionRelease
429 ),
430 Self::ExpectedVersion => matches!(
431 command,
432 Command::ExecutionStop | Command::ExecutionAbandon | Command::ExecutionRecover
433 ),
434 Self::Job => matches!(
435 command,
436 Command::JobShow
437 | Command::InstanceList
438 | Command::Launch
439 | Command::ExecutionRestart
440 | Command::RetentionPlan
441 | Command::RetentionApply
442 ),
443 Self::Instance => matches!(
444 command,
445 Command::InstanceShow
446 | Command::ExecutionList
447 | Command::ExecutionRestart
448 | Command::RetentionHold
449 | Command::RetentionRelease
450 ),
451 Self::Execution => matches!(
452 command,
453 Command::ExecutionShow
454 | Command::ExecutionSteps
455 | Command::ExecutionHistory
456 | Command::ExecutionStop
457 | Command::ExecutionAbandon
458 | Command::ExecutionRecover
459 | Command::DiagnosticsBundle
460 ),
461 Self::Step => matches!(command, Command::ExecutionPartitions),
462 Self::UnresolvedAge => matches!(command, Command::ExecutionList),
463 Self::Record => matches!(command, Command::ExecutionHistory),
464 Self::Directive | Self::FailureCategory | Self::FailureId | Self::EvidenceDigest => {
465 matches!(command, Command::ExecutionRecover)
466 }
467 Self::OlderThan | Self::Batch | Self::Status => {
468 matches!(command, Command::RetentionPlan | Command::RetentionApply)
469 }
470 Self::PlanDigest => matches!(command, Command::RetentionApply),
471 Self::Parameter | Self::ParametersFile => matches!(command, Command::Launch),
472 Self::Out => matches!(command, Command::DiagnosticsBundle),
473 Self::Config | Self::Output | Self::Timeout | Self::NoColor | Self::PageSize => true,
474 }
475 }
476}
477
478pub fn parse(words: &[String]) -> Result<(Command, Arguments), ArgumentError> {
487 let (command, rest) = split_command(words)?;
488 let arguments = parse_options(command, rest)?;
489 require_target(command, &arguments)?;
490 Ok((command, arguments))
491}
492
493fn split_command(words: &[String]) -> Result<(Command, &[String]), ArgumentError> {
495 if words.is_empty() {
496 return Err(ArgumentError::MissingCommand);
497 }
498 let leading: Vec<&str> = words
499 .iter()
500 .take(2)
501 .take_while(|word| !word.starts_with('-'))
502 .map(String::as_str)
503 .collect();
504 if leading.is_empty() {
505 return Err(ArgumentError::MissingCommand);
506 }
507 if leading.len() == 2
510 && let Some(command) = Command::resolve(&leading)
511 {
512 return Ok((command, &words[2..]));
513 }
514 Command::resolve(&leading[..1])
515 .map(|command| (command, &words[1..]))
516 .ok_or(ArgumentError::UnknownCommand)
517}
518
519#[allow(clippy::too_many_lines)]
520fn parse_options(command: Command, words: &[String]) -> Result<Arguments, ArgumentError> {
521 let mut arguments = Arguments::default();
522 let mut seen: Vec<Opt> = Vec::new();
523 let mut index = 0;
524 while index < words.len() {
525 let word = words[index].as_str();
526 let (name, inline) = match word.split_once('=') {
527 Some((name, value)) => (name, Some(value.to_owned())),
528 None => (word, None),
529 };
530 let option = Opt::resolve(name).ok_or_else(|| ArgumentError::UnknownOption {
531 option: name.to_owned(),
532 })?;
533 if !option.accepted_by(command) {
534 return Err(ArgumentError::OptionNotAccepted {
535 option: option.as_str().to_owned(),
536 command,
537 });
538 }
539 if !option.is_repeatable() {
540 if seen.contains(&option) {
541 return Err(ArgumentError::RepeatedOption {
542 option: option.as_str().to_owned(),
543 });
544 }
545 seen.push(option);
546 }
547 index += 1;
548 if option.is_flag() {
549 if inline.is_some() {
550 return Err(ArgumentError::InvalidValue {
551 option: option.as_str().to_owned(),
552 });
553 }
554 match option {
555 Opt::DryRun => arguments.dry_run = true,
556 Opt::Yes => arguments.yes = true,
557 Opt::NoColor => arguments.no_color = true,
558 _ => {}
559 }
560 continue;
561 }
562 let value = if let Some(value) = inline {
563 value
564 } else {
565 let value = words
566 .get(index)
567 .ok_or_else(|| ArgumentError::MissingValue {
568 option: option.as_str().to_owned(),
569 })?
570 .clone();
571 index += 1;
572 value
573 };
574 assign(&mut arguments, option, value)?;
575 }
576 Ok(arguments)
577}
578
579fn assign(arguments: &mut Arguments, option: Opt, value: String) -> Result<(), ArgumentError> {
580 let invalid = || ArgumentError::InvalidValue {
581 option: option.as_str().to_owned(),
582 };
583 match option {
584 Opt::Config => arguments.config = Some(PathBuf::from(value)),
585 Opt::Output => {
586 if OutputForm::parse(&value).is_none() {
587 return Err(ArgumentError::InvalidConfigurationValue {
588 option: option.as_str().to_owned(),
589 });
590 }
591 arguments.output = Some(value);
592 }
593 Opt::PageSize => arguments.page_size = Some(value),
594 Opt::Timeout => arguments.timeout = Some(value),
595 Opt::Cursor => arguments.cursor = Some(value),
596 Opt::OperationId => arguments.operation_id = Some(value),
597 Opt::Actor => arguments.actor = Some(value),
598 Opt::Reason => arguments.reason = Some(value),
599 Opt::ExpectedVersion => {
600 arguments.expected_version = Some(value.parse().map_err(|_| invalid())?);
601 }
602 Opt::Job => arguments.job = Some(value),
603 Opt::Instance => arguments.instance = Some(value.parse().map_err(|_| invalid())?),
604 Opt::Execution => arguments.execution = Some(value.parse().map_err(|_| invalid())?),
605 Opt::Step => arguments.step = Some(value.parse().map_err(|_| invalid())?),
606 Opt::UnresolvedAge => arguments.unresolved_age = Some(value),
607 Opt::Record => {
608 arguments.record = Some(RecordArg::parse(&value).ok_or_else(invalid)?);
609 }
610 Opt::Directive => {
611 arguments.directive = Some(DirectiveArg::parse(&value).ok_or_else(invalid)?);
612 }
613 Opt::FailureCategory => arguments.failure_category = Some(value),
614 Opt::FailureId => arguments.failure_id = Some(value.parse().map_err(|_| invalid())?),
615 Opt::EvidenceDigest => arguments.evidence_digest = Some(value),
616 Opt::OlderThan => arguments.older_than = Some(value),
617 Opt::Batch => arguments.batch = Some(value),
618 Opt::Status => arguments.status.push(value),
619 Opt::PlanDigest => arguments.plan_digest = Some(value),
620 Opt::Parameter => {
621 let (name, parameter) = value.split_once('=').ok_or_else(invalid)?;
622 arguments
623 .parameters
624 .push((name.to_owned(), parameter.to_owned()));
625 }
626 Opt::ParametersFile => arguments.parameters_file = Some(PathBuf::from(value)),
627 Opt::Out => arguments.out = Some(PathBuf::from(value)),
628 Opt::DryRun | Opt::Yes | Opt::NoColor => {}
629 }
630 Ok(())
631}
632
633fn require_target(command: Command, arguments: &Arguments) -> Result<(), ArgumentError> {
635 let missing = |option: &str| ArgumentError::MissingRequiredOption {
636 option: option.to_owned(),
637 command,
638 };
639 match command {
640 Command::JobShow | Command::Launch | Command::InstanceList | Command::RetentionPlan => {
641 if arguments.job.is_none() {
642 return Err(missing("--job"));
643 }
644 }
645 Command::InstanceShow
646 | Command::ExecutionRestart
647 | Command::RetentionHold
648 | Command::RetentionRelease => {
649 if arguments.instance.is_none() {
650 return Err(missing("--instance"));
651 }
652 }
653 Command::ExecutionList => {
654 match (arguments.instance, arguments.unresolved_age.as_ref()) {
657 (None, None) => return Err(missing("--instance")),
658 (Some(_), Some(_)) => {
659 return Err(ArgumentError::ContradictoryOptions {
660 first: "--instance".to_owned(),
661 second: "--unresolved-age".to_owned(),
662 });
663 }
664 _ => {}
665 }
666 }
667 Command::ExecutionShow
668 | Command::ExecutionSteps
669 | Command::ExecutionHistory
670 | Command::ExecutionStop
671 | Command::ExecutionAbandon
672 | Command::ExecutionRecover
673 | Command::DiagnosticsBundle => {
674 if arguments.execution.is_none() {
675 return Err(missing("--execution"));
676 }
677 if command == Command::DiagnosticsBundle && arguments.out.is_none() {
678 return Err(missing("--out"));
679 }
680 }
681 Command::ExecutionPartitions => {
682 if arguments.step.is_none() {
683 return Err(missing("--step"));
684 }
685 }
686 Command::RetentionApply => {
687 if arguments.job.is_none() {
688 return Err(missing("--job"));
689 }
690 if arguments.plan_digest.is_none() {
691 return Err(missing("--plan-digest"));
692 }
693 }
694 Command::JobList | Command::ConfigShow | Command::SchemaStatus => {}
695 }
696 require_mutation_fields(command, arguments)
697}
698
699fn require_mutation_fields(command: Command, arguments: &Arguments) -> Result<(), ArgumentError> {
701 let missing = |option: &str| ArgumentError::MissingRequiredOption {
702 option: option.to_owned(),
703 command,
704 };
705 if !command.is_mutating() {
706 return Ok(());
707 }
708 if arguments.actor.is_none() {
709 return Err(missing("--actor"));
710 }
711 let needs_reason = matches!(
712 command,
713 Command::ExecutionAbandon
714 | Command::ExecutionRecover
715 | Command::RetentionApply
716 | Command::RetentionHold
717 | Command::RetentionRelease
718 );
719 if needs_reason && arguments.reason.is_none() {
720 return Err(missing("--reason"));
721 }
722 let needs_version = matches!(
723 command,
724 Command::ExecutionStop | Command::ExecutionAbandon | Command::ExecutionRecover
725 );
726 if needs_version && arguments.expected_version.is_none() {
727 return Err(missing("--expected-version"));
728 }
729 if matches!(command, Command::ExecutionRecover) {
730 if arguments.directive.is_none() {
731 return Err(missing("--directive"));
732 }
733 if arguments.evidence_digest.is_none() {
734 return Err(missing("--evidence-digest"));
735 }
736 if matches!(arguments.directive, Some(DirectiveArg::MarkFailed)) {
737 if arguments.failure_category.is_none() {
738 return Err(missing("--failure-category"));
739 }
740 if arguments.failure_id.is_none() {
741 return Err(missing("--failure-id"));
742 }
743 } else if arguments.failure_category.is_some() || arguments.failure_id.is_some() {
744 return Err(ArgumentError::ContradictoryOptions {
745 first: "--directive abandon".to_owned(),
746 second: "--failure-category".to_owned(),
747 });
748 }
749 }
750 Ok(())
751}
752
753#[cfg(test)]
754mod tests {
755 #![allow(clippy::expect_used, clippy::panic)]
756
757 use super::{ArgumentError, Command, DirectiveArg, ExitCategory, parse};
758
759 fn words(value: &str) -> Vec<String> {
760 value.split(' ').map(str::to_owned).collect()
761 }
762
763 #[test]
764 fn parses_a_paginated_read() {
765 let (command, arguments) = parse(&words("execution list --instance 7 --page-size 25"))
766 .expect("the invocation is valid");
767 assert_eq!(command, Command::ExecutionList);
768 assert_eq!(arguments.instance, Some(7));
769 assert_eq!(arguments.page_size.as_deref(), Some("25"));
770 }
771
772 #[test]
773 fn accepts_the_inline_value_form() {
774 let (_, arguments) =
775 parse(&words("execution show --execution=12")).expect("the invocation is valid");
776 assert_eq!(arguments.execution, Some(12));
777 }
778
779 #[test]
780 fn rejects_an_unknown_option() {
781 let error = parse(&words("job list --colour")).expect_err("the option is unknown");
782 assert!(matches!(error, ArgumentError::UnknownOption { .. }));
783 assert_eq!(error.category(), ExitCategory::Usage);
784 }
785
786 #[test]
787 fn rejects_an_option_the_command_does_not_accept() {
788 let error =
789 parse(&words("job list --expected-version 3")).expect_err("the option is not accepted");
790 assert!(matches!(error, ArgumentError::OptionNotAccepted { .. }));
791 }
792
793 #[test]
794 fn rejects_a_repeated_single_valued_option() {
795 let error = parse(&words("execution show --execution 1 --execution 2"))
796 .expect_err("the option is repeated");
797 assert!(matches!(error, ArgumentError::RepeatedOption { .. }));
798 }
799
800 #[test]
801 fn rejects_a_missing_value() {
802 let error = parse(&words("execution show --execution")).expect_err("the value is missing");
803 assert!(matches!(error, ArgumentError::MissingValue { .. }));
804 }
805
806 #[test]
807 fn rejects_an_unknown_command() {
808 assert_eq!(
809 parse(&words("job delete --job orders")).expect_err("the command is unknown"),
810 ArgumentError::UnknownCommand
811 );
812 }
813
814 #[test]
815 fn rejects_a_missing_target() {
816 let error = parse(&words("execution show")).expect_err("the target is required");
817 assert!(matches!(error, ArgumentError::MissingRequiredOption { .. }));
818 }
819
820 #[test]
821 fn rejects_the_ambiguous_execution_list_shape() {
822 let error = parse(&words("execution list --instance 1 --unresolved-age 15m"))
823 .expect_err("the shapes are contradictory");
824 assert!(matches!(error, ArgumentError::ContradictoryOptions { .. }));
825 }
826
827 #[test]
828 fn an_invalid_output_form_is_a_configuration_error() {
829 let error = parse(&words("job list --output yaml")).expect_err("the form is unknown");
830 assert_eq!(error.category(), ExitCategory::ConfigurationInvalid);
831 }
832
833 #[test]
834 fn recover_requires_its_evidence() {
835 let error = parse(&words(
836 "execution recover --execution 4 --expected-version 2 --actor ops --reason STALE \
837 --directive mark-failed --evidence-digest ab --failure-category Infrastructure",
838 ))
839 .expect_err("the failure identifier is required");
840 assert!(matches!(error, ArgumentError::MissingRequiredOption { .. }));
841 }
842
843 #[test]
844 fn abandon_directive_rejects_a_stated_failure() {
845 let error = parse(&words(
846 "execution recover --execution 4 --expected-version 2 --actor ops --reason STALE \
847 --directive abandon --evidence-digest ab --failure-category Infrastructure",
848 ))
849 .expect_err("an abandon directive carries no failure");
850 assert!(matches!(error, ArgumentError::ContradictoryOptions { .. }));
851 }
852
853 #[test]
854 fn parses_a_recovery_directive() {
855 let (_, arguments) = parse(&words(
856 "execution recover --execution 4 --expected-version 2 --actor ops --reason STALE \
857 --directive abandon --evidence-digest ab",
858 ))
859 .expect("the invocation is valid");
860 assert_eq!(arguments.directive, Some(DirectiveArg::Abandon));
861 }
862
863 #[test]
864 fn repeatable_options_accumulate() {
865 let (_, arguments) = parse(&words(
866 "retention plan --job orders --status COMPLETED --status FAILED --older-than 30d",
867 ))
868 .expect("the invocation is valid");
869 assert_eq!(arguments.status, vec!["COMPLETED", "FAILED"]);
870 }
871
872 #[test]
873 fn a_mutating_command_requires_an_actor() {
874 let error = parse(&words("execution stop --execution 3 --expected-version 1"))
875 .expect_err("the actor is required");
876 assert!(matches!(error, ArgumentError::MissingRequiredOption { .. }));
877 }
878}