Skip to main content

drasi_lib/
error.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Error types for drasi-lib operations.
16//!
17//! This module provides structured error types using `thiserror` for idiomatic Rust error handling.
18//! The pattern follows major Rust libraries like `tokio`, `reqwest`, and `sqlx`.
19//!
20//! # Error Handling Architecture
21//!
22//! drasi-lib uses a three-layer error strategy:
23//!
24//! | Layer | Error Type | When to Use |
25//! |-------|-----------|-------------|
26//! | **Public API** | `crate::error::Result<T>` / `DrasiError` | Methods on `DrasiLib`, `*_ops` modules, `InspectionAPI` |
27//! | **Internal modules** | `anyhow::Result<T>` | Lifecycle, managers, component_graph — use `.context()` for rich chains |
28//! | **Plugin traits** | `anyhow::Result<T>` | `Source`, `Reaction`, `BootstrapProvider` trait methods |
29//!
30//! ## Bridge: Internal → Public
31//!
32//! `DrasiError::Internal(#[from] anyhow::Error)` auto-converts internal `anyhow` errors
33//! at the public API boundary via the `?` operator. For errors with known semantics, use
34//! the structured variants directly (e.g., `DrasiError::invalid_state()`).
35//!
36//! ## Rules
37//!
38//! - **Public API methods** must return `crate::error::Result<T>` with `DrasiError` variants
39//! - **Internal modules** should use `anyhow::Result` with `.context("what failed")`
40//! - **Plugin trait implementations** should use `anyhow::Result` with `.context()`
41//! - **Never** use `anyhow!()` in public API methods — use `DrasiError` constructors
42//!
43//! # Example
44//!
45//! ```ignore
46//! use drasi_lib::error::{DrasiError, Result};
47//!
48//! fn example() -> Result<()> {
49//!     // Pattern match on specific error variants
50//!     match some_operation() {
51//!         Err(DrasiError::ComponentNotFound { component_type, component_id }) => {
52//!             println!("{} '{}' not found", component_type, component_id);
53//!         }
54//!         Err(DrasiError::InvalidState { message }) => {
55//!             println!("Invalid state: {}", message);
56//!         }
57//!         Err(e) => return Err(e),
58//!         Ok(v) => { /* ... */ }
59//!     }
60//!     Ok(())
61//! }
62//! ```
63
64use thiserror::Error;
65
66/// Main error type for drasi-lib operations.
67///
68/// This enum provides structured error variants that enable type-safe pattern matching
69/// by callers. Each variant contains contextual information about the error.
70#[derive(Error, Debug)]
71pub enum DrasiError {
72    /// Component (source, query, or reaction) was not found.
73    #[error("{component_type} '{component_id}' not found")]
74    ComponentNotFound {
75        /// The type of component (e.g., "source", "query", "reaction")
76        component_type: String,
77        /// The ID of the component that was not found
78        component_id: String,
79    },
80
81    /// Component already exists with the given ID.
82    #[error("{component_type} '{component_id}' already exists")]
83    AlreadyExists {
84        /// The type of component
85        component_type: String,
86        /// The ID that already exists
87        component_id: String,
88    },
89
90    /// Invalid configuration provided.
91    #[error("Invalid configuration: {message}")]
92    InvalidConfig {
93        /// Description of the configuration error
94        message: String,
95    },
96
97    /// Operation is not valid in the current state.
98    #[error("Invalid state: {message}")]
99    InvalidState {
100        /// Description of the state error
101        message: String,
102    },
103
104    /// Validation failed (e.g., builder validation, input validation).
105    #[error("Validation failed: {message}")]
106    Validation {
107        /// Description of the validation error
108        message: String,
109    },
110
111    /// A component operation (start, stop, delete, etc.) failed.
112    #[error("Failed to {operation} {component_type} '{component_id}': {reason}")]
113    OperationFailed {
114        /// The type of component
115        component_type: String,
116        /// The ID of the component
117        component_id: String,
118        /// The operation that failed (e.g., "start", "stop", "delete")
119        operation: String,
120        /// The reason for the failure
121        reason: String,
122    },
123
124    /// Internal error - wraps underlying errors while preserving the error chain.
125    /// Use `.source()` to access the underlying error chain.
126    #[error(transparent)]
127    Internal(#[from] anyhow::Error),
128}
129
130// ============================================================================
131// Constructor helpers for common error patterns
132// ============================================================================
133
134impl DrasiError {
135    /// Create a component not found error.
136    ///
137    /// # Example
138    /// ```ignore
139    /// DrasiError::component_not_found("source", "my-source-id")
140    /// ```
141    pub fn component_not_found(
142        component_type: impl Into<String>,
143        component_id: impl Into<String>,
144    ) -> Self {
145        DrasiError::ComponentNotFound {
146            component_type: component_type.into(),
147            component_id: component_id.into(),
148        }
149    }
150
151    /// Create an already exists error.
152    ///
153    /// # Example
154    /// ```ignore
155    /// DrasiError::already_exists("query", "my-query-id")
156    /// ```
157    pub fn already_exists(
158        component_type: impl Into<String>,
159        component_id: impl Into<String>,
160    ) -> Self {
161        DrasiError::AlreadyExists {
162            component_type: component_type.into(),
163            component_id: component_id.into(),
164        }
165    }
166
167    /// Create an invalid configuration error.
168    ///
169    /// # Example
170    /// ```ignore
171    /// DrasiError::invalid_config("Missing required field 'query'")
172    /// ```
173    pub fn invalid_config(message: impl Into<String>) -> Self {
174        DrasiError::InvalidConfig {
175            message: message.into(),
176        }
177    }
178
179    /// Create an invalid state error.
180    ///
181    /// # Example
182    /// ```ignore
183    /// DrasiError::invalid_state("Server must be initialized before starting")
184    /// ```
185    pub fn invalid_state(message: impl Into<String>) -> Self {
186        DrasiError::InvalidState {
187            message: message.into(),
188        }
189    }
190
191    /// Create a validation error.
192    ///
193    /// # Example
194    /// ```ignore
195    /// DrasiError::validation("Query string cannot be empty")
196    /// ```
197    pub fn validation(message: impl Into<String>) -> Self {
198        DrasiError::Validation {
199            message: message.into(),
200        }
201    }
202
203    /// Create an operation failed error.
204    ///
205    /// # Example
206    /// ```ignore
207    /// DrasiError::operation_failed("source", "my-source", "start", "Connection refused")
208    /// ```
209    pub fn operation_failed(
210        component_type: impl Into<String>,
211        component_id: impl Into<String>,
212        operation: impl Into<String>,
213        reason: impl Into<String>,
214    ) -> Self {
215        DrasiError::OperationFailed {
216            component_type: component_type.into(),
217            component_id: component_id.into(),
218            operation: operation.into(),
219            reason: reason.into(),
220        }
221    }
222
223    // ========================================================================
224    // Backward compatibility helpers (deprecated, use structured variants)
225    // ========================================================================
226}
227
228/// Result type for drasi-lib operations.
229///
230/// This is the standard result type for all public API methods in drasi-lib.
231/// It uses `DrasiError` which supports pattern matching on specific error variants.
232pub type Result<T> = std::result::Result<T, DrasiError>;
233
234// ============================================================================
235// Tests
236// ============================================================================
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_component_not_found_display() {
244        let err = DrasiError::component_not_found("source", "my-source");
245        assert_eq!(err.to_string(), "source 'my-source' not found");
246    }
247
248    #[test]
249    fn test_already_exists_display() {
250        let err = DrasiError::already_exists("query", "my-query");
251        assert_eq!(err.to_string(), "query 'my-query' already exists");
252    }
253
254    #[test]
255    fn test_invalid_config_display() {
256        let err = DrasiError::invalid_config("Missing field");
257        assert_eq!(err.to_string(), "Invalid configuration: Missing field");
258    }
259
260    #[test]
261    fn test_invalid_state_display() {
262        let err = DrasiError::invalid_state("Not initialized");
263        assert_eq!(err.to_string(), "Invalid state: Not initialized");
264    }
265
266    #[test]
267    fn test_validation_display() {
268        let err = DrasiError::validation("Empty query string");
269        assert_eq!(err.to_string(), "Validation failed: Empty query string");
270    }
271
272    #[test]
273    fn test_operation_failed_display() {
274        let err =
275            DrasiError::operation_failed("source", "my-source", "start", "Connection refused");
276        assert_eq!(
277            err.to_string(),
278            "Failed to start source 'my-source': Connection refused"
279        );
280    }
281
282    #[test]
283    fn test_internal_error_from_anyhow() {
284        let anyhow_err = anyhow::anyhow!("Something went wrong");
285        let drasi_err: DrasiError = anyhow_err.into();
286        assert!(matches!(drasi_err, DrasiError::Internal(_)));
287        assert!(drasi_err.to_string().contains("Something went wrong"));
288    }
289
290    #[test]
291    fn test_error_pattern_matching() {
292        let err = DrasiError::component_not_found("source", "test-source");
293
294        match err {
295            DrasiError::ComponentNotFound {
296                component_type,
297                component_id,
298            } => {
299                assert_eq!(component_type, "source");
300                assert_eq!(component_id, "test-source");
301            }
302            _ => panic!("Expected ComponentNotFound variant"),
303        }
304    }
305
306    #[test]
307    fn test_internal_error_transparent() {
308        // Create an anyhow error with a source chain
309        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
310        let anyhow_err = anyhow::Error::new(io_error).context("Failed to read config");
311        let drasi_err: DrasiError = anyhow_err.into();
312
313        // The error should be Internal variant
314        assert!(matches!(drasi_err, DrasiError::Internal(_)));
315
316        // The display should show the full chain due to #[error(transparent)]
317        let display = drasi_err.to_string();
318        assert!(display.contains("Failed to read config"));
319
320        // source() returns the underlying anyhow error's source
321        // Note: anyhow wraps errors, so source behavior depends on the chain
322        if let DrasiError::Internal(ref anyhow_err) = drasi_err {
323            // We can access the anyhow error and its chain
324            assert!(anyhow_err.to_string().contains("Failed to read config"));
325        }
326    }
327}