Skip to main content

oxide_batch_cli/
args.rs

1//! The closed-grammar argument parser.
2//!
3//! Parsing is hand written rather than delegated to a parser library so that
4//! the closed grammar, the rejection of every unknown word, and the stable exit
5//! categories stay observable properties of this crate rather than of a
6//! dependency's defaults.
7//!
8//! The parser records raw strings and validates only what argument syntax can
9//! decide. A value that participates in configuration precedence is validated
10//! by [`crate::config`] so that a bad value reports
11//! [`ExitCategory::ConfigurationInvalid`] rather than a usage error.
12
13use std::fmt;
14use std::path::PathBuf;
15
16use crate::command::Command;
17use crate::exit::ExitCategory;
18
19/// The requested output form.
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
21pub enum OutputForm {
22    /// A stable but explicitly unversioned presentation for a person.
23    #[default]
24    Human,
25    /// The versioned machine envelope.
26    Json,
27}
28
29impl OutputForm {
30    /// Parses the closed set of output forms.
31    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    /// Returns the stable machine name of this form.
40    #[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/// The record family selected by `execution history`.
50///
51/// A single traversal is selected per invocation so that one opaque cursor
52/// continues exactly one keyset traversal. Merging three record families into
53/// one page would make a continuation token ambiguous.
54#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
55pub enum RecordArg {
56    /// Audited operator requests.
57    #[default]
58    Operator,
59    /// Append-only recovery decisions.
60    Recovery,
61    /// Recorded flow transitions.
62    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    /// Returns the stable machine name of this record family.
76    #[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/// The recovery disposition requested by `execution recover`.
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum DirectiveArg {
89    /// Make the observed attempt restart-eligible under a stated failure.
90    MarkFailed,
91    /// Make the logical instance permanently non-restartable.
92    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/// One parsed invocation of the closed grammar.
106///
107/// Every field is the raw argument text or a syntactically validated value.
108/// Semantic validation belongs to configuration resolution and to the command
109/// itself.
110#[derive(Clone, Debug, Default)]
111pub struct Arguments {
112    /// Path of an explicit configuration file.
113    pub config: Option<PathBuf>,
114    /// Requested output form.
115    pub output: Option<String>,
116    /// Requested page bound.
117    pub page_size: Option<String>,
118    /// Opaque continuation token from a prior page.
119    pub cursor: Option<String>,
120    /// Idempotency key for a mutating command.
121    pub operation_id: Option<String>,
122    /// Deployment-supplied opaque actor reference.
123    pub actor: Option<String>,
124    /// Bounded closed-set reason code.
125    pub reason: Option<String>,
126    /// Observed optimistic version for a mutation.
127    pub expected_version: Option<u64>,
128    /// Client deadline.
129    pub timeout: Option<String>,
130    /// Validate and report without mutating.
131    pub dry_run: bool,
132    /// Confirm a destructive command non-interactively.
133    pub yes: bool,
134    /// Disable styling.
135    pub no_color: bool,
136    /// Target job name.
137    pub job: Option<String>,
138    /// Target logical instance.
139    pub instance: Option<u64>,
140    /// Target execution attempt.
141    pub execution: Option<u64>,
142    /// Target step execution.
143    pub step: Option<u64>,
144    /// Age bound selecting stale candidates for `execution list`.
145    pub unresolved_age: Option<String>,
146    /// Record family selected by `execution history`.
147    pub record: Option<RecordArg>,
148    /// Recovery disposition.
149    pub directive: Option<DirectiveArg>,
150    /// Framework failure category of a `mark-failed` directive.
151    pub failure_category: Option<String>,
152    /// Opaque failure identifier of a `mark-failed` directive.
153    pub failure_id: Option<u64>,
154    /// Hexadecimal evidence digest binding a recovery decision.
155    pub evidence_digest: Option<String>,
156    /// Minimum age of a purge candidate.
157    pub older_than: Option<String>,
158    /// Bounded purge batch size.
159    pub batch: Option<String>,
160    /// Terminal statuses a purge may target.
161    pub status: Vec<String>,
162    /// Plan digest a `retention apply` must match.
163    pub plan_digest: Option<String>,
164    /// Identifying job parameters supplied as `name=value`.
165    pub parameters: Vec<(String, String)>,
166    /// Path of a typed job-parameter file.
167    pub parameters_file: Option<PathBuf>,
168    /// Target path of a diagnostics bundle.
169    pub out: Option<PathBuf>,
170}
171
172/// A rejected invocation.
173#[derive(Clone, Debug, Eq, PartialEq)]
174#[non_exhaustive]
175pub enum ArgumentError {
176    /// No command word was supplied.
177    MissingCommand,
178    /// The words did not name a command of the closed grammar.
179    UnknownCommand,
180    /// The option is not part of the grammar.
181    UnknownOption {
182        /// Rejected option, without its value.
183        option: String,
184    },
185    /// The option is not accepted by this command.
186    OptionNotAccepted {
187        /// Rejected option.
188        option: String,
189        /// Command that does not accept it.
190        command: Command,
191    },
192    /// The option requires a value that was not supplied.
193    MissingValue {
194        /// Option missing its value.
195        option: String,
196    },
197    /// A single-valued option was supplied more than once.
198    RepeatedOption {
199        /// Repeated option.
200        option: String,
201    },
202    /// The value could not be parsed as the option's type.
203    InvalidValue {
204        /// Option whose value was rejected.
205        option: String,
206    },
207    /// A required option was not supplied.
208    MissingRequiredOption {
209        /// Missing option.
210        option: String,
211        /// Command that requires it.
212        command: Command,
213    },
214    /// Two options that cannot be combined were both supplied.
215    ContradictoryOptions {
216        /// First option.
217        first: String,
218        /// Second option.
219        second: String,
220    },
221    /// The value of a configuration option was rejected.
222    ///
223    /// This variant reports [`ExitCategory::ConfigurationInvalid`] because the
224    /// same value can be supplied by an environment variable or a file.
225    InvalidConfigurationValue {
226        /// Option whose value was rejected.
227        option: String,
228    },
229}
230
231impl ArgumentError {
232    /// Returns the exit category this rejection reports.
233    #[must_use]
234    pub const fn category(&self) -> ExitCategory {
235        match self {
236            Self::InvalidConfigurationValue { .. } => ExitCategory::ConfigurationInvalid,
237            _ => ExitCategory::Usage,
238        }
239    }
240
241    /// Returns the stable machine code of this rejection.
242    #[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/// One accepted option of the grammar.
289#[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    /// Resolves an option name from the closed set.
324    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    /// Returns whether the option is a flag rather than a value option.
360    const fn is_flag(self) -> bool {
361        matches!(self, Self::DryRun | Self::Yes | Self::NoColor)
362    }
363
364    /// Returns whether the option may be repeated.
365    const fn is_repeatable(self) -> bool {
366        matches!(self, Self::Status | Self::Parameter)
367    }
368
369    /// Returns the canonical option text for a diagnostic.
370    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    /// Returns whether the option is accepted by every command.
405    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    /// Returns whether `command` accepts this option.
413    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
478/// Parses one invocation of the closed grammar.
479///
480/// The words are the process arguments without the program name.
481///
482/// # Errors
483///
484/// Returns [`ArgumentError`] for an unknown command or option, a missing or
485/// repeated value, a value that does not parse, or a missing required option.
486pub 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
493/// Splits the leading command words from the options.
494fn 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    // The two-word form is tried first so that `job list` never resolves as a
508    // one-word command with a stray positional argument.
509    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
633/// Rejects an invocation that names no target the command requires.
634fn 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            // The stale form and the per-instance form are the two accepted
655            // shapes; naming both would make the traversal ambiguous.
656            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
699/// Rejects a mutating invocation that omits a field its audit record requires.
700fn 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}