Skip to main content

fabryk_core/
error.rs

1//! Error types for the Fabryk ecosystem.
2//!
3//! Provides a common `Error` type and `Result<T>` alias used across all Fabryk
4//! crates. Uses `thiserror` for derive macros with backtrace support.
5//!
6//! # Error Categories
7//!
8//! - **I/O errors**: File operations, network, etc.
9//! - **Configuration errors**: Invalid config, missing fields
10//! - **Not found errors**: Missing resources (files, concepts, etc.)
11//! - **Path errors**: Invalid paths, missing directories
12//! - **Parse errors**: Malformed content, invalid format
13//! - **Operation errors**: Generic operation failures
14//!
15//! # MCP Integration
16//!
17//! MCP-specific error mapping (converting to `ErrorData`) is provided by
18//! `fabryk-mcp` via the `McpErrorExt` trait, keeping this crate free of
19//! MCP dependencies.
20
21use std::path::PathBuf;
22
23use thiserror::Error;
24
25/// Common error type for Fabryk operations.
26///
27/// All Fabryk crates use this error type or wrap it in their own domain-specific
28/// error types. The variants cover common infrastructure errors; domain-specific
29/// errors should use `Operation` with a descriptive message or wrap this type.
30#[derive(Error, Debug)]
31pub enum Error {
32    /// I/O error (file operations, network, etc.)
33    #[error("I/O error: {0}")]
34    Io(#[from] std::io::Error),
35
36    /// I/O error with path context.
37    #[error("I/O error at {path}: {message}")]
38    IoWithPath { path: PathBuf, message: String },
39
40    /// Configuration error.
41    #[error("Configuration error: {0}")]
42    Config(String),
43
44    /// JSON serialization/deserialization error.
45    #[error("JSON error: {0}")]
46    Json(#[from] serde_json::Error),
47
48    /// YAML serialization/deserialization error.
49    #[error("YAML error: {0}")]
50    Yaml(#[from] serde_yaml::Error),
51
52    /// Resource not found (file, concept, source, etc.)
53    #[error("{resource_type} not found: {id}")]
54    NotFound { resource_type: String, id: String },
55
56    /// File not found at specific path.
57    #[error("File not found: {}", path.display())]
58    FileNotFound { path: PathBuf },
59
60    /// Invalid path.
61    #[error("Invalid path {}: {reason}", path.display())]
62    InvalidPath { path: PathBuf, reason: String },
63
64    /// Parse error (malformed content, invalid format).
65    #[error("Parse error: {0}")]
66    Parse(String),
67
68    /// Generic operation error (escape hatch for domain-specific errors).
69    #[error("{0}")]
70    Operation(String),
71}
72
73impl Error {
74    // ========================================================================
75    // Constructor helpers
76    // ========================================================================
77
78    /// Create an I/O error.
79    ///
80    /// This is useful when you have an `std::io::Error` and want to convert
81    /// it explicitly (as opposed to using `?` with `From` conversion).
82    pub fn io(err: std::io::Error) -> Self {
83        Self::Io(err)
84    }
85
86    /// Create an I/O error with path context.
87    pub fn io_with_path(err: std::io::Error, path: impl Into<PathBuf>) -> Self {
88        Self::IoWithPath {
89            path: path.into(),
90            message: err.to_string(),
91        }
92    }
93
94    /// Create a configuration error.
95    pub fn config(msg: impl Into<String>) -> Self {
96        Self::Config(msg.into())
97    }
98
99    /// Create a not-found error with resource type and ID.
100    pub fn not_found(resource_type: impl Into<String>, id: impl Into<String>) -> Self {
101        Self::NotFound {
102            resource_type: resource_type.into(),
103            id: id.into(),
104        }
105    }
106
107    /// Create a file-not-found error.
108    pub fn file_not_found(path: impl Into<PathBuf>) -> Self {
109        Self::FileNotFound { path: path.into() }
110    }
111
112    /// Create a not-found error with just a message.
113    ///
114    /// This is a convenience method for when you don't have a specific
115    /// resource type, or the message already contains the context.
116    pub fn not_found_msg(msg: impl Into<String>) -> Self {
117        Self::NotFound {
118            resource_type: "Resource".to_string(),
119            id: msg.into(),
120        }
121    }
122
123    /// Create an invalid path error.
124    pub fn invalid_path(path: impl Into<PathBuf>, reason: impl Into<String>) -> Self {
125        Self::InvalidPath {
126            path: path.into(),
127            reason: reason.into(),
128        }
129    }
130
131    /// Create a parse error.
132    pub fn parse(msg: impl Into<String>) -> Self {
133        Self::Parse(msg.into())
134    }
135
136    /// Create an operation error (generic domain-specific error).
137    pub fn operation(msg: impl Into<String>) -> Self {
138        Self::Operation(msg.into())
139    }
140
141    // ========================================================================
142    // Inspector methods
143    // ========================================================================
144
145    /// Check if this is an I/O error.
146    pub fn is_io(&self) -> bool {
147        matches!(self, Self::Io(_) | Self::IoWithPath { .. })
148    }
149
150    /// Check if this is a not-found error (any variant).
151    pub fn is_not_found(&self) -> bool {
152        matches!(self, Self::NotFound { .. } | Self::FileNotFound { .. })
153    }
154
155    /// Check if this is a configuration error.
156    pub fn is_config(&self) -> bool {
157        matches!(self, Self::Config(_))
158    }
159
160    /// Check if this is a path-related error.
161    pub fn is_path_error(&self) -> bool {
162        matches!(
163            self,
164            Self::InvalidPath { .. } | Self::FileNotFound { .. } | Self::IoWithPath { .. }
165        )
166    }
167
168    /// Check if this is a parse error.
169    pub fn is_parse(&self) -> bool {
170        matches!(self, Self::Parse(_))
171    }
172}
173
174/// Result type alias for Fabryk operations.
175pub type Result<T> = std::result::Result<T, Error>;
176
177// ============================================================================
178// Tests
179// ============================================================================
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use std::error::Error as StdError;
185
186    // ------------------------------------------------------------------------
187    // Constructor tests
188    // ------------------------------------------------------------------------
189
190    #[test]
191    fn test_error_io_from() {
192        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
193        let err: Error = io_err.into();
194        assert!(err.is_io());
195        assert!(!err.is_not_found());
196        assert!(!err.is_config());
197        assert!(err.to_string().contains("I/O error"));
198    }
199
200    #[test]
201    fn test_error_io_constructor() {
202        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
203        let err = Error::io(io_err);
204        assert!(err.is_io());
205        assert!(err.to_string().contains("I/O error"));
206        assert!(err.to_string().contains("file not found"));
207    }
208
209    #[test]
210    fn test_error_io_with_path() {
211        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
212        let path = PathBuf::from("/test/path.txt");
213        let err = Error::io_with_path(io_err, &path);
214        assert!(err.is_io());
215        assert!(err.is_path_error());
216        let msg = err.to_string();
217        assert!(msg.contains("I/O error at"));
218        assert!(msg.contains("/test/path.txt"));
219        assert!(msg.contains("permission denied"));
220    }
221
222    #[test]
223    fn test_error_config() {
224        let err = Error::config("invalid configuration");
225        assert!(err.is_config());
226        assert!(!err.is_io());
227        assert!(!err.is_not_found());
228        assert!(err.to_string().contains("Configuration error"));
229        assert!(err.to_string().contains("invalid configuration"));
230    }
231
232    #[test]
233    fn test_error_not_found() {
234        let err = Error::not_found("Concept", "major-triad");
235        assert!(err.is_not_found());
236        assert!(!err.is_io());
237        assert!(!err.is_config());
238        let msg = err.to_string();
239        assert!(msg.contains("Concept not found"));
240        assert!(msg.contains("major-triad"));
241    }
242
243    #[test]
244    fn test_error_file_not_found() {
245        let path = PathBuf::from("/missing/file.txt");
246        let err = Error::file_not_found(&path);
247        assert!(err.is_not_found());
248        assert!(err.is_path_error());
249        assert!(!err.is_io());
250        let msg = err.to_string();
251        assert!(msg.contains("File not found"));
252        assert!(msg.contains("/missing/file.txt"));
253    }
254
255    #[test]
256    fn test_error_not_found_msg() {
257        let err = Error::not_found_msg("file with id 'xyz' not in cache");
258        assert!(err.is_not_found());
259        assert!(!err.is_io());
260        let msg = err.to_string();
261        assert!(msg.contains("Resource not found"));
262        assert!(msg.contains("file with id 'xyz' not in cache"));
263    }
264
265    #[test]
266    fn test_error_invalid_path() {
267        let path = PathBuf::from("/bad/path");
268        let err = Error::invalid_path(&path, "invalid characters");
269        assert!(err.is_path_error());
270        assert!(!err.is_io());
271        assert!(!err.is_not_found());
272        let msg = err.to_string();
273        assert!(msg.contains("Invalid path"));
274        assert!(msg.contains("/bad/path"));
275        assert!(msg.contains("invalid characters"));
276    }
277
278    #[test]
279    fn test_error_parse() {
280        let err = Error::parse("syntax error at line 5");
281        assert!(err.is_parse());
282        assert!(!err.is_io());
283        assert!(!err.is_config());
284        assert!(err.to_string().contains("Parse error"));
285        assert!(err.to_string().contains("syntax error at line 5"));
286    }
287
288    #[test]
289    fn test_error_operation() {
290        let err = Error::operation("index corrupted");
291        assert!(!err.is_io());
292        assert!(!err.is_not_found());
293        assert!(!err.is_config());
294        assert!(err.to_string().contains("index corrupted"));
295    }
296
297    // ------------------------------------------------------------------------
298    // From implementations
299    // ------------------------------------------------------------------------
300
301    #[test]
302    fn test_error_from_io_error() {
303        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "connection lost");
304        let err: Error = io_err.into();
305        assert!(err.is_io());
306        assert!(err.to_string().contains("connection lost"));
307    }
308
309    #[test]
310    fn test_error_from_json_error() {
311        let json_str = "{ invalid json }";
312        let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
313        let err: Error = json_err.into();
314        assert!(matches!(err, Error::Json(_)));
315        assert!(err.to_string().contains("JSON error"));
316    }
317
318    // ------------------------------------------------------------------------
319    // Error trait implementation
320    // ------------------------------------------------------------------------
321
322    #[test]
323    fn test_error_source_io() {
324        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "test");
325        let err: Error = io_err.into();
326        assert!(err.source().is_some());
327    }
328
329    #[test]
330    fn test_error_source_non_io() {
331        let err = Error::config("test");
332        assert!(err.source().is_none());
333    }
334
335    // ------------------------------------------------------------------------
336    // Display tests
337    // ------------------------------------------------------------------------
338
339    #[test]
340    fn test_error_display_all_variants() {
341        let errors = vec![
342            Error::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "io")),
343            Error::io_with_path(
344                std::io::Error::new(std::io::ErrorKind::NotFound, "io"),
345                "/path",
346            ),
347            Error::config("config"),
348            Error::not_found("Type", "id"),
349            Error::file_not_found("/path"),
350            Error::invalid_path("/path", "reason"),
351            Error::parse("parse"),
352            Error::operation("operation"),
353        ];
354
355        for err in errors {
356            let display = err.to_string();
357            assert!(
358                !display.is_empty(),
359                "Display should produce non-empty string for {:?}",
360                err
361            );
362        }
363    }
364
365    // ------------------------------------------------------------------------
366    // Result alias
367    // ------------------------------------------------------------------------
368
369    #[test]
370    fn test_result_alias() {
371        let ok: Result<i32> = Ok(42);
372        assert_eq!(ok.ok(), Some(42));
373
374        let err: Result<i32> = Err(Error::config("bad"));
375        assert!(err.is_err());
376    }
377}