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}