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>(
83    lock: &std::sync::RwLock<T>,
84) -> std::sync::RwLockReadGuard<'_, T> {
85    lock.read().unwrap_or_else(|p| p.into_inner())
86}
87
88/// Acquire a write guard, recovering from poison if the lock was poisoned.
89///
90/// See [`read_or_recover`] for rationale.
91pub(crate) fn write_or_recover<T>(
92    lock: &std::sync::RwLock<T>,
93) -> std::sync::RwLockWriteGuard<'_, T> {
94    lock.write().unwrap_or_else(|p| p.into_inner())
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn test_code_error_config() {
103        let err = CodeError::Config("missing API key".to_string());
104        assert!(err.to_string().contains("Config error"));
105        assert!(err.to_string().contains("missing API key"));
106    }
107
108    #[test]
109    fn test_code_error_llm() {
110        let err = CodeError::Llm("rate limited".to_string());
111        assert!(err.to_string().contains("LLM error"));
112    }
113
114    #[test]
115    fn test_code_error_tool() {
116        let err = CodeError::Tool {
117            tool: "bash".to_string(),
118            message: "command not found".to_string(),
119        };
120        let msg = err.to_string();
121        assert!(msg.contains("bash"));
122        assert!(msg.contains("command not found"));
123    }
124
125    #[test]
126    fn test_code_error_session() {
127        let err = CodeError::Session("not found".to_string());
128        assert!(err.to_string().contains("Session error"));
129    }
130
131    #[test]
132    fn test_code_error_security() {
133        let err = CodeError::Security("taint detected".to_string());
134        assert!(err.to_string().contains("Security error"));
135    }
136
137    #[test]
138    fn test_code_error_context() {
139        let err = CodeError::Context("provider failed".to_string());
140        assert!(err.to_string().contains("Context error"));
141    }
142
143    #[test]
144    fn test_code_error_mcp() {
145        let err = CodeError::Mcp("connection refused".to_string());
146        assert!(err.to_string().contains("MCP error"));
147    }
148
149    #[test]
150    fn test_code_error_queue() {
151        let err = CodeError::Queue("lane full".to_string());
152        assert!(err.to_string().contains("Queue error"));
153    }
154
155    #[test]
156    fn test_code_error_from_io() {
157        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
158        let err: CodeError = io_err.into();
159        assert!(matches!(err, CodeError::Io(_)));
160        assert!(err.to_string().contains("file missing"));
161    }
162
163    #[test]
164    fn test_code_error_from_serde_json() {
165        let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
166        let err: CodeError = json_err.into();
167        assert!(matches!(err, CodeError::Serialization(_)));
168    }
169
170    #[test]
171    fn test_code_error_from_anyhow() {
172        let anyhow_err = anyhow::anyhow!("something went wrong");
173        let err: CodeError = anyhow_err.into();
174        assert!(matches!(err, CodeError::Internal(_)));
175        assert!(err.to_string().contains("something went wrong"));
176    }
177
178    #[test]
179    fn test_code_error_question_mark_from_anyhow() {
180        fn inner() -> anyhow::Result<()> {
181            anyhow::bail!("inner error")
182        }
183
184        fn outer() -> Result<()> {
185            inner()?; // anyhow::Error -> CodeError::Internal via #[from]
186            Ok(())
187        }
188
189        let result = outer();
190        assert!(result.is_err());
191        let err = result.unwrap_err();
192        assert!(matches!(err, CodeError::Internal(_)));
193    }
194
195    #[test]
196    fn test_read_or_recover_normal() {
197        let lock = std::sync::RwLock::new(42);
198        let guard = read_or_recover(&lock);
199        assert_eq!(*guard, 42);
200    }
201
202    #[test]
203    fn test_write_or_recover_normal() {
204        let lock = std::sync::RwLock::new(42);
205        let mut guard = write_or_recover(&lock);
206        *guard = 99;
207        drop(guard);
208        assert_eq!(*read_or_recover(&lock), 99);
209    }
210
211    #[test]
212    fn test_read_or_recover_poisoned() {
213        let lock = std::sync::RwLock::new(42);
214        // Poison the lock by panicking while holding a write guard
215        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
216            let _guard = lock.write().unwrap();
217            panic!("intentional poison");
218        }));
219        // Should recover without panicking
220        let guard = read_or_recover(&lock);
221        assert_eq!(*guard, 42);
222    }
223
224    #[test]
225    fn test_write_or_recover_poisoned() {
226        let lock = std::sync::RwLock::new(42);
227        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
228            let _guard = lock.write().unwrap();
229            panic!("intentional poison");
230        }));
231        let mut guard = write_or_recover(&lock);
232        *guard = 100;
233        assert_eq!(*guard, 100);
234    }
235}