adaptive_pipeline_bootstrap/
exit_code.rs

1// /////////////////////////////////////////////////////////////////////////////
2// Adaptive Pipeline
3// Copyright (c) 2025 Michael Gardner, A Bit of Help, Inc.
4// SPDX-License-Identifier: BSD-3-Clause
5// See LICENSE file in the project root.
6// /////////////////////////////////////////////////////////////////////////////
7
8//! # Exit Code Management
9//!
10//! Provides standardized Unix exit codes following BSD `sysexits.h`
11//! conventions.
12//!
13//! ## Exit Code Conventions
14//!
15//! - **0**: Success
16//! - **1**: General error
17//! - **2**: Misuse of shell command (reserved by Bash)
18//! - **64-78**: Specific error conditions (BSD sysexits.h)
19//! - **126**: Command cannot execute
20//! - **127**: Command not found
21//! - **128+N**: Fatal signal N (e.g., 130 = SIGINT)
22//!
23//! ## Usage
24//!
25//! ```rust,no_run
26//! use adaptive_pipeline_bootstrap::exit_code::ExitCode;
27//!
28//! fn run_application() -> Result<(), Box<dyn std::error::Error>> {
29//!     // Application logic here
30//!     Ok(())
31//! }
32//!
33//! fn main() {
34//!     let result = run_application();
35//!     let exit_code = match result {
36//!         Ok(_) => ExitCode::Success,
37//!         Err(e) => ExitCode::from_error(e.as_ref()),
38//!     };
39//!     std::process::exit(exit_code.as_i32());
40//! }
41//! ```
42
43use std::fmt;
44
45/// Exit codes following Unix conventions (BSD sysexits.h)
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47#[repr(i32)]
48pub enum ExitCode {
49    /// Successful termination (0)
50    #[default]
51    Success = 0,
52
53    /// General error (1)
54    Error = 1,
55
56    /// Command line usage error (64)
57    /// - Invalid arguments
58    /// - Missing required arguments
59    /// - Unknown flags
60    UsageError = 64,
61
62    /// Data format error (65)
63    /// - Invalid input data
64    /// - Malformed configuration
65    /// - Parse errors
66    DataError = 65,
67
68    /// Cannot open input (66)
69    /// - File not found
70    /// - Cannot read file
71    /// - Permission denied on input
72    NoInput = 66,
73
74    /// User does not exist (67)
75    /// - Unknown user specified
76    /// - Invalid user context
77    NoUser = 67,
78
79    /// Host name unknown (68)
80    /// - Unknown host
81    /// - Cannot resolve hostname
82    NoHost = 68,
83
84    /// Service unavailable (69)
85    /// - Required service not running
86    /// - Dependency not available
87    /// - External service unreachable
88    Unavailable = 69,
89
90    /// Internal software error (70)
91    /// - Unexpected error
92    /// - Assertion failure
93    /// - Internal consistency check failed
94    Software = 70,
95
96    /// System error (71)
97    /// - OS error
98    /// - System call failed
99    /// - Fork failed
100    OsError = 71,
101
102    /// Critical OS file missing (72)
103    /// - Required system file not found
104    /// - Missing configuration file
105    OsFile = 72,
106
107    /// Cannot create output file (73)
108    /// - Cannot write output
109    /// - Disk full
110    /// - Permission denied on output
111    CantCreate = 73,
112
113    /// I/O error (74)
114    /// - Read error
115    /// - Write error
116    /// - Network I/O error
117    IoError = 74,
118
119    /// Temporary failure, retry (75)
120    /// - Resource temporarily unavailable
121    /// - Retry operation
122    TempFail = 75,
123
124    /// Remote error in protocol (76)
125    /// - Protocol violation
126    /// - Invalid response
127    /// - Communication error
128    Protocol = 76,
129
130    /// Permission denied (77)
131    /// - Insufficient privileges
132    /// - Access denied
133    /// - Not authorized
134    NoPerm = 77,
135
136    /// Configuration error (78)
137    /// - Invalid configuration
138    /// - Missing required configuration
139    /// - Configuration validation failed
140    Config = 78,
141
142    /// Interrupted by signal (SIGINT - Ctrl+C) (130)
143    /// - User interrupted (Ctrl+C)
144    /// - SIGINT received
145    Interrupted = 130,
146
147    /// Terminated by signal (SIGTERM) (143)
148    /// - SIGTERM received
149    /// - Graceful shutdown requested
150    Terminated = 143,
151}
152
153impl ExitCode {
154    /// Convert to i32 for use with std::process::exit
155    pub fn as_i32(self) -> i32 {
156        self as i32
157    }
158
159    /// Create ExitCode from error type
160    ///
161    /// Maps common error types to appropriate exit codes:
162    /// - I/O errors → IoError (74)
163    /// - Parse errors → DataError (65)
164    /// - Permission errors → NoPerm (77)
165    /// - Not found errors → NoInput (66)
166    /// - Invalid argument → UsageError (64)
167    /// - Other errors → Error (1)
168    pub fn from_error(error: &dyn std::error::Error) -> Self {
169        let error_string = error.to_string().to_lowercase();
170
171        // Check for specific error patterns
172        if error_string.contains("permission") || error_string.contains("access denied") {
173            ExitCode::NoPerm
174        } else if error_string.contains("not found") || error_string.contains("no such") {
175            ExitCode::NoInput
176        } else if error_string.contains("invalid") || error_string.contains("argument") {
177            ExitCode::UsageError
178        } else if error_string.contains("parse") || error_string.contains("format") {
179            ExitCode::DataError
180        } else if error_string.contains("io") || error_string.contains("read") || error_string.contains("write") {
181            ExitCode::IoError
182        } else if error_string.contains("config") {
183            ExitCode::Config
184        } else if error_string.contains("unavailable") || error_string.contains("not available") {
185            ExitCode::Unavailable
186        } else {
187            ExitCode::Error
188        }
189    }
190
191    /// Get human-readable description of exit code
192    pub fn description(self) -> &'static str {
193        match self {
194            ExitCode::Success => "Success",
195            ExitCode::Error => "General error",
196            ExitCode::UsageError => "Command line usage error",
197            ExitCode::DataError => "Data format error",
198            ExitCode::NoInput => "Cannot open input",
199            ExitCode::NoUser => "User does not exist",
200            ExitCode::NoHost => "Host name unknown",
201            ExitCode::Unavailable => "Service unavailable",
202            ExitCode::Software => "Internal software error",
203            ExitCode::OsError => "System error",
204            ExitCode::OsFile => "Critical OS file missing",
205            ExitCode::CantCreate => "Cannot create output file",
206            ExitCode::IoError => "I/O error",
207            ExitCode::TempFail => "Temporary failure, retry",
208            ExitCode::Protocol => "Remote error in protocol",
209            ExitCode::NoPerm => "Permission denied",
210            ExitCode::Config => "Configuration error",
211            ExitCode::Interrupted => "Interrupted by signal (SIGINT)",
212            ExitCode::Terminated => "Terminated by signal (SIGTERM)",
213        }
214    }
215
216    /// Check if this is a success exit code
217    pub fn is_success(self) -> bool {
218        matches!(self, ExitCode::Success)
219    }
220
221    /// Check if this is an error exit code
222    pub fn is_error(self) -> bool {
223        !self.is_success()
224    }
225
226    /// Check if this represents a signal interruption
227    pub fn is_signal(self) -> bool {
228        matches!(self, ExitCode::Interrupted | ExitCode::Terminated)
229    }
230}
231
232impl fmt::Display for ExitCode {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        write!(f, "{} ({})", self.description(), self.as_i32())
235    }
236}
237
238impl From<ExitCode> for i32 {
239    fn from(code: ExitCode) -> i32 {
240        code.as_i32()
241    }
242}
243
244impl From<ExitCode> for std::process::ExitCode {
245    fn from(code: ExitCode) -> std::process::ExitCode {
246        std::process::ExitCode::from(code.as_i32() as u8)
247    }
248}
249
250/// Maps application error messages to Unix exit codes (sysexits.h standard)
251///
252/// This function analyzes error messages and maps them to appropriate exit
253/// codes. It's designed to work with the pipeline's error messages.
254///
255/// # Exit Code Mappings
256///
257/// - `70` (EX_SOFTWARE) - Internal software error (initialization failures)
258/// - `66` (EX_NOINPUT) - Cannot open input (file not found)
259/// - `65` (EX_DATAERR) - Data format error (invalid input)
260/// - `74` (EX_IOERR) - Input/output error (read/write failures)
261/// - `1` - General error (fallback for unclassified errors)
262///
263/// # Arguments
264///
265/// * `error_message` - The error message to classify
266///
267/// # Returns
268///
269/// The appropriate `ExitCode` variant
270///
271/// # Example
272///
273/// ```
274/// use adaptive_pipeline_bootstrap::exit_code::map_error_to_exit_code;
275///
276/// let code = map_error_to_exit_code("Failed to initialize resource manager");
277/// assert_eq!(code.as_i32(), 70); // EX_SOFTWARE
278/// ```
279pub fn map_error_to_exit_code(error_message: &str) -> ExitCode {
280    if error_message.contains("Failed to initialize") {
281        ExitCode::Software // 70 - internal software error
282    } else if error_message.contains("not found") || error_message.contains("does not exist") {
283        ExitCode::NoInput // 66 - cannot open input
284    } else if error_message.contains("invalid") || error_message.contains("Invalid") {
285        ExitCode::DataError // 65 - data format error
286    } else if error_message.contains("I/O")
287        || error_message.contains("Failed to read")
288        || error_message.contains("Failed to write")
289    {
290        ExitCode::IoError // 74 - input/output error
291    } else {
292        ExitCode::Error // 1 - general error
293    }
294}
295
296/// Maps a Result to a process exit code
297///
298/// Convenience function for mapping application results to exit codes.
299///
300/// # Arguments
301///
302/// * `result` - The application result
303///
304/// # Returns
305///
306/// `std::process::ExitCode` - SUCCESS (0) on Ok, or mapped error code on Err
307///
308/// # Example
309///
310/// ```
311/// use adaptive_pipeline_bootstrap::exit_code::result_to_exit_code;
312///
313/// fn run_app() -> Result<(), String> {
314///     Err("File not found: input.txt".to_string())
315/// }
316///
317/// let exit_code = result_to_exit_code(run_app());
318/// // exit_code will be 66 (EX_NOINPUT)
319/// ```
320pub fn result_to_exit_code<E: std::fmt::Display>(result: Result<(), E>) -> std::process::ExitCode {
321    match result {
322        Ok(()) => std::process::ExitCode::SUCCESS,
323        Err(e) => {
324            let error_message = e.to_string();
325            let code = map_error_to_exit_code(&error_message);
326            code.into()
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_exit_code_values() {
337        assert_eq!(ExitCode::Success.as_i32(), 0);
338        assert_eq!(ExitCode::Error.as_i32(), 1);
339        assert_eq!(ExitCode::UsageError.as_i32(), 64);
340        assert_eq!(ExitCode::Config.as_i32(), 78);
341        assert_eq!(ExitCode::Interrupted.as_i32(), 130);
342        assert_eq!(ExitCode::Terminated.as_i32(), 143);
343    }
344
345    #[test]
346    fn test_is_success() {
347        assert!(ExitCode::Success.is_success());
348        assert!(!ExitCode::Error.is_success());
349        assert!(!ExitCode::UsageError.is_success());
350    }
351
352    #[test]
353    fn test_is_error() {
354        assert!(!ExitCode::Success.is_error());
355        assert!(ExitCode::Error.is_error());
356        assert!(ExitCode::Config.is_error());
357    }
358
359    #[test]
360    fn test_is_signal() {
361        assert!(ExitCode::Interrupted.is_signal());
362        assert!(ExitCode::Terminated.is_signal());
363        assert!(!ExitCode::Success.is_signal());
364        assert!(!ExitCode::Error.is_signal());
365    }
366
367    #[test]
368    fn test_default() {
369        assert_eq!(ExitCode::default(), ExitCode::Success);
370    }
371
372    #[test]
373    fn test_display() {
374        let code = ExitCode::UsageError;
375        let display = format!("{}", code);
376        assert!(display.contains("Command line usage error"));
377        assert!(display.contains("64"));
378    }
379
380    #[test]
381    fn test_from_error() {
382        use std::io;
383
384        // Permission error
385        let err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
386        assert_eq!(ExitCode::from_error(&err), ExitCode::NoPerm);
387
388        // Not found error
389        let err = io::Error::new(io::ErrorKind::NotFound, "file not found");
390        assert_eq!(ExitCode::from_error(&err), ExitCode::NoInput);
391    }
392
393    #[test]
394    fn test_conversion_to_i32() {
395        let code: i32 = ExitCode::Config.into();
396        assert_eq!(code, 78);
397    }
398
399    // Tests for map_error_to_exit_code function
400    #[test]
401    fn test_map_error_initialization_error() {
402        assert_eq!(
403            map_error_to_exit_code("Failed to initialize resource manager").as_i32(),
404            70
405        );
406        assert_eq!(
407            map_error_to_exit_code("Error: Failed to initialize database connection").as_i32(),
408            70
409        );
410    }
411
412    #[test]
413    fn test_map_error_file_not_found() {
414        assert_eq!(map_error_to_exit_code("File not found: input.txt").as_i32(), 66);
415        assert_eq!(map_error_to_exit_code("The file does not exist").as_i32(), 66);
416    }
417
418    #[test]
419    fn test_map_error_invalid_data() {
420        assert_eq!(map_error_to_exit_code("invalid chunk size specified").as_i32(), 65);
421        assert_eq!(map_error_to_exit_code("Invalid pipeline configuration").as_i32(), 65);
422    }
423
424    #[test]
425    fn test_map_error_io_error() {
426        assert_eq!(map_error_to_exit_code("I/O error occurred").as_i32(), 74);
427        assert_eq!(map_error_to_exit_code("Failed to read from disk").as_i32(), 74);
428        assert_eq!(map_error_to_exit_code("Failed to write to output file").as_i32(), 74);
429    }
430
431    #[test]
432    fn test_map_error_general_error() {
433        assert_eq!(map_error_to_exit_code("Unknown error occurred").as_i32(), 1);
434        assert_eq!(map_error_to_exit_code("Something went wrong").as_i32(), 1);
435    }
436
437    #[test]
438    fn test_map_error_case_sensitivity() {
439        // Test that "Invalid" (capital I) also triggers DATAERR
440        assert_eq!(map_error_to_exit_code("Invalid input provided").as_i32(), 65);
441        assert_eq!(map_error_to_exit_code("invalid input provided").as_i32(), 65);
442    }
443
444    #[test]
445    fn test_map_error_priority() {
446        // If multiple patterns match, the first one wins
447        // "Failed to initialize" contains "Failed to" but should match initialization
448        // first
449        assert_eq!(
450            map_error_to_exit_code("Failed to initialize with invalid data").as_i32(),
451            70 // Should be EX_SOFTWARE, not EX_DATAERR
452        );
453    }
454
455    #[test]
456    fn test_map_error_exact_messages() {
457        // Test exact error messages from the codebase
458        assert_eq!(map_error_to_exit_code("Pipeline 'test' not found").as_i32(), 66);
459        assert_eq!(map_error_to_exit_code("I/O error: permission denied").as_i32(), 74);
460        assert_eq!(map_error_to_exit_code("Invalid pipeline name").as_i32(), 65);
461    }
462
463    #[test]
464    fn test_result_to_exit_code() {
465        // Test OK case
466        let result: Result<(), String> = Ok(());
467        let exit_code = result_to_exit_code(result);
468        assert_eq!(exit_code, std::process::ExitCode::SUCCESS);
469
470        // Test error case
471        let result: Result<(), String> = Err("File not found".to_string());
472        let exit_code = result_to_exit_code(result);
473        // Should map to NoInput (66)
474        let expected: std::process::ExitCode = ExitCode::NoInput.into();
475        assert_eq!(format!("{:?}", exit_code), format!("{:?}", expected));
476    }
477}