Skip to main content

xchecker_utils/
exit_codes.rs

1//! Exit code constants and error kind mapping for xchecker.
2//!
3//! This module defines standardized exit codes for different failure modes
4//! and provides mapping from `XCheckerError` to exit codes and error kinds.
5//!
6//! # Exit Code Table
7//!
8//! | Code | Constant | Description |
9//! |------|----------|-------------|
10//! | 0 | `SUCCESS` | Operation completed successfully |
11//! | 1 | `INTERNAL` | General/internal failure |
12//! | 2 | `CLI_ARGS` | Invalid CLI arguments or configuration |
13//! | 7 | `PACKET_OVERFLOW` | Input packet exceeded size limits |
14//! | 8 | `SECRET_DETECTED` | Secret found in content (security) |
15//! | 9 | `LOCK_HELD` | Another process holds the lock |
16//! | 10 | `PHASE_TIMEOUT` | Phase execution timed out |
17//! | 70 | `CLAUDE_FAILURE` | Claude CLI invocation failed |
18
19use crate::error::XCheckerError;
20use crate::types::ErrorKind;
21
22/// Exit codes matching the documented exit code table.
23///
24/// `ExitCode` provides type-safe exit code handling for xchecker operations.
25/// Use the named constants for common exit codes, or [`as_i32()`](Self::as_i32)
26/// to get the numeric value for `std::process::exit()`.
27///
28/// This is a stable public type. The numeric values are part of the public API
29/// and will not change in 1.x releases.
30///
31/// # Constants
32///
33/// | Constant | Value | Description |
34/// |----------|-------|-------------|
35/// | [`SUCCESS`](Self::SUCCESS) | 0 | Operation completed successfully |
36/// | [`INTERNAL`](Self::INTERNAL) | 1 | General/internal failure |
37/// | [`CLI_ARGS`](Self::CLI_ARGS) | 2 | Invalid CLI arguments |
38/// | [`PACKET_OVERFLOW`](Self::PACKET_OVERFLOW) | 7 | Packet size exceeded |
39/// | [`SECRET_DETECTED`](Self::SECRET_DETECTED) | 8 | Secret found in content |
40/// | [`LOCK_HELD`](Self::LOCK_HELD) | 9 | Lock already held |
41/// | [`PHASE_TIMEOUT`](Self::PHASE_TIMEOUT) | 10 | Phase timed out |
42/// | [`CLAUDE_FAILURE`](Self::CLAUDE_FAILURE) | 70 | Claude CLI failed |
43///
44/// # Example
45///
46/// ```rust
47/// use xchecker_utils::exit_codes::ExitCode;
48///
49/// // Using named constants
50/// let code = ExitCode::SUCCESS;
51/// assert_eq!(code.as_i32(), 0);
52///
53/// let code = ExitCode::PACKET_OVERFLOW;
54/// assert_eq!(code.as_i32(), 7);
55///
56/// // Comparing exit codes
57/// assert_eq!(ExitCode::SUCCESS, ExitCode::from_i32(0));
58/// ```
59///
60/// # Integration with XCheckerError
61///
62/// Use [`XCheckerError::to_exit_code()`](crate::error::XCheckerError::to_exit_code) to map
63/// errors to exit codes:
64///
65/// ```rust
66/// use xchecker_utils::error::ConfigError;
67/// use xchecker_utils::error::XCheckerError;
68/// use xchecker_utils::exit_codes::ExitCode;
69///
70/// let err = XCheckerError::Config(ConfigError::InvalidFile("test".to_string()));
71/// assert_eq!(err.to_exit_code(), ExitCode::CLI_ARGS);
72/// ```
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct ExitCode(i32);
75
76impl ExitCode {
77    /// Success - operation completed successfully
78    pub const SUCCESS: ExitCode = ExitCode(0);
79
80    /// CLI arguments error - invalid or missing command-line arguments
81    pub const CLI_ARGS: ExitCode = ExitCode(2);
82
83    /// Packet overflow - input packet exceeded size limits before Claude invocation
84    pub const PACKET_OVERFLOW: ExitCode = ExitCode(7);
85
86    /// Secret detected - redaction system detected potential secrets
87    pub const SECRET_DETECTED: ExitCode = ExitCode(8);
88
89    /// Lock held - another process is already working on the same spec
90    pub const LOCK_HELD: ExitCode = ExitCode(9);
91
92    /// Phase timeout - phase execution exceeded configured timeout
93    pub const PHASE_TIMEOUT: ExitCode = ExitCode(10);
94
95    /// Claude failure - underlying Claude CLI invocation failed
96    pub const CLAUDE_FAILURE: ExitCode = ExitCode(70);
97
98    /// Internal error - general failure
99    pub const INTERNAL: ExitCode = ExitCode(1);
100
101    /// Get the numeric exit code value.
102    ///
103    /// Use this with `std::process::exit()`.
104    #[must_use]
105    pub const fn as_i32(self) -> i32 {
106        self.0
107    }
108
109    /// Create an ExitCode from a raw i32 value.
110    ///
111    /// Prefer using the named constants when possible.
112    #[must_use]
113    pub const fn from_i32(code: i32) -> Self {
114        ExitCode(code)
115    }
116}
117
118impl From<i32> for ExitCode {
119    fn from(code: i32) -> Self {
120        ExitCode(code)
121    }
122}
123
124impl From<ExitCode> for i32 {
125    fn from(code: ExitCode) -> Self {
126        code.0
127    }
128}
129
130/// Exit code constants for xchecker (legacy module for backward compatibility)
131pub mod codes {
132    /// Success - operation completed successfully
133    #[allow(dead_code)] // Used in tests (line 103)
134    pub const SUCCESS: i32 = 0;
135
136    /// CLI arguments error - invalid or missing command-line arguments
137    pub const CLI_ARGS: i32 = 2;
138
139    /// Packet overflow - input packet exceeded size limits before Claude invocation
140    pub const PACKET_OVERFLOW: i32 = 7;
141
142    /// Secret detected - redaction system detected potential secrets
143    pub const SECRET_DETECTED: i32 = 8;
144
145    /// Lock held - another process is already working on the same spec
146    pub const LOCK_HELD: i32 = 9;
147
148    /// Phase timeout - phase execution exceeded configured timeout
149    pub const PHASE_TIMEOUT: i32 = 10;
150
151    /// Claude failure - underlying Claude CLI invocation failed
152    pub const CLAUDE_FAILURE: i32 = 70;
153}
154
155/// Convert `XCheckerError` to (`exit_code`, `error_kind`) tuple
156#[allow(dead_code)] // Error handling utility for receipt generation
157pub fn error_to_exit_code_and_kind(error: &XCheckerError) -> (i32, ErrorKind) {
158    match error {
159        // Configuration errors map to CLI_ARGS
160        XCheckerError::Config(_) => (codes::CLI_ARGS, ErrorKind::CliArgs),
161
162        // Packet overflow before Claude invocation
163        XCheckerError::PacketOverflow { .. } => (codes::PACKET_OVERFLOW, ErrorKind::PacketOverflow),
164
165        // Secret detection (redaction hard stop)
166        XCheckerError::SecretDetected { .. } => (codes::SECRET_DETECTED, ErrorKind::SecretDetected),
167
168        // Concurrent execution / lock held
169        XCheckerError::ConcurrentExecution { .. } => (codes::LOCK_HELD, ErrorKind::LockHeld),
170        XCheckerError::Lock(_) => (codes::LOCK_HELD, ErrorKind::LockHeld),
171
172        // Phase errors
173        XCheckerError::Phase(phase_err) => {
174            use crate::error::PhaseError;
175            match phase_err {
176                PhaseError::Timeout { .. } => (codes::PHASE_TIMEOUT, ErrorKind::PhaseTimeout),
177                // Invalid transitions are CLI argument errors (FR-ORC-001, FR-ORC-002)
178                PhaseError::InvalidTransition { .. } => (codes::CLI_ARGS, ErrorKind::CliArgs),
179                PhaseError::DependencyNotSatisfied { .. } => (codes::CLI_ARGS, ErrorKind::CliArgs),
180                _ => (1, ErrorKind::Unknown),
181            }
182        }
183
184        // Claude CLI failures
185        XCheckerError::Claude(_) => (codes::CLAUDE_FAILURE, ErrorKind::ClaudeFailure),
186        XCheckerError::Runner(_) => (codes::CLAUDE_FAILURE, ErrorKind::ClaudeFailure),
187
188        // LLM backend errors
189        XCheckerError::Llm(llm_err) => {
190            use crate::error::LlmError;
191            match llm_err {
192                LlmError::ProviderAuth(_) => (codes::CLAUDE_FAILURE, ErrorKind::ClaudeFailure),
193                LlmError::ProviderQuota(_) => (codes::CLAUDE_FAILURE, ErrorKind::ClaudeFailure),
194                LlmError::ProviderOutage(_) => (codes::CLAUDE_FAILURE, ErrorKind::ClaudeFailure),
195                LlmError::Timeout { .. } => (codes::PHASE_TIMEOUT, ErrorKind::PhaseTimeout),
196                LlmError::Misconfiguration(_) => (codes::CLI_ARGS, ErrorKind::CliArgs),
197                LlmError::Unsupported(_) => (codes::CLI_ARGS, ErrorKind::CliArgs),
198                LlmError::Transport(_) => (codes::CLAUDE_FAILURE, ErrorKind::ClaudeFailure),
199                LlmError::BudgetExceeded { .. } => {
200                    (codes::CLAUDE_FAILURE, ErrorKind::ClaudeFailure)
201                }
202            }
203        }
204
205        // All other errors default to exit code 1 with Unknown kind
206        _ => (1, ErrorKind::Unknown),
207    }
208}
209
210/// Convert `XCheckerError` to (`exit_code`, `error_kind`) tuple
211impl From<&XCheckerError> for (i32, ErrorKind) {
212    fn from(err: &XCheckerError) -> (i32, ErrorKind) {
213        error_to_exit_code_and_kind(err)
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::error::{ClaudeError, ConfigError, PhaseError, RunnerError};
221    use crate::lock::LockError;
222    use crate::types::ErrorKind;
223
224    #[test]
225    fn test_exit_code_constants() {
226        assert_eq!(codes::SUCCESS, 0);
227        assert_eq!(codes::CLI_ARGS, 2);
228        assert_eq!(codes::PACKET_OVERFLOW, 7);
229        assert_eq!(codes::SECRET_DETECTED, 8);
230        assert_eq!(codes::LOCK_HELD, 9);
231        assert_eq!(codes::PHASE_TIMEOUT, 10);
232        assert_eq!(codes::CLAUDE_FAILURE, 70);
233    }
234
235    #[test]
236    fn test_error_kind_serialization() {
237        // Test snake_case serialization
238        let json = serde_json::to_string(&ErrorKind::CliArgs).unwrap();
239        assert_eq!(json, r#""cli_args""#);
240
241        let json = serde_json::to_string(&ErrorKind::PacketOverflow).unwrap();
242        assert_eq!(json, r#""packet_overflow""#);
243
244        let json = serde_json::to_string(&ErrorKind::SecretDetected).unwrap();
245        assert_eq!(json, r#""secret_detected""#);
246
247        let json = serde_json::to_string(&ErrorKind::LockHeld).unwrap();
248        assert_eq!(json, r#""lock_held""#);
249
250        let json = serde_json::to_string(&ErrorKind::PhaseTimeout).unwrap();
251        assert_eq!(json, r#""phase_timeout""#);
252
253        let json = serde_json::to_string(&ErrorKind::ClaudeFailure).unwrap();
254        assert_eq!(json, r#""claude_failure""#);
255
256        let json = serde_json::to_string(&ErrorKind::Unknown).unwrap();
257        assert_eq!(json, r#""unknown""#);
258    }
259
260    #[test]
261    fn test_config_error_mapping() {
262        let err = XCheckerError::Config(ConfigError::InvalidFile("test".to_string()));
263        let (code, kind) = (&err).into();
264        assert_eq!(code, codes::CLI_ARGS);
265        assert_eq!(kind, ErrorKind::CliArgs);
266    }
267
268    #[test]
269    fn test_packet_overflow_mapping() {
270        let err = XCheckerError::PacketOverflow {
271            used_bytes: 100000,
272            used_lines: 2000,
273            limit_bytes: 65536,
274            limit_lines: 1200,
275        };
276        let (code, kind) = (&err).into();
277        assert_eq!(code, codes::PACKET_OVERFLOW);
278        assert_eq!(kind, ErrorKind::PacketOverflow);
279    }
280
281    #[test]
282    fn test_secret_detected_mapping() {
283        let err = XCheckerError::SecretDetected {
284            pattern: "ghp_".to_string(),
285            location: "test.txt".to_string(),
286        };
287        let (code, kind) = (&err).into();
288        assert_eq!(code, codes::SECRET_DETECTED);
289        assert_eq!(kind, ErrorKind::SecretDetected);
290    }
291
292    #[test]
293    fn test_concurrent_execution_mapping() {
294        let err = XCheckerError::ConcurrentExecution {
295            id: "test-spec".to_string(),
296        };
297        let (code, kind) = (&err).into();
298        assert_eq!(code, codes::LOCK_HELD);
299        assert_eq!(kind, ErrorKind::LockHeld);
300    }
301
302    #[test]
303    fn test_lock_error_mapping() {
304        let lock_err = LockError::ConcurrentExecution {
305            spec_id: "test-spec".to_string(),
306            pid: 12345,
307            created_ago: "5m".to_string(),
308        };
309        let err = XCheckerError::Lock(lock_err);
310        let (code, kind) = (&err).into();
311        assert_eq!(code, codes::LOCK_HELD);
312        assert_eq!(kind, ErrorKind::LockHeld);
313    }
314
315    #[test]
316    fn test_phase_timeout_mapping() {
317        let phase_err = PhaseError::Timeout {
318            phase: "REQUIREMENTS".to_string(),
319            timeout_seconds: 600,
320        };
321        let err = XCheckerError::Phase(phase_err);
322        let (code, kind) = (&err).into();
323        assert_eq!(code, codes::PHASE_TIMEOUT);
324        assert_eq!(kind, ErrorKind::PhaseTimeout);
325    }
326
327    #[test]
328    fn test_phase_non_timeout_mapping() {
329        let phase_err = PhaseError::ExecutionFailed {
330            phase: "DESIGN".to_string(),
331            code: 1,
332        };
333        let err = XCheckerError::Phase(phase_err);
334        let (code, kind) = (&err).into();
335        assert_eq!(code, 1);
336        assert_eq!(kind, ErrorKind::Unknown);
337    }
338
339    #[test]
340    fn test_claude_error_mapping() {
341        let claude_err = ClaudeError::ExecutionFailed {
342            stderr: "API error".to_string(),
343        };
344        let err = XCheckerError::Claude(claude_err);
345        let (code, kind) = (&err).into();
346        assert_eq!(code, codes::CLAUDE_FAILURE);
347        assert_eq!(kind, ErrorKind::ClaudeFailure);
348    }
349
350    #[test]
351    fn test_runner_error_mapping() {
352        let runner_err = RunnerError::NativeExecutionFailed {
353            reason: "command not found".to_string(),
354        };
355        let err = XCheckerError::Runner(runner_err);
356        let (code, kind) = (&err).into();
357        assert_eq!(code, codes::CLAUDE_FAILURE);
358        assert_eq!(kind, ErrorKind::ClaudeFailure);
359    }
360
361    #[test]
362    fn test_io_error_mapping() {
363        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
364        let err = XCheckerError::Io(io_err);
365        let (code, kind) = (&err).into();
366        assert_eq!(code, 1);
367        assert_eq!(kind, ErrorKind::Unknown);
368    }
369
370    #[test]
371    fn test_invalid_transition_mapping() {
372        let phase_err = PhaseError::InvalidTransition {
373            from: "none".to_string(),
374            to: "design".to_string(),
375        };
376        let err = XCheckerError::Phase(phase_err);
377        let (code, kind) = (&err).into();
378        assert_eq!(code, codes::CLI_ARGS);
379        assert_eq!(kind, ErrorKind::CliArgs);
380    }
381
382    #[test]
383    fn test_dependency_not_satisfied_mapping() {
384        let phase_err = PhaseError::DependencyNotSatisfied {
385            phase: "design".to_string(),
386            dependency: "requirements".to_string(),
387        };
388        let err = XCheckerError::Phase(phase_err);
389        let (code, kind) = (&err).into();
390        assert_eq!(code, codes::CLI_ARGS);
391        assert_eq!(kind, ErrorKind::CliArgs);
392    }
393
394    #[test]
395    fn test_llm_provider_auth_mapping() {
396        use crate::error::LlmError;
397        let llm_err = LlmError::ProviderAuth("Invalid API key".to_string());
398        let err = XCheckerError::Llm(llm_err);
399        let (code, kind) = (&err).into();
400        assert_eq!(code, codes::CLAUDE_FAILURE);
401        assert_eq!(kind, ErrorKind::ClaudeFailure);
402    }
403
404    #[test]
405    fn test_llm_provider_quota_mapping() {
406        use crate::error::LlmError;
407        let llm_err = LlmError::ProviderQuota("Rate limit exceeded".to_string());
408        let err = XCheckerError::Llm(llm_err);
409        let (code, kind) = (&err).into();
410        assert_eq!(code, codes::CLAUDE_FAILURE);
411        assert_eq!(kind, ErrorKind::ClaudeFailure);
412    }
413
414    #[test]
415    fn test_llm_provider_outage_mapping() {
416        use crate::error::LlmError;
417        let llm_err = LlmError::ProviderOutage("Service unavailable".to_string());
418        let err = XCheckerError::Llm(llm_err);
419        let (code, kind) = (&err).into();
420        assert_eq!(code, codes::CLAUDE_FAILURE);
421        assert_eq!(kind, ErrorKind::ClaudeFailure);
422    }
423
424    #[test]
425    fn test_llm_timeout_mapping() {
426        use crate::error::LlmError;
427        use std::time::Duration;
428        let llm_err = LlmError::Timeout {
429            duration: Duration::from_secs(300),
430        };
431        let err = XCheckerError::Llm(llm_err);
432        let (code, kind) = (&err).into();
433        assert_eq!(code, codes::PHASE_TIMEOUT);
434        assert_eq!(kind, ErrorKind::PhaseTimeout);
435    }
436
437    #[test]
438    fn test_llm_misconfiguration_mapping() {
439        use crate::error::LlmError;
440        let llm_err = LlmError::Misconfiguration("Missing provider config".to_string());
441        let err = XCheckerError::Llm(llm_err);
442        let (code, kind) = (&err).into();
443        assert_eq!(code, codes::CLI_ARGS);
444        assert_eq!(kind, ErrorKind::CliArgs);
445    }
446
447    #[test]
448    fn test_llm_unsupported_mapping() {
449        use crate::error::LlmError;
450        let llm_err = LlmError::Unsupported("ExternalTool not yet supported".to_string());
451        let err = XCheckerError::Llm(llm_err);
452        let (code, kind) = (&err).into();
453        assert_eq!(code, codes::CLI_ARGS);
454        assert_eq!(kind, ErrorKind::CliArgs);
455    }
456
457    #[test]
458    fn test_llm_transport_mapping() {
459        use crate::error::LlmError;
460        let llm_err = LlmError::Transport("Connection failed".to_string());
461        let err = XCheckerError::Llm(llm_err);
462        let (code, kind) = (&err).into();
463        assert_eq!(code, codes::CLAUDE_FAILURE);
464        assert_eq!(kind, ErrorKind::ClaudeFailure);
465    }
466
467    #[test]
468    fn test_llm_budget_exceeded_mapping() {
469        use crate::error::LlmError;
470        let llm_err = LlmError::BudgetExceeded {
471            limit: 20,
472            attempted: 21,
473        };
474        let err = XCheckerError::Llm(llm_err);
475        let (code, kind) = (&err).into();
476        assert_eq!(code, codes::CLAUDE_FAILURE);
477        assert_eq!(kind, ErrorKind::ClaudeFailure);
478    }
479
480    #[test]
481    fn test_validation_failed_mapping() {
482        // ValidationFailed maps to exit code 1 (general error) with Unknown kind
483        // This is intentional: validation failures are user-recoverable errors
484        // that don't fit into infrastructure-level categories
485        use crate::error::ValidationError;
486        let err = XCheckerError::ValidationFailed {
487            phase: "requirements".to_string(),
488            issue_count: 2,
489            issues: vec![
490                ValidationError::MetaSummaryDetected {
491                    pattern: "Here is".to_string(),
492                },
493                ValidationError::TooShort {
494                    actual: 10,
495                    minimum: 30,
496                },
497            ],
498        };
499        let (code, kind) = (&err).into();
500        assert_eq!(
501            code, 1,
502            "ValidationFailed should use exit code 1 (general error)"
503        );
504        assert_eq!(kind, ErrorKind::Unknown);
505    }
506
507    // ========================================================================
508    // Tests for XCheckerError::to_exit_code() method
509    // These tests verify the method returns ExitCode struct with correct values
510    // ========================================================================
511
512    #[test]
513    fn test_to_exit_code_config_error() {
514        let err = XCheckerError::Config(ConfigError::InvalidFile("test".to_string()));
515        assert_eq!(err.to_exit_code(), ExitCode::CLI_ARGS);
516        assert_eq!(err.to_exit_code().as_i32(), codes::CLI_ARGS);
517    }
518
519    #[test]
520    fn test_to_exit_code_packet_overflow() {
521        let err = XCheckerError::PacketOverflow {
522            used_bytes: 100000,
523            used_lines: 2000,
524            limit_bytes: 65536,
525            limit_lines: 1200,
526        };
527        assert_eq!(err.to_exit_code(), ExitCode::PACKET_OVERFLOW);
528        assert_eq!(err.to_exit_code().as_i32(), codes::PACKET_OVERFLOW);
529    }
530
531    #[test]
532    fn test_to_exit_code_secret_detected() {
533        let err = XCheckerError::SecretDetected {
534            pattern: "ghp_".to_string(),
535            location: "test.txt".to_string(),
536        };
537        assert_eq!(err.to_exit_code(), ExitCode::SECRET_DETECTED);
538        assert_eq!(err.to_exit_code().as_i32(), codes::SECRET_DETECTED);
539    }
540
541    #[test]
542    fn test_to_exit_code_concurrent_execution() {
543        let err = XCheckerError::ConcurrentExecution {
544            id: "test-spec".to_string(),
545        };
546        assert_eq!(err.to_exit_code(), ExitCode::LOCK_HELD);
547        assert_eq!(err.to_exit_code().as_i32(), codes::LOCK_HELD);
548    }
549
550    #[test]
551    fn test_to_exit_code_lock_error() {
552        let lock_err = LockError::ConcurrentExecution {
553            spec_id: "test-spec".to_string(),
554            pid: 12345,
555            created_ago: "5m".to_string(),
556        };
557        let err = XCheckerError::Lock(lock_err);
558        assert_eq!(err.to_exit_code(), ExitCode::LOCK_HELD);
559        assert_eq!(err.to_exit_code().as_i32(), codes::LOCK_HELD);
560    }
561
562    #[test]
563    fn test_to_exit_code_phase_timeout() {
564        let phase_err = PhaseError::Timeout {
565            phase: "REQUIREMENTS".to_string(),
566            timeout_seconds: 600,
567        };
568        let err = XCheckerError::Phase(phase_err);
569        assert_eq!(err.to_exit_code(), ExitCode::PHASE_TIMEOUT);
570        assert_eq!(err.to_exit_code().as_i32(), codes::PHASE_TIMEOUT);
571    }
572
573    #[test]
574    fn test_to_exit_code_phase_invalid_transition() {
575        let phase_err = PhaseError::InvalidTransition {
576            from: "none".to_string(),
577            to: "design".to_string(),
578        };
579        let err = XCheckerError::Phase(phase_err);
580        assert_eq!(err.to_exit_code(), ExitCode::CLI_ARGS);
581        assert_eq!(err.to_exit_code().as_i32(), codes::CLI_ARGS);
582    }
583
584    #[test]
585    fn test_to_exit_code_phase_dependency_not_satisfied() {
586        let phase_err = PhaseError::DependencyNotSatisfied {
587            phase: "design".to_string(),
588            dependency: "requirements".to_string(),
589        };
590        let err = XCheckerError::Phase(phase_err);
591        assert_eq!(err.to_exit_code(), ExitCode::CLI_ARGS);
592        assert_eq!(err.to_exit_code().as_i32(), codes::CLI_ARGS);
593    }
594
595    #[test]
596    fn test_to_exit_code_phase_execution_failed() {
597        let phase_err = PhaseError::ExecutionFailed {
598            phase: "DESIGN".to_string(),
599            code: 1,
600        };
601        let err = XCheckerError::Phase(phase_err);
602        assert_eq!(err.to_exit_code(), ExitCode::INTERNAL);
603        assert_eq!(err.to_exit_code().as_i32(), 1);
604    }
605
606    #[test]
607    fn test_to_exit_code_claude_error() {
608        let claude_err = ClaudeError::ExecutionFailed {
609            stderr: "API error".to_string(),
610        };
611        let err = XCheckerError::Claude(claude_err);
612        assert_eq!(err.to_exit_code(), ExitCode::CLAUDE_FAILURE);
613        assert_eq!(err.to_exit_code().as_i32(), codes::CLAUDE_FAILURE);
614    }
615
616    #[test]
617    fn test_to_exit_code_runner_error() {
618        let runner_err = RunnerError::NativeExecutionFailed {
619            reason: "command not found".to_string(),
620        };
621        let err = XCheckerError::Runner(runner_err);
622        assert_eq!(err.to_exit_code(), ExitCode::CLAUDE_FAILURE);
623        assert_eq!(err.to_exit_code().as_i32(), codes::CLAUDE_FAILURE);
624    }
625
626    #[test]
627    fn test_to_exit_code_io_error() {
628        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
629        let err = XCheckerError::Io(io_err);
630        assert_eq!(err.to_exit_code(), ExitCode::INTERNAL);
631        assert_eq!(err.to_exit_code().as_i32(), 1);
632    }
633
634    #[test]
635    fn test_to_exit_code_llm_timeout() {
636        use crate::error::LlmError;
637        use std::time::Duration;
638        let llm_err = LlmError::Timeout {
639            duration: Duration::from_secs(300),
640        };
641        let err = XCheckerError::Llm(llm_err);
642        assert_eq!(err.to_exit_code(), ExitCode::PHASE_TIMEOUT);
643        assert_eq!(err.to_exit_code().as_i32(), codes::PHASE_TIMEOUT);
644    }
645
646    #[test]
647    fn test_to_exit_code_llm_misconfiguration() {
648        use crate::error::LlmError;
649        let llm_err = LlmError::Misconfiguration("Missing provider config".to_string());
650        let err = XCheckerError::Llm(llm_err);
651        assert_eq!(err.to_exit_code(), ExitCode::CLI_ARGS);
652        assert_eq!(err.to_exit_code().as_i32(), codes::CLI_ARGS);
653    }
654
655    #[test]
656    fn test_to_exit_code_llm_provider_auth() {
657        use crate::error::LlmError;
658        let llm_err = LlmError::ProviderAuth("Invalid API key".to_string());
659        let err = XCheckerError::Llm(llm_err);
660        assert_eq!(err.to_exit_code(), ExitCode::CLAUDE_FAILURE);
661        assert_eq!(err.to_exit_code().as_i32(), codes::CLAUDE_FAILURE);
662    }
663
664    #[test]
665    fn test_to_exit_code_consistency_with_error_to_exit_code_and_kind() {
666        // Verify that to_exit_code() returns the same exit code as error_to_exit_code_and_kind()
667        // This ensures consistency between the two APIs
668
669        let test_cases: Vec<XCheckerError> = vec![
670            XCheckerError::Config(ConfigError::InvalidFile("test".to_string())),
671            XCheckerError::PacketOverflow {
672                used_bytes: 100000,
673                used_lines: 2000,
674                limit_bytes: 65536,
675                limit_lines: 1200,
676            },
677            XCheckerError::SecretDetected {
678                pattern: "ghp_".to_string(),
679                location: "test.txt".to_string(),
680            },
681            XCheckerError::ConcurrentExecution {
682                id: "test-spec".to_string(),
683            },
684            XCheckerError::Claude(ClaudeError::ExecutionFailed {
685                stderr: "API error".to_string(),
686            }),
687            XCheckerError::Runner(RunnerError::NativeExecutionFailed {
688                reason: "command not found".to_string(),
689            }),
690            XCheckerError::Phase(PhaseError::Timeout {
691                phase: "REQUIREMENTS".to_string(),
692                timeout_seconds: 600,
693            }),
694            XCheckerError::Phase(PhaseError::InvalidTransition {
695                from: "none".to_string(),
696                to: "design".to_string(),
697            }),
698        ];
699
700        for err in test_cases {
701            let (legacy_code, _kind) = error_to_exit_code_and_kind(&err);
702            let new_code = err.to_exit_code().as_i32();
703            assert_eq!(
704                legacy_code, new_code,
705                "Exit code mismatch for error: {:?}",
706                err
707            );
708        }
709    }
710
711    // ========================================================================
712    // Comprehensive ErrorKind to ExitCode mapping test
713    // Validates: Requirements 2.3, 5.2, 10.5
714    // ========================================================================
715
716    /// Test that each ErrorKind maps to the correct ExitCode as documented.
717    ///
718    /// This test validates the documented exit code table:
719    /// | Code | Name           | ErrorKind      | Description                |
720    /// |------|----------------|----------------|----------------------------|
721    /// | 0    | SUCCESS        | (none)         | Completed successfully     |
722    /// | 1    | INTERNAL       | Unknown        | General failure            |
723    /// | 2    | CLI_ARGS       | CliArgs        | Invalid CLI arguments      |
724    /// | 7    | PACKET_OVERFLOW| PacketOverflow | Packet size exceeded       |
725    /// | 8    | SECRET_DETECTED| SecretDetected | Secret found in content    |
726    /// | 9    | LOCK_HELD      | LockHeld       | Lock already held          |
727    /// | 10   | PHASE_TIMEOUT  | PhaseTimeout   | Phase timed out            |
728    /// | 70   | CLAUDE_FAILURE | ClaudeFailure  | Claude CLI failed          |
729    ///
730    /// **Validates: Requirements 2.3, 5.2, 10.5**
731    #[test]
732    fn test_error_kind_to_exit_code_mapping() {
733        // Test each ErrorKind variant maps to the correct exit code
734        // This uses explicit test cases as required by the task
735
736        // ErrorKind::CliArgs -> ExitCode::CLI_ARGS (2)
737        let err = XCheckerError::Config(ConfigError::InvalidFile("test".to_string()));
738        let (code, kind) = error_to_exit_code_and_kind(&err);
739        assert_eq!(
740            kind,
741            ErrorKind::CliArgs,
742            "Config error should produce CliArgs kind"
743        );
744        assert_eq!(code, codes::CLI_ARGS, "CliArgs should map to exit code 2");
745        assert_eq!(code, 2, "CLI_ARGS constant should be 2");
746
747        // ErrorKind::PacketOverflow -> ExitCode::PACKET_OVERFLOW (7)
748        let err = XCheckerError::PacketOverflow {
749            used_bytes: 100000,
750            used_lines: 2000,
751            limit_bytes: 65536,
752            limit_lines: 1200,
753        };
754        let (code, kind) = error_to_exit_code_and_kind(&err);
755        assert_eq!(
756            kind,
757            ErrorKind::PacketOverflow,
758            "PacketOverflow error should produce PacketOverflow kind"
759        );
760        assert_eq!(
761            code,
762            codes::PACKET_OVERFLOW,
763            "PacketOverflow should map to exit code 7"
764        );
765        assert_eq!(code, 7, "PACKET_OVERFLOW constant should be 7");
766
767        // ErrorKind::SecretDetected -> ExitCode::SECRET_DETECTED (8)
768        let err = XCheckerError::SecretDetected {
769            pattern: "ghp_".to_string(),
770            location: "test.txt".to_string(),
771        };
772        let (code, kind) = error_to_exit_code_and_kind(&err);
773        assert_eq!(
774            kind,
775            ErrorKind::SecretDetected,
776            "SecretDetected error should produce SecretDetected kind"
777        );
778        assert_eq!(
779            code,
780            codes::SECRET_DETECTED,
781            "SecretDetected should map to exit code 8"
782        );
783        assert_eq!(code, 8, "SECRET_DETECTED constant should be 8");
784
785        // ErrorKind::LockHeld -> ExitCode::LOCK_HELD (9)
786        let err = XCheckerError::ConcurrentExecution {
787            id: "test-spec".to_string(),
788        };
789        let (code, kind) = error_to_exit_code_and_kind(&err);
790        assert_eq!(
791            kind,
792            ErrorKind::LockHeld,
793            "ConcurrentExecution error should produce LockHeld kind"
794        );
795        assert_eq!(code, codes::LOCK_HELD, "LockHeld should map to exit code 9");
796        assert_eq!(code, 9, "LOCK_HELD constant should be 9");
797
798        // ErrorKind::PhaseTimeout -> ExitCode::PHASE_TIMEOUT (10)
799        let err = XCheckerError::Phase(PhaseError::Timeout {
800            phase: "REQUIREMENTS".to_string(),
801            timeout_seconds: 600,
802        });
803        let (code, kind) = error_to_exit_code_and_kind(&err);
804        assert_eq!(
805            kind,
806            ErrorKind::PhaseTimeout,
807            "Phase timeout error should produce PhaseTimeout kind"
808        );
809        assert_eq!(
810            code,
811            codes::PHASE_TIMEOUT,
812            "PhaseTimeout should map to exit code 10"
813        );
814        assert_eq!(code, 10, "PHASE_TIMEOUT constant should be 10");
815
816        // ErrorKind::ClaudeFailure -> ExitCode::CLAUDE_FAILURE (70)
817        let err = XCheckerError::Claude(ClaudeError::ExecutionFailed {
818            stderr: "API error".to_string(),
819        });
820        let (code, kind) = error_to_exit_code_and_kind(&err);
821        assert_eq!(
822            kind,
823            ErrorKind::ClaudeFailure,
824            "Claude error should produce ClaudeFailure kind"
825        );
826        assert_eq!(
827            code,
828            codes::CLAUDE_FAILURE,
829            "ClaudeFailure should map to exit code 70"
830        );
831        assert_eq!(code, 70, "CLAUDE_FAILURE constant should be 70");
832
833        // ErrorKind::Unknown -> ExitCode::INTERNAL (1)
834        let err = XCheckerError::Io(std::io::Error::new(
835            std::io::ErrorKind::NotFound,
836            "file not found",
837        ));
838        let (code, kind) = error_to_exit_code_and_kind(&err);
839        assert_eq!(
840            kind,
841            ErrorKind::Unknown,
842            "IO error should produce Unknown kind"
843        );
844        assert_eq!(code, 1, "Unknown should map to exit code 1 (INTERNAL)");
845    }
846
847    /// Test that ExitCode constants match the documented values.
848    ///
849    /// This validates the ExitCode struct constants against the documented table.
850    /// **Validates: Requirements 2.3, 5.2, 10.5**
851    #[test]
852    fn test_exit_code_struct_constants_match_documented_values() {
853        // Verify ExitCode struct constants match the documented exit code table
854        assert_eq!(ExitCode::SUCCESS.as_i32(), 0, "SUCCESS should be 0");
855        assert_eq!(ExitCode::INTERNAL.as_i32(), 1, "INTERNAL should be 1");
856        assert_eq!(ExitCode::CLI_ARGS.as_i32(), 2, "CLI_ARGS should be 2");
857        assert_eq!(
858            ExitCode::PACKET_OVERFLOW.as_i32(),
859            7,
860            "PACKET_OVERFLOW should be 7"
861        );
862        assert_eq!(
863            ExitCode::SECRET_DETECTED.as_i32(),
864            8,
865            "SECRET_DETECTED should be 8"
866        );
867        assert_eq!(ExitCode::LOCK_HELD.as_i32(), 9, "LOCK_HELD should be 9");
868        assert_eq!(
869            ExitCode::PHASE_TIMEOUT.as_i32(),
870            10,
871            "PHASE_TIMEOUT should be 10"
872        );
873        assert_eq!(
874            ExitCode::CLAUDE_FAILURE.as_i32(),
875            70,
876            "CLAUDE_FAILURE should be 70"
877        );
878
879        // Verify codes module constants match ExitCode struct constants
880        assert_eq!(
881            codes::SUCCESS,
882            ExitCode::SUCCESS.as_i32(),
883            "codes::SUCCESS should match ExitCode::SUCCESS"
884        );
885        assert_eq!(
886            codes::CLI_ARGS,
887            ExitCode::CLI_ARGS.as_i32(),
888            "codes::CLI_ARGS should match ExitCode::CLI_ARGS"
889        );
890        assert_eq!(
891            codes::PACKET_OVERFLOW,
892            ExitCode::PACKET_OVERFLOW.as_i32(),
893            "codes::PACKET_OVERFLOW should match ExitCode::PACKET_OVERFLOW"
894        );
895        assert_eq!(
896            codes::SECRET_DETECTED,
897            ExitCode::SECRET_DETECTED.as_i32(),
898            "codes::SECRET_DETECTED should match ExitCode::SECRET_DETECTED"
899        );
900        assert_eq!(
901            codes::LOCK_HELD,
902            ExitCode::LOCK_HELD.as_i32(),
903            "codes::LOCK_HELD should match ExitCode::LOCK_HELD"
904        );
905        assert_eq!(
906            codes::PHASE_TIMEOUT,
907            ExitCode::PHASE_TIMEOUT.as_i32(),
908            "codes::PHASE_TIMEOUT should match ExitCode::PHASE_TIMEOUT"
909        );
910        assert_eq!(
911            codes::CLAUDE_FAILURE,
912            ExitCode::CLAUDE_FAILURE.as_i32(),
913            "codes::CLAUDE_FAILURE should match ExitCode::CLAUDE_FAILURE"
914        );
915    }
916
917    /// Test that to_exit_code() method returns correct ExitCode for each error type.
918    ///
919    /// This validates the XCheckerError::to_exit_code() method against the documented table.
920    /// **Validates: Requirements 2.3, 5.2, 10.5**
921    #[test]
922    fn test_to_exit_code_matches_documented_table() {
923        // Test each documented error type maps to the correct ExitCode via to_exit_code()
924
925        // Config errors -> CLI_ARGS (2)
926        let err = XCheckerError::Config(ConfigError::InvalidFile("test".to_string()));
927        assert_eq!(err.to_exit_code(), ExitCode::CLI_ARGS);
928
929        // PacketOverflow -> PACKET_OVERFLOW (7)
930        let err = XCheckerError::PacketOverflow {
931            used_bytes: 100000,
932            used_lines: 2000,
933            limit_bytes: 65536,
934            limit_lines: 1200,
935        };
936        assert_eq!(err.to_exit_code(), ExitCode::PACKET_OVERFLOW);
937
938        // SecretDetected -> SECRET_DETECTED (8)
939        let err = XCheckerError::SecretDetected {
940            pattern: "ghp_".to_string(),
941            location: "test.txt".to_string(),
942        };
943        assert_eq!(err.to_exit_code(), ExitCode::SECRET_DETECTED);
944
945        // ConcurrentExecution -> LOCK_HELD (9)
946        let err = XCheckerError::ConcurrentExecution {
947            id: "test-spec".to_string(),
948        };
949        assert_eq!(err.to_exit_code(), ExitCode::LOCK_HELD);
950
951        // Lock errors -> LOCK_HELD (9)
952        let lock_err = LockError::ConcurrentExecution {
953            spec_id: "test-spec".to_string(),
954            pid: 12345,
955            created_ago: "5m".to_string(),
956        };
957        let err = XCheckerError::Lock(lock_err);
958        assert_eq!(err.to_exit_code(), ExitCode::LOCK_HELD);
959
960        // Phase timeout -> PHASE_TIMEOUT (10)
961        let phase_err = PhaseError::Timeout {
962            phase: "REQUIREMENTS".to_string(),
963            timeout_seconds: 600,
964        };
965        let err = XCheckerError::Phase(phase_err);
966        assert_eq!(err.to_exit_code(), ExitCode::PHASE_TIMEOUT);
967
968        // Claude errors -> CLAUDE_FAILURE (70)
969        let claude_err = ClaudeError::ExecutionFailed {
970            stderr: "API error".to_string(),
971        };
972        let err = XCheckerError::Claude(claude_err);
973        assert_eq!(err.to_exit_code(), ExitCode::CLAUDE_FAILURE);
974
975        // Runner errors -> CLAUDE_FAILURE (70)
976        let runner_err = RunnerError::NativeExecutionFailed {
977            reason: "command not found".to_string(),
978        };
979        let err = XCheckerError::Runner(runner_err);
980        assert_eq!(err.to_exit_code(), ExitCode::CLAUDE_FAILURE);
981
982        // IO errors -> INTERNAL (1)
983        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
984        let err = XCheckerError::Io(io_err);
985        assert_eq!(err.to_exit_code(), ExitCode::INTERNAL);
986    }
987}