Skip to main content

oxide_batch_cli/
command.rs

1//! The closed command grammar.
2//!
3//! Nouns and verbs are fixed at compile time. There is no plugin, alias, or
4//! dynamic discovery, so an unknown word is always an error rather than an
5//! extension point.
6
7use std::fmt;
8
9use oxide_batch::AuthorizationClass;
10
11/// One command of the closed `oxide-batch <noun> <verb>` grammar.
12#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
13#[non_exhaustive]
14pub enum Command {
15    /// List registered job names.
16    JobList,
17    /// Show one job's definition identity.
18    JobShow,
19    /// List instances of a job.
20    InstanceList,
21    /// Show one instance.
22    InstanceShow,
23    /// List executions of an instance, or age-bounded stale candidates.
24    ExecutionList,
25    /// Show one execution projection.
26    ExecutionShow,
27    /// List step executions.
28    ExecutionSteps,
29    /// List partitions of a partitioned step.
30    ExecutionPartitions,
31    /// List flow, recovery, and operator records.
32    ExecutionHistory,
33    /// Request a durable stop.
34    ExecutionStop,
35    /// Start a new attempt.
36    ExecutionRestart,
37    /// Make an execution permanently non-restartable.
38    ExecutionAbandon,
39    /// Propose and apply a recovery decision.
40    ExecutionRecover,
41    /// Launch a registered job.
42    Launch,
43    /// Produce a bounded purge plan and digest.
44    RetentionPlan,
45    /// Apply a purge plan.
46    RetentionApply,
47    /// Place a hold on an instance.
48    RetentionHold,
49    /// Release a hold.
50    RetentionRelease,
51    /// Print effective configuration with sources.
52    ConfigShow,
53    /// Report schema version and migration state.
54    SchemaStatus,
55    /// Write a bounded redacted incident bundle.
56    DiagnosticsBundle,
57}
58
59impl Command {
60    /// Resolves one closed noun and verb.
61    ///
62    /// `launch` is the single one-word command; every other command is a noun
63    /// followed by a verb.
64    #[must_use]
65    pub fn resolve(words: &[&str]) -> Option<Self> {
66        match words {
67            ["launch"] => Some(Self::Launch),
68            ["job", "list"] => Some(Self::JobList),
69            ["job", "show"] => Some(Self::JobShow),
70            ["instance", "list"] => Some(Self::InstanceList),
71            ["instance", "show"] => Some(Self::InstanceShow),
72            ["execution", "list"] => Some(Self::ExecutionList),
73            ["execution", "show"] => Some(Self::ExecutionShow),
74            ["execution", "steps"] => Some(Self::ExecutionSteps),
75            ["execution", "partitions"] => Some(Self::ExecutionPartitions),
76            ["execution", "history"] => Some(Self::ExecutionHistory),
77            ["execution", "stop"] => Some(Self::ExecutionStop),
78            ["execution", "restart"] => Some(Self::ExecutionRestart),
79            ["execution", "abandon"] => Some(Self::ExecutionAbandon),
80            ["execution", "recover"] => Some(Self::ExecutionRecover),
81            ["retention", "plan"] => Some(Self::RetentionPlan),
82            ["retention", "apply"] => Some(Self::RetentionApply),
83            ["retention", "hold"] => Some(Self::RetentionHold),
84            ["retention", "release"] => Some(Self::RetentionRelease),
85            ["config", "show"] => Some(Self::ConfigShow),
86            ["schema", "status"] => Some(Self::SchemaStatus),
87            ["diagnostics", "bundle"] => Some(Self::DiagnosticsBundle),
88            _ => None,
89        }
90    }
91
92    /// Returns the canonical space-separated command name.
93    #[must_use]
94    pub const fn as_str(self) -> &'static str {
95        match self {
96            Self::JobList => "job list",
97            Self::JobShow => "job show",
98            Self::InstanceList => "instance list",
99            Self::InstanceShow => "instance show",
100            Self::ExecutionList => "execution list",
101            Self::ExecutionShow => "execution show",
102            Self::ExecutionSteps => "execution steps",
103            Self::ExecutionPartitions => "execution partitions",
104            Self::ExecutionHistory => "execution history",
105            Self::ExecutionStop => "execution stop",
106            Self::ExecutionRestart => "execution restart",
107            Self::ExecutionAbandon => "execution abandon",
108            Self::ExecutionRecover => "execution recover",
109            Self::Launch => "launch",
110            Self::RetentionPlan => "retention plan",
111            Self::RetentionApply => "retention apply",
112            Self::RetentionHold => "retention hold",
113            Self::RetentionRelease => "retention release",
114            Self::ConfigShow => "config show",
115            Self::SchemaStatus => "schema status",
116            Self::DiagnosticsBundle => "diagnostics bundle",
117        }
118    }
119
120    /// Returns the class a deployment authorizes before the command runs.
121    #[must_use]
122    pub const fn class(self) -> ActionClass {
123        match self {
124            Self::JobList
125            | Self::JobShow
126            | Self::InstanceList
127            | Self::InstanceShow
128            | Self::ExecutionList
129            | Self::ExecutionShow
130            | Self::ExecutionSteps
131            | Self::ExecutionPartitions
132            | Self::ExecutionHistory
133            | Self::RetentionPlan
134            | Self::ConfigShow
135            | Self::SchemaStatus
136            | Self::DiagnosticsBundle => ActionClass::Read,
137            Self::ExecutionStop | Self::ExecutionRestart | Self::Launch => ActionClass::Lifecycle,
138            Self::ExecutionAbandon
139            | Self::ExecutionRecover
140            | Self::RetentionApply
141            | Self::RetentionHold
142            | Self::RetentionRelease => ActionClass::Destructive,
143        }
144    }
145
146    /// Returns whether the command can change durable state.
147    #[must_use]
148    pub const fn is_mutating(self) -> bool {
149        !matches!(self.class(), ActionClass::Read)
150    }
151
152    /// Returns whether `--dry-run` is accepted.
153    ///
154    /// Dry run is offered only where a guard evaluation or a plan digest is
155    /// worth reporting without a mutation.
156    #[must_use]
157    pub const fn supports_dry_run(self) -> bool {
158        matches!(
159            self,
160            Self::Launch | Self::ExecutionRestart | Self::ExecutionRecover | Self::RetentionApply
161        )
162    }
163
164    /// Returns whether the command reads a bounded page of rows.
165    #[must_use]
166    pub const fn is_paginated(self) -> bool {
167        matches!(
168            self,
169            Self::JobList
170                | Self::InstanceList
171                | Self::ExecutionList
172                | Self::ExecutionSteps
173                | Self::ExecutionPartitions
174                | Self::ExecutionHistory
175        )
176    }
177
178    /// Returns whether the command needs an open repository connection.
179    #[must_use]
180    pub const fn needs_repository(self) -> bool {
181        !matches!(self, Self::ConfigShow)
182    }
183
184    /// Returns every command in canonical order.
185    #[must_use]
186    pub const fn all() -> &'static [Self] {
187        &[
188            Self::JobList,
189            Self::JobShow,
190            Self::InstanceList,
191            Self::InstanceShow,
192            Self::ExecutionList,
193            Self::ExecutionShow,
194            Self::ExecutionSteps,
195            Self::ExecutionPartitions,
196            Self::ExecutionHistory,
197            Self::ExecutionStop,
198            Self::ExecutionRestart,
199            Self::ExecutionAbandon,
200            Self::ExecutionRecover,
201            Self::Launch,
202            Self::RetentionPlan,
203            Self::RetentionApply,
204            Self::RetentionHold,
205            Self::RetentionRelease,
206            Self::ConfigShow,
207            Self::SchemaStatus,
208            Self::DiagnosticsBundle,
209        ]
210    }
211}
212
213impl fmt::Display for Command {
214    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215        formatter.write_str(self.as_str())
216    }
217}
218
219/// The separately authorizable class of one command.
220///
221/// The class mirrors [`AuthorizationClass`] so a deployment authorizes the CLI
222/// with the same vocabulary it authorizes the portable services.
223#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
224#[non_exhaustive]
225pub enum ActionClass {
226    /// Inspection and planning. Deletes and changes nothing.
227    Read,
228    /// Launch, restart, and stop.
229    Lifecycle,
230    /// Abandon, recover, hold, release, and purge application.
231    Destructive,
232}
233
234impl ActionClass {
235    /// Returns the stable machine name of this class.
236    #[must_use]
237    pub const fn as_str(self) -> &'static str {
238        match self {
239            Self::Read => "READ",
240            Self::Lifecycle => "LIFECYCLE",
241            Self::Destructive => "DESTRUCTIVE",
242        }
243    }
244
245    /// Returns whether the class requires explicit confirmation.
246    #[must_use]
247    pub const fn requires_confirmation(self) -> bool {
248        matches!(self, Self::Destructive)
249    }
250}
251
252impl From<AuthorizationClass> for ActionClass {
253    fn from(value: AuthorizationClass) -> Self {
254        match value {
255            AuthorizationClass::Lifecycle => Self::Lifecycle,
256            AuthorizationClass::Destructive => Self::Destructive,
257            _ => Self::Read,
258        }
259    }
260}
261
262impl fmt::Display for ActionClass {
263    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264        formatter.write_str(self.as_str())
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    #![allow(clippy::expect_used, clippy::panic)]
271
272    use super::{ActionClass, Command};
273
274    #[test]
275    fn every_command_resolves_from_its_canonical_name() {
276        for command in Command::all() {
277            let words: Vec<&str> = command.as_str().split(' ').collect();
278            assert_eq!(Command::resolve(&words), Some(*command));
279        }
280    }
281
282    #[test]
283    fn unknown_words_do_not_resolve() {
284        assert_eq!(Command::resolve(&["job", "delete"]), None);
285        assert_eq!(Command::resolve(&["jobs", "list"]), None);
286        assert_eq!(Command::resolve(&["launch", "now"]), None);
287        assert_eq!(Command::resolve(&[]), None);
288    }
289
290    #[test]
291    fn destructive_commands_require_confirmation() {
292        for command in Command::all() {
293            assert_eq!(
294                command.class().requires_confirmation(),
295                matches!(command.class(), ActionClass::Destructive),
296                "{command} confirmation rule disagrees with its class"
297            );
298        }
299    }
300
301    #[test]
302    fn read_commands_never_mutate() {
303        for command in Command::all() {
304            if matches!(command.class(), ActionClass::Read) {
305                assert!(!command.is_mutating(), "{command} is read but mutating");
306            }
307        }
308    }
309}