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//! - Public API returns `crate::error::Result<T>` with structured `DrasiError` variants
20//! - Internal code uses `anyhow::Result<T>` for flexibility
21//! - Error chains are preserved via `#[error(transparent)]` for debugging
22//!
23//! # Example
24//!
25//! ```ignore
26//! use drasi_lib::error::{DrasiError, Result};
27//!
28//! fn example() -> Result<()> {
29//! // Pattern match on specific error variants
30//! match some_operation() {
31//! Err(DrasiError::ComponentNotFound { component_type, component_id }) => {
32//! println!("{} '{}' not found", component_type, component_id);
33//! }
34//! Err(DrasiError::InvalidState { message }) => {
35//! println!("Invalid state: {}", message);
36//! }
37//! Err(e) => return Err(e),
38//! Ok(v) => { /* ... */ }
39//! }
40//! Ok(())
41//! }
42//! ```
43
44use thiserror::Error;
45
46/// Main error type for drasi-lib operations.
47///
48/// This enum provides structured error variants that enable type-safe pattern matching
49/// by callers. Each variant contains contextual information about the error.
50#[derive(Error, Debug)]
51pub enum DrasiError {
52 /// Component (source, query, or reaction) was not found.
53 #[error("{component_type} '{component_id}' not found")]
54 ComponentNotFound {
55 /// The type of component (e.g., "source", "query", "reaction")
56 component_type: String,
57 /// The ID of the component that was not found
58 component_id: String,
59 },
60
61 /// Component already exists with the given ID.
62 #[error("{component_type} '{component_id}' already exists")]
63 AlreadyExists {
64 /// The type of component
65 component_type: String,
66 /// The ID that already exists
67 component_id: String,
68 },
69
70 /// Invalid configuration provided.
71 #[error("Invalid configuration: {message}")]
72 InvalidConfig {
73 /// Description of the configuration error
74 message: String,
75 },
76
77 /// Operation is not valid in the current state.
78 #[error("Invalid state: {message}")]
79 InvalidState {
80 /// Description of the state error
81 message: String,
82 },
83
84 /// Validation failed (e.g., builder validation, input validation).
85 #[error("Validation failed: {message}")]
86 Validation {
87 /// Description of the validation error
88 message: String,
89 },
90
91 /// A component operation (start, stop, delete, etc.) failed.
92 #[error("Failed to {operation} {component_type} '{component_id}': {reason}")]
93 OperationFailed {
94 /// The type of component
95 component_type: String,
96 /// The ID of the component
97 component_id: String,
98 /// The operation that failed (e.g., "start", "stop", "delete")
99 operation: String,
100 /// The reason for the failure
101 reason: String,
102 },
103
104 /// Internal error - wraps underlying errors while preserving the error chain.
105 /// Use `.source()` to access the underlying error chain.
106 #[error(transparent)]
107 Internal(#[from] anyhow::Error),
108}
109
110// ============================================================================
111// Constructor helpers for common error patterns
112// ============================================================================
113
114impl DrasiError {
115 /// Create a component not found error.
116 ///
117 /// # Example
118 /// ```ignore
119 /// DrasiError::component_not_found("source", "my-source-id")
120 /// ```
121 pub fn component_not_found(
122 component_type: impl Into<String>,
123 component_id: impl Into<String>,
124 ) -> Self {
125 DrasiError::ComponentNotFound {
126 component_type: component_type.into(),
127 component_id: component_id.into(),
128 }
129 }
130
131 /// Create an already exists error.
132 ///
133 /// # Example
134 /// ```ignore
135 /// DrasiError::already_exists("query", "my-query-id")
136 /// ```
137 pub fn already_exists(
138 component_type: impl Into<String>,
139 component_id: impl Into<String>,
140 ) -> Self {
141 DrasiError::AlreadyExists {
142 component_type: component_type.into(),
143 component_id: component_id.into(),
144 }
145 }
146
147 /// Create an invalid configuration error.
148 ///
149 /// # Example
150 /// ```ignore
151 /// DrasiError::invalid_config("Missing required field 'query'")
152 /// ```
153 pub fn invalid_config(message: impl Into<String>) -> Self {
154 DrasiError::InvalidConfig {
155 message: message.into(),
156 }
157 }
158
159 /// Create an invalid state error.
160 ///
161 /// # Example
162 /// ```ignore
163 /// DrasiError::invalid_state("Server must be initialized before starting")
164 /// ```
165 pub fn invalid_state(message: impl Into<String>) -> Self {
166 DrasiError::InvalidState {
167 message: message.into(),
168 }
169 }
170
171 /// Create a validation error.
172 ///
173 /// # Example
174 /// ```ignore
175 /// DrasiError::validation("Query string cannot be empty")
176 /// ```
177 pub fn validation(message: impl Into<String>) -> Self {
178 DrasiError::Validation {
179 message: message.into(),
180 }
181 }
182
183 /// Create an operation failed error.
184 ///
185 /// # Example
186 /// ```ignore
187 /// DrasiError::operation_failed("source", "my-source", "start", "Connection refused")
188 /// ```
189 pub fn operation_failed(
190 component_type: impl Into<String>,
191 component_id: impl Into<String>,
192 operation: impl Into<String>,
193 reason: impl Into<String>,
194 ) -> Self {
195 DrasiError::OperationFailed {
196 component_type: component_type.into(),
197 component_id: component_id.into(),
198 operation: operation.into(),
199 reason: reason.into(),
200 }
201 }
202
203 // ========================================================================
204 // Backward compatibility helpers (deprecated, use structured variants)
205 // ========================================================================
206
207 /// Create a provisioning error.
208 ///
209 /// # Deprecated
210 /// Consider using `operation_failed` with specific component context instead.
211 pub fn provisioning(msg: impl Into<String>) -> Self {
212 DrasiError::InvalidConfig {
213 message: format!("Provisioning error: {}", msg.into()),
214 }
215 }
216
217 /// Create a component error (generic operation failure).
218 ///
219 /// # Deprecated
220 /// Consider using `operation_failed` with specific component context instead.
221 pub fn component_error(msg: impl Into<String>) -> Self {
222 DrasiError::InvalidState {
223 message: format!("Component error: {}", msg.into()),
224 }
225 }
226
227 /// Create a startup validation error.
228 ///
229 /// # Deprecated
230 /// Consider using `validation` or `invalid_config` instead.
231 pub fn startup_validation(msg: impl Into<String>) -> Self {
232 DrasiError::Validation {
233 message: format!("Startup validation failed: {}", msg.into()),
234 }
235 }
236}
237
238/// Result type for drasi-lib operations.
239///
240/// This is the standard result type for all public API methods in drasi-lib.
241/// It uses `DrasiError` which supports pattern matching on specific error variants.
242pub type Result<T> = std::result::Result<T, DrasiError>;
243
244// ============================================================================
245// Tests
246// ============================================================================
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_component_not_found_display() {
254 let err = DrasiError::component_not_found("source", "my-source");
255 assert_eq!(err.to_string(), "source 'my-source' not found");
256 }
257
258 #[test]
259 fn test_already_exists_display() {
260 let err = DrasiError::already_exists("query", "my-query");
261 assert_eq!(err.to_string(), "query 'my-query' already exists");
262 }
263
264 #[test]
265 fn test_invalid_config_display() {
266 let err = DrasiError::invalid_config("Missing field");
267 assert_eq!(err.to_string(), "Invalid configuration: Missing field");
268 }
269
270 #[test]
271 fn test_invalid_state_display() {
272 let err = DrasiError::invalid_state("Not initialized");
273 assert_eq!(err.to_string(), "Invalid state: Not initialized");
274 }
275
276 #[test]
277 fn test_validation_display() {
278 let err = DrasiError::validation("Empty query string");
279 assert_eq!(err.to_string(), "Validation failed: Empty query string");
280 }
281
282 #[test]
283 fn test_operation_failed_display() {
284 let err =
285 DrasiError::operation_failed("source", "my-source", "start", "Connection refused");
286 assert_eq!(
287 err.to_string(),
288 "Failed to start source 'my-source': Connection refused"
289 );
290 }
291
292 #[test]
293 fn test_internal_error_from_anyhow() {
294 let anyhow_err = anyhow::anyhow!("Something went wrong");
295 let drasi_err: DrasiError = anyhow_err.into();
296 assert!(matches!(drasi_err, DrasiError::Internal(_)));
297 assert!(drasi_err.to_string().contains("Something went wrong"));
298 }
299
300 #[test]
301 fn test_error_pattern_matching() {
302 let err = DrasiError::component_not_found("source", "test-source");
303
304 match err {
305 DrasiError::ComponentNotFound {
306 component_type,
307 component_id,
308 } => {
309 assert_eq!(component_type, "source");
310 assert_eq!(component_id, "test-source");
311 }
312 _ => panic!("Expected ComponentNotFound variant"),
313 }
314 }
315
316 #[test]
317 fn test_internal_error_transparent() {
318 // Create an anyhow error with a source chain
319 let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
320 let anyhow_err = anyhow::Error::new(io_error).context("Failed to read config");
321 let drasi_err: DrasiError = anyhow_err.into();
322
323 // The error should be Internal variant
324 assert!(matches!(drasi_err, DrasiError::Internal(_)));
325
326 // The display should show the full chain due to #[error(transparent)]
327 let display = drasi_err.to_string();
328 assert!(display.contains("Failed to read config"));
329
330 // source() returns the underlying anyhow error's source
331 // Note: anyhow wraps errors, so source behavior depends on the chain
332 if let DrasiError::Internal(ref anyhow_err) = drasi_err {
333 // We can access the anyhow error and its chain
334 assert!(anyhow_err.to_string().contains("Failed to read config"));
335 }
336 }
337
338 #[test]
339 fn test_backward_compat_provisioning() {
340 let err = DrasiError::provisioning("Failed to provision");
341 assert!(err.to_string().contains("Provisioning error"));
342 }
343
344 #[test]
345 fn test_backward_compat_component_error() {
346 let err = DrasiError::component_error("Component failed");
347 assert!(err.to_string().contains("Component error"));
348 }
349
350 #[test]
351 fn test_backward_compat_startup_validation() {
352 let err = DrasiError::startup_validation("Validation failed");
353 assert!(err.to_string().contains("Startup validation failed"));
354 }
355}