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/// Walk the full error chain and log each cause at ERROR level.
178///
179/// Useful for diagnosing authentication failures, database errors, and other
180/// deeply-nested error chains where the root cause is several layers deep.
181///
182/// # Example
183///
184/// ```rust,no_run
185/// use fabryk_core::log_error_chain;
186///
187/// fn handle_error(err: &dyn std::error::Error) {
188///     log::error!("Operation failed: {err}");
189///     log_error_chain(err);
190/// }
191/// ```
192pub fn log_error_chain(err: &dyn std::error::Error) {
193    let mut depth = 0;
194    let mut source = err.source();
195    while let Some(cause) = source {
196        depth += 1;
197        log::error!("  cause[{depth}]: {cause}");
198        log::debug!("  cause[{depth}] debug: {cause:?}");
199        source = cause.source();
200    }
201    if depth == 0 {
202        log::debug!("  (no further error sources in chain)");
203    }
204}
205
206// ============================================================================
207// Tests
208// ============================================================================
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::error::Error as StdError;
214
215    // ------------------------------------------------------------------------
216    // Constructor tests
217    // ------------------------------------------------------------------------
218
219    #[test]
220    fn test_error_io_from() {
221        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
222        let err: Error = io_err.into();
223        assert!(err.is_io());
224        assert!(!err.is_not_found());
225        assert!(!err.is_config());
226        assert!(err.to_string().contains("I/O error"));
227    }
228
229    #[test]
230    fn test_error_io_constructor() {
231        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
232        let err = Error::io(io_err);
233        assert!(err.is_io());
234        assert!(err.to_string().contains("I/O error"));
235        assert!(err.to_string().contains("file not found"));
236    }
237
238    #[test]
239    fn test_error_io_with_path() {
240        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
241        let path = PathBuf::from("/test/path.txt");
242        let err = Error::io_with_path(io_err, &path);
243        assert!(err.is_io());
244        assert!(err.is_path_error());
245        let msg = err.to_string();
246        assert!(msg.contains("I/O error at"));
247        assert!(msg.contains("/test/path.txt"));
248        assert!(msg.contains("permission denied"));
249    }
250
251    #[test]
252    fn test_error_config() {
253        let err = Error::config("invalid configuration");
254        assert!(err.is_config());
255        assert!(!err.is_io());
256        assert!(!err.is_not_found());
257        assert!(err.to_string().contains("Configuration error"));
258        assert!(err.to_string().contains("invalid configuration"));
259    }
260
261    #[test]
262    fn test_error_not_found() {
263        let err = Error::not_found("Concept", "major-triad");
264        assert!(err.is_not_found());
265        assert!(!err.is_io());
266        assert!(!err.is_config());
267        let msg = err.to_string();
268        assert!(msg.contains("Concept not found"));
269        assert!(msg.contains("major-triad"));
270    }
271
272    #[test]
273    fn test_error_file_not_found() {
274        let path = PathBuf::from("/missing/file.txt");
275        let err = Error::file_not_found(&path);
276        assert!(err.is_not_found());
277        assert!(err.is_path_error());
278        assert!(!err.is_io());
279        let msg = err.to_string();
280        assert!(msg.contains("File not found"));
281        assert!(msg.contains("/missing/file.txt"));
282    }
283
284    #[test]
285    fn test_error_not_found_msg() {
286        let err = Error::not_found_msg("file with id 'xyz' not in cache");
287        assert!(err.is_not_found());
288        assert!(!err.is_io());
289        let msg = err.to_string();
290        assert!(msg.contains("Resource not found"));
291        assert!(msg.contains("file with id 'xyz' not in cache"));
292    }
293
294    #[test]
295    fn test_error_invalid_path() {
296        let path = PathBuf::from("/bad/path");
297        let err = Error::invalid_path(&path, "invalid characters");
298        assert!(err.is_path_error());
299        assert!(!err.is_io());
300        assert!(!err.is_not_found());
301        let msg = err.to_string();
302        assert!(msg.contains("Invalid path"));
303        assert!(msg.contains("/bad/path"));
304        assert!(msg.contains("invalid characters"));
305    }
306
307    #[test]
308    fn test_error_parse() {
309        let err = Error::parse("syntax error at line 5");
310        assert!(err.is_parse());
311        assert!(!err.is_io());
312        assert!(!err.is_config());
313        assert!(err.to_string().contains("Parse error"));
314        assert!(err.to_string().contains("syntax error at line 5"));
315    }
316
317    #[test]
318    fn test_error_operation() {
319        let err = Error::operation("index corrupted");
320        assert!(!err.is_io());
321        assert!(!err.is_not_found());
322        assert!(!err.is_config());
323        assert!(err.to_string().contains("index corrupted"));
324    }
325
326    // ------------------------------------------------------------------------
327    // From implementations
328    // ------------------------------------------------------------------------
329
330    #[test]
331    fn test_error_from_io_error() {
332        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "connection lost");
333        let err: Error = io_err.into();
334        assert!(err.is_io());
335        assert!(err.to_string().contains("connection lost"));
336    }
337
338    #[test]
339    fn test_error_from_json_error() {
340        let json_str = "{ invalid json }";
341        let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
342        let err: Error = json_err.into();
343        assert!(matches!(err, Error::Json(_)));
344        assert!(err.to_string().contains("JSON error"));
345    }
346
347    // ------------------------------------------------------------------------
348    // Error trait implementation
349    // ------------------------------------------------------------------------
350
351    #[test]
352    fn test_error_source_io() {
353        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "test");
354        let err: Error = io_err.into();
355        assert!(err.source().is_some());
356    }
357
358    #[test]
359    fn test_error_source_non_io() {
360        let err = Error::config("test");
361        assert!(err.source().is_none());
362    }
363
364    // ------------------------------------------------------------------------
365    // Display tests
366    // ------------------------------------------------------------------------
367
368    #[test]
369    fn test_error_display_all_variants() {
370        let errors = vec![
371            Error::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "io")),
372            Error::io_with_path(
373                std::io::Error::new(std::io::ErrorKind::NotFound, "io"),
374                "/path",
375            ),
376            Error::config("config"),
377            Error::not_found("Type", "id"),
378            Error::file_not_found("/path"),
379            Error::invalid_path("/path", "reason"),
380            Error::parse("parse"),
381            Error::operation("operation"),
382        ];
383
384        for err in errors {
385            let display = err.to_string();
386            assert!(
387                !display.is_empty(),
388                "Display should produce non-empty string for {:?}",
389                err
390            );
391        }
392    }
393
394    // ------------------------------------------------------------------------
395    // Result alias
396    // ------------------------------------------------------------------------
397
398    #[test]
399    fn test_result_alias() {
400        let ok: Result<i32> = Ok(42);
401        assert_eq!(ok.ok(), Some(42));
402
403        let err: Result<i32> = Err(Error::config("bad"));
404        assert!(err.is_err());
405    }
406
407    // ------------------------------------------------------------------------
408    // log_error_chain tests
409    // ------------------------------------------------------------------------
410
411    #[test]
412    fn test_log_error_chain_no_source() {
413        // Should not panic when error has no source
414        let err = Error::config("standalone error");
415        log_error_chain(&err);
416    }
417
418    #[test]
419    fn test_log_error_chain_with_source() {
420        // Should not panic when error has a source chain
421        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "inner cause");
422        let err: Error = io_err.into();
423        log_error_chain(&err);
424    }
425
426    #[test]
427    fn test_log_error_chain_nested() {
428        // Two-level chain: Error::Io wraps std::io::Error
429        let inner =
430            std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused");
431        let err: Error = inner.into();
432        // Verify it doesn't panic with a chained error
433        log_error_chain(&err);
434    }
435}