Skip to main content

oxide_batch_cli/
exit.rs

1//! The stable closed set of process exit categories.
2//!
3//! Exit codes are a machine interface. A code is never reused for a different
4//! meaning, and a new meaning takes a new code rather than overloading an
5//! existing one.
6
7use std::fmt;
8
9/// One stable process exit category.
10///
11/// The numeric codes are fixed by the
12/// [operator CLI contract](https://github.com/luceat-lux-vestra/oxide-batch/blob/main/docs/operations/operator-cli.md)
13/// and are part of the published interface.
14#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15#[non_exhaustive]
16pub enum ExitCategory {
17    /// The command completed and its durable effect, if any, is committed.
18    Success,
19    /// The invocation could not be parsed against the closed grammar.
20    Usage,
21    /// Configuration was missing, unknown, out of bounds, or contradictory.
22    ConfigurationInvalid,
23    /// A core guard rejected the action; no effect was applied.
24    GuardRejected,
25    /// The named target does not exist.
26    TargetNotFound,
27    /// The supplied expected version lost its compare-and-swap.
28    OptimisticConflict,
29    /// The durable outcome could not be determined.
30    ///
31    /// This is not a failure. The caller replays the same operation identifier
32    /// to learn the recorded outcome or to re-attempt the effect exactly once.
33    OutcomeUnknown,
34    /// The repository was unavailable or its infrastructure failed.
35    RepositoryUnavailable,
36    /// A destructive command lacked or was denied its required confirmation.
37    ConfirmationRequired,
38    /// The client deadline elapsed before the command completed.
39    DeadlineExceeded,
40    /// Standard output could not be written, including a closed pipe.
41    OutputFailure,
42    /// A defect. An internal error always emits a redacted diagnostic.
43    Internal,
44}
45
46impl ExitCategory {
47    /// Returns the stable process exit code.
48    #[must_use]
49    pub const fn code(self) -> u8 {
50        match self {
51            Self::Success => 0,
52            Self::Usage => 1,
53            Self::ConfigurationInvalid => 2,
54            Self::GuardRejected => 3,
55            Self::TargetNotFound => 4,
56            Self::OptimisticConflict => 5,
57            Self::OutcomeUnknown => 6,
58            Self::RepositoryUnavailable => 7,
59            Self::ConfirmationRequired => 8,
60            Self::DeadlineExceeded => 9,
61            Self::OutputFailure => 10,
62            Self::Internal => 70,
63        }
64    }
65
66    /// Returns the stable machine name of this category.
67    #[must_use]
68    pub const fn as_str(self) -> &'static str {
69        match self {
70            Self::Success => "SUCCESS",
71            Self::Usage => "USAGE",
72            Self::ConfigurationInvalid => "CONFIGURATION_INVALID",
73            Self::GuardRejected => "GUARD_REJECTED",
74            Self::TargetNotFound => "TARGET_NOT_FOUND",
75            Self::OptimisticConflict => "OPTIMISTIC_CONFLICT",
76            Self::OutcomeUnknown => "OUTCOME_UNKNOWN",
77            Self::RepositoryUnavailable => "REPOSITORY_UNAVAILABLE",
78            Self::ConfirmationRequired => "CONFIRMATION_REQUIRED",
79            Self::DeadlineExceeded => "DEADLINE_EXCEEDED",
80            Self::OutputFailure => "OUTPUT_FAILURE",
81            Self::Internal => "INTERNAL",
82        }
83    }
84
85    /// Returns the JSON envelope outcome this category reports.
86    #[must_use]
87    pub const fn outcome(self) -> Outcome {
88        match self {
89            Self::Success => Outcome::Success,
90            Self::GuardRejected | Self::ConfirmationRequired => Outcome::Rejected,
91            Self::OptimisticConflict => Outcome::Conflict,
92            Self::OutcomeUnknown => Outcome::Unknown,
93            Self::Usage
94            | Self::ConfigurationInvalid
95            | Self::TargetNotFound
96            | Self::RepositoryUnavailable
97            | Self::DeadlineExceeded
98            | Self::OutputFailure
99            | Self::Internal => Outcome::Error,
100        }
101    }
102
103    /// Returns every category in code order.
104    ///
105    /// The published exit-category test walks this slice, so a new category
106    /// cannot be added without a named case proving it.
107    #[must_use]
108    pub const fn all() -> &'static [Self] {
109        &[
110            Self::Success,
111            Self::Usage,
112            Self::ConfigurationInvalid,
113            Self::GuardRejected,
114            Self::TargetNotFound,
115            Self::OptimisticConflict,
116            Self::OutcomeUnknown,
117            Self::RepositoryUnavailable,
118            Self::ConfirmationRequired,
119            Self::DeadlineExceeded,
120            Self::OutputFailure,
121            Self::Internal,
122        ]
123    }
124}
125
126impl fmt::Display for ExitCategory {
127    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
128        formatter.write_str(self.as_str())
129    }
130}
131
132/// The `outcome` field of the versioned JSON envelope.
133#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
134#[non_exhaustive]
135pub enum Outcome {
136    /// The command applied or replayed its effect.
137    Success,
138    /// A guard or a confirmation rule refused the action.
139    Rejected,
140    /// An optimistic version lost its compare-and-swap.
141    Conflict,
142    /// The durable outcome is undetermined and must be replayed.
143    Unknown,
144    /// The command failed before reaching a durable decision.
145    Error,
146}
147
148impl Outcome {
149    /// Returns the stable machine name of this outcome.
150    #[must_use]
151    pub const fn as_str(self) -> &'static str {
152        match self {
153            Self::Success => "success",
154            Self::Rejected => "rejected",
155            Self::Conflict => "conflict",
156            Self::Unknown => "unknown",
157            Self::Error => "error",
158        }
159    }
160}
161
162impl fmt::Display for Outcome {
163    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164        formatter.write_str(self.as_str())
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    #![allow(clippy::expect_used, clippy::panic)]
171
172    use super::ExitCategory;
173
174    #[test]
175    fn codes_are_unique_and_ordered() {
176        let mut seen = Vec::new();
177        for category in ExitCategory::all() {
178            let code = category.code();
179            assert!(!seen.contains(&code), "exit code {code} is reused");
180            seen.push(code);
181        }
182    }
183
184    #[test]
185    fn published_codes_never_change() {
186        assert_eq!(ExitCategory::Success.code(), 0);
187        assert_eq!(ExitCategory::Usage.code(), 1);
188        assert_eq!(ExitCategory::ConfigurationInvalid.code(), 2);
189        assert_eq!(ExitCategory::GuardRejected.code(), 3);
190        assert_eq!(ExitCategory::TargetNotFound.code(), 4);
191        assert_eq!(ExitCategory::OptimisticConflict.code(), 5);
192        assert_eq!(ExitCategory::OutcomeUnknown.code(), 6);
193        assert_eq!(ExitCategory::RepositoryUnavailable.code(), 7);
194        assert_eq!(ExitCategory::ConfirmationRequired.code(), 8);
195        assert_eq!(ExitCategory::DeadlineExceeded.code(), 9);
196        assert_eq!(ExitCategory::OutputFailure.code(), 10);
197        assert_eq!(ExitCategory::Internal.code(), 70);
198    }
199}