Skip to main content

alopex_cli/batch/
exit_code.rs

1use crate::config::EXIT_CODE_INTERRUPTED;
2
3#[allow(dead_code)]
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5#[repr(i32)]
6pub enum ExitCode {
7    Success = 0,
8    Error = 1,
9    Warning = 2,
10    Retryable = 3,
11    Authorization = 4,
12    Unsupported = 5,
13    Interrupted = EXIT_CODE_INTERRUPTED,
14}
15
16/// Normalized terminal class for distributed SQL reads. It deliberately has a
17/// narrower vocabulary than arbitrary HTTP/server errors so shell automation
18/// can distinguish unsupported input from retryable infrastructure failures.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DistributedReadOutcome {
21    Success,
22    Unsupported,
23    AuthorizationFailure,
24    RetryableFailure,
25    TerminalFailure,
26    Cancelled,
27}
28
29impl DistributedReadOutcome {
30    pub fn exit_code(self) -> ExitCode {
31        match self {
32            Self::Success => ExitCode::Success,
33            Self::Unsupported => ExitCode::Unsupported,
34            Self::AuthorizationFailure => ExitCode::Authorization,
35            Self::RetryableFailure => ExitCode::Retryable,
36            Self::TerminalFailure => ExitCode::Error,
37            Self::Cancelled => ExitCode::Interrupted,
38        }
39    }
40
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::Success => "success",
44            Self::Unsupported => "unsupported",
45            Self::AuthorizationFailure => "authorization_failure",
46            Self::RetryableFailure => "retryable_failure",
47            Self::TerminalFailure => "terminal_failure",
48            Self::Cancelled => "cancelled",
49        }
50    }
51}
52
53#[allow(dead_code)]
54impl ExitCode {
55    pub fn as_i32(self) -> i32 {
56        self as i32
57    }
58}
59
60/// Normalized outcome classes for `server cluster` management operations.
61/// Unknown server values deliberately fail closed as terminal failures, rather
62/// than allowing an unrecognized result to look successful to automation.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ClusterManagementOutcome {
65    Succeeded,
66    Pending,
67    RetryableFailure,
68    TerminalFailure,
69    AuthorizationFailure,
70}
71
72impl ClusterManagementOutcome {
73    pub fn from_wire(outcome_class: &str, reason: &str) -> Self {
74        let outcome_class = outcome_class.to_ascii_lowercase();
75        let reason = reason.to_ascii_lowercase();
76        if outcome_class == "authorization_failure"
77            || reason.contains("authorization")
78            || reason.contains("permission")
79            || reason.contains("forbidden")
80        {
81            return Self::AuthorizationFailure;
82        }
83        match outcome_class.as_str() {
84            "succeeded" | "success" => Self::Succeeded,
85            "pending" => Self::Pending,
86            "retryable_failure" => Self::RetryableFailure,
87            "terminal_failure" => Self::TerminalFailure,
88            _ => Self::TerminalFailure,
89        }
90    }
91
92    pub fn exit_code(self) -> ExitCode {
93        match self {
94            Self::Succeeded => ExitCode::Success,
95            Self::Pending => ExitCode::Warning,
96            Self::RetryableFailure => ExitCode::Retryable,
97            Self::TerminalFailure => ExitCode::Error,
98            Self::AuthorizationFailure => ExitCode::Authorization,
99        }
100    }
101
102    pub fn is_success(self) -> bool {
103        matches!(self, Self::Succeeded)
104    }
105}
106
107#[allow(dead_code)]
108#[derive(Debug, Default)]
109pub struct ExitCodeCollector {
110    success_count: usize,
111    error_count: usize,
112}
113
114#[allow(dead_code)]
115impl ExitCodeCollector {
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    pub fn record_success(&mut self) {
121        self.success_count += 1;
122    }
123
124    pub fn record_error(&mut self) {
125        self.error_count += 1;
126    }
127
128    pub fn finalize(&self) -> ExitCode {
129        match (self.success_count > 0, self.error_count > 0) {
130            (false, false) => ExitCode::Success,
131            (true, false) => ExitCode::Success,
132            (false, true) => ExitCode::Error,
133            (true, true) => ExitCode::Warning,
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn exit_code_values() {
144        assert_eq!(ExitCode::Success.as_i32(), 0);
145        assert_eq!(ExitCode::Error.as_i32(), 1);
146        assert_eq!(ExitCode::Warning.as_i32(), 2);
147        assert_eq!(ExitCode::Retryable.as_i32(), 3);
148        assert_eq!(ExitCode::Authorization.as_i32(), 4);
149        assert_eq!(ExitCode::Unsupported.as_i32(), 5);
150        assert_eq!(ExitCode::Interrupted.as_i32(), EXIT_CODE_INTERRUPTED);
151    }
152
153    #[test]
154    fn cluster_management_outcomes_have_stable_exit_classes() {
155        assert_eq!(
156            ClusterManagementOutcome::from_wire("succeeded", "committed").exit_code(),
157            ExitCode::Success
158        );
159        assert_eq!(
160            ClusterManagementOutcome::from_wire("pending", "waiting_for_quorum").exit_code(),
161            ExitCode::Warning
162        );
163        assert_eq!(
164            ClusterManagementOutcome::from_wire("retryable_failure", "not_leader").exit_code(),
165            ExitCode::Retryable
166        );
167        assert_eq!(
168            ClusterManagementOutcome::from_wire("terminal_failure", "stale_version").exit_code(),
169            ExitCode::Error
170        );
171        assert_eq!(
172            ClusterManagementOutcome::from_wire("terminal_failure", "authorization_denied")
173                .exit_code(),
174            ExitCode::Authorization
175        );
176        assert_eq!(
177            ClusterManagementOutcome::from_wire("new_outcome", "unknown").exit_code(),
178            ExitCode::Error
179        );
180    }
181
182    #[test]
183    fn collector_defaults_to_success() {
184        let collector = ExitCodeCollector::new();
185
186        assert_eq!(collector.finalize(), ExitCode::Success);
187    }
188
189    #[test]
190    fn collector_reports_success_only() {
191        let mut collector = ExitCodeCollector::new();
192        collector.record_success();
193
194        assert_eq!(collector.finalize(), ExitCode::Success);
195    }
196
197    #[test]
198    fn collector_reports_error_only() {
199        let mut collector = ExitCodeCollector::new();
200        collector.record_error();
201
202        assert_eq!(collector.finalize(), ExitCode::Error);
203    }
204
205    #[test]
206    fn collector_reports_warning_on_mixed_results() {
207        let mut collector = ExitCodeCollector::new();
208        collector.record_success();
209        collector.record_error();
210
211        assert_eq!(collector.finalize(), ExitCode::Warning);
212    }
213
214    #[test]
215    fn distributed_read_outcomes_have_distinct_exit_classes() {
216        assert_eq!(
217            DistributedReadOutcome::Success.exit_code(),
218            ExitCode::Success
219        );
220        assert_eq!(
221            DistributedReadOutcome::Unsupported.exit_code(),
222            ExitCode::Unsupported
223        );
224        assert_eq!(
225            DistributedReadOutcome::AuthorizationFailure.exit_code(),
226            ExitCode::Authorization
227        );
228        assert_eq!(
229            DistributedReadOutcome::RetryableFailure.exit_code(),
230            ExitCode::Retryable
231        );
232        assert_eq!(
233            DistributedReadOutcome::TerminalFailure.exit_code(),
234            ExitCode::Error
235        );
236        assert_eq!(
237            DistributedReadOutcome::Cancelled.exit_code(),
238            ExitCode::Interrupted
239        );
240    }
241}