Skip to main content

a3s_code_core/
error.rs

1//! Typed error enum for A3S Code Core
2//!
3//! Provides categorized errors that SDK consumers can match on programmatically,
4//! instead of receiving opaque `anyhow::Error` strings.
5//!
6//! ## Migration Strategy
7//!
8//! The `Internal` variant wraps `anyhow::Error` via `#[from]`, allowing
9//! gradual migration: call sites that haven't been updated yet auto-convert
10//! through `?`. Over time, each call site replaces `anyhow::anyhow!(...)`
11//! with a specific variant like `CodeError::Config(...)`.
12
13use thiserror::Error;
14
15/// Crate-wide result type alias.
16pub type Result<T> = std::result::Result<T, CodeError>;
17
18/// Categorized error type for A3S Code Core.
19///
20/// SDK bindings (Python/Node) can match on the variant to expose typed
21/// exceptions (e.g., `CodeConfigError`, `CodeLlmError`).
22#[derive(Debug, Error)]
23pub enum CodeError {
24    /// Configuration loading or parsing error
25    #[error("Config error: {0}")]
26    Config(String),
27
28    /// LLM provider communication error
29    #[error("LLM error: {0}")]
30    Llm(String),
31
32    /// Tool execution error
33    #[error("Tool error: {tool}: {message}")]
34    Tool { tool: String, message: String },
35
36    /// Session management error
37    #[error("Session error: {0}")]
38    Session(String),
39
40    /// Security subsystem error
41    #[error("Security error: {0}")]
42    Security(String),
43
44    /// Context provider or context store error
45    #[error("Context error: {0}")]
46    Context(String),
47
48    /// MCP (Model Context Protocol) error
49    #[error("MCP error: {0}")]
50    Mcp(String),
51
52    /// Queue or lane error
53    #[error("Queue error: {0}")]
54    Queue(String),
55
56    /// I/O error
57    #[error("IO error: {0}")]
58    Io(#[from] std::io::Error),
59
60    /// JSON serialization/deserialization error
61    #[error("Serialization error: {0}")]
62    Serialization(#[from] serde_json::Error),
63
64    /// Catch-all for errors not yet migrated to a specific variant.
65    ///
66    /// The `#[from] anyhow::Error` conversion enables gradual migration:
67    /// any function returning `anyhow::Result` can be called with `?` from
68    /// a function returning `crate::error::Result` without changes.
69    #[error("{0:#}")]
70    Internal(#[from] anyhow::Error),
71}
72
73// ============================================================================
74// Lock Poisoning Helpers (Phase 3b)
75// ============================================================================
76
77/// Acquire a read guard, recovering from poison if the lock was poisoned.
78///
79/// Non-security code should never panic on a poisoned lock. The data may
80/// be in an inconsistent state, but crashing the entire process is worse
81/// than serving stale data in a coding agent context.
82pub(crate) fn read_or_recover<T>(lock: &std::sync::RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
83    lock.read().unwrap_or_else(|p| p.into_inner())
84}
85
86/// Acquire a write guard, recovering from poison if the lock was poisoned.
87///
88/// See [`read_or_recover`] for rationale.
89pub(crate) fn write_or_recover<T>(
90    lock: &std::sync::RwLock<T>,
91) -> std::sync::RwLockWriteGuard<'_, T> {
92    lock.write().unwrap_or_else(|p| p.into_inner())
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn test_code_error_config() {
101        let err = CodeError::Config("missing API key".to_string());
102        assert!(err.to_string().contains("Config error"));
103        assert!(err.to_string().contains("missing API key"));
104    }
105
106    #[test]
107    fn test_code_error_llm() {
108        let err = CodeError::Llm("rate limited".to_string());
109        assert!(err.to_string().contains("LLM error"));
110    }
111
112    #[test]
113    fn test_code_error_tool() {
114        let err = CodeError::Tool {
115            tool: "bash".to_string(),
116            message: "command not found".to_string(),
117        };
118        let msg = err.to_string();
119        assert!(msg.contains("bash"));
120        assert!(msg.contains("command not found"));
121    }
122
123    #[test]
124    fn test_code_error_session() {
125        let err = CodeError::Session("not found".to_string());
126        assert!(err.to_string().contains("Session error"));
127    }
128
129    #[test]
130    fn test_code_error_security() {
131        let err = CodeError::Security("taint detected".to_string());
132        assert!(err.to_string().contains("Security error"));
133    }
134
135    #[test]
136    fn test_code_error_context() {
137        let err = CodeError::Context("provider failed".to_string());
138        assert!(err.to_string().contains("Context error"));
139    }
140
141    #[test]
142    fn test_code_error_mcp() {
143        let err = CodeError::Mcp("connection refused".to_string());
144        assert!(err.to_string().contains("MCP error"));
145    }
146
147    #[test]
148    fn test_code_error_queue() {
149        let err = CodeError::Queue("lane full".to_string());
150        assert!(err.to_string().contains("Queue error"));
151    }
152
153    #[test]
154    fn test_code_error_from_io() {
155        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
156        let err: CodeError = io_err.into();
157        assert!(matches!(err, CodeError::Io(_)));
158        assert!(err.to_string().contains("file missing"));
159    }
160
161    #[test]
162    fn test_code_error_from_serde_json() {
163        let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
164        let err: CodeError = json_err.into();
165        assert!(matches!(err, CodeError::Serialization(_)));
166    }
167
168    #[test]
169    fn test_code_error_from_anyhow() {
170        let anyhow_err = anyhow::anyhow!("something went wrong");
171        let err: CodeError = anyhow_err.into();
172        assert!(matches!(err, CodeError::Internal(_)));
173        assert!(err.to_string().contains("something went wrong"));
174    }
175
176    #[test]
177    fn test_code_error_question_mark_from_anyhow() {
178        fn inner() -> anyhow::Result<()> {
179            anyhow::bail!("inner error")
180        }
181
182        fn outer() -> Result<()> {
183            inner()?; // anyhow::Error -> CodeError::Internal via #[from]
184            Ok(())
185        }
186
187        let result = outer();
188        assert!(result.is_err());
189        let err = result.unwrap_err();
190        assert!(matches!(err, CodeError::Internal(_)));
191    }
192
193    #[test]
194    fn test_read_or_recover_normal() {
195        let lock = std::sync::RwLock::new(42);
196        let guard = read_or_recover(&lock);
197        assert_eq!(*guard, 42);
198    }
199
200    #[test]
201    fn test_write_or_recover_normal() {
202        let lock = std::sync::RwLock::new(42);
203        let mut guard = write_or_recover(&lock);
204        *guard = 99;
205        drop(guard);
206        assert_eq!(*read_or_recover(&lock), 99);
207    }
208
209    #[test]
210    fn test_read_or_recover_poisoned() {
211        let lock = std::sync::RwLock::new(42);
212        // Poison the lock by panicking while holding a write guard
213        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
214            let _guard = lock.write().unwrap();
215            panic!("intentional poison");
216        }));
217        // Should recover without panicking
218        let guard = read_or_recover(&lock);
219        assert_eq!(*guard, 42);
220    }
221
222    #[test]
223    fn test_write_or_recover_poisoned() {
224        let lock = std::sync::RwLock::new(42);
225        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
226            let _guard = lock.write().unwrap();
227            panic!("intentional poison");
228        }));
229        let mut guard = write_or_recover(&lock);
230        *guard = 100;
231        assert_eq!(*guard, 100);
232    }
233}