evidentsource_core/domain/
error.rs

1//! Consolidated error hierarchy for the core library.
2//!
3//! This module provides a clean, hierarchical error structure that groups errors
4//! by domain concept while enabling seamless error propagation via `#[from]`.
5
6use thiserror::Error;
7
8/// Errors related to identifier validation (names, IDs, etc.)
9#[derive(Error, Debug, Clone, PartialEq)]
10pub enum IdentifierError {
11    #[error("invalid database name '{0}'")]
12    DatabaseName(String),
13
14    #[error("invalid stream name: {0:?}")]
15    StreamName(Option<String>),
16
17    #[error("invalid event id: {0}")]
18    EventId(String),
19
20    #[error("invalid event type: {0:?}")]
21    EventType(Option<String>),
22
23    #[error("invalid event subject: {0}")]
24    EventSubject(String),
25
26    #[error("invalid state view name '{0}'")]
27    StateViewName(String),
28
29    #[error("invalid state change name '{0}'")]
30    StateChangeName(String),
31}
32
33/// Errors related to constraint operations
34#[derive(Error, Debug, Clone, PartialEq)]
35pub enum ConstraintError {
36    #[error("blank constraint")]
37    BlankConstraint,
38
39    #[error("invalid range: min ({min}) must be <= max ({max})")]
40    InvalidRange { min: u64, max: u64 },
41
42    #[error("constraint conflict: {left} vs {right}")]
43    Conflict { left: String, right: String },
44
45    #[error("cannot combine constraints with different selectors")]
46    IncompatibleSelectors,
47
48    #[error(transparent)]
49    Identifier(#[from] IdentifierError),
50}
51
52/// Errors related to event validation
53#[derive(Error, Debug, Clone, PartialEq)]
54pub enum EventError {
55    #[error("invalid event source: {0}")]
56    InvalidSource(String),
57
58    #[error("duplicate event id: {stream}/{event_id}")]
59    DuplicateEventId { stream: String, event_id: String },
60
61    #[error(transparent)]
62    Identifier(#[from] IdentifierError),
63}
64
65/// Errors related to transaction operations
66#[derive(Error, Debug, Clone)]
67pub enum TransactionError {
68    #[error("transaction must contain at least one event")]
69    Empty,
70
71    #[error("invalid transaction ID: {0}")]
72    InvalidId(String),
73
74    #[error("transaction too large: {actual} events, max {max}")]
75    TooLarge { actual: usize, max: usize },
76
77    #[error("duplicate transaction id: {0}")]
78    DuplicateId(String),
79
80    #[error("transaction contains {} invalid events", .0.len())]
81    InvalidEvents(Vec<EventError>),
82
83    #[error("transaction has {} invalid constraints", .0.len())]
84    InvalidConstraints(Vec<ConstraintError>),
85
86    #[error("transaction has {} constraint violations", .0.len())]
87    ConstraintViolations(Vec<String>),
88}
89
90/// Context for enriching errors with operation details.
91///
92/// This provides additional context about what operation was being performed
93/// when an error occurred.
94///
95/// # Example
96///
97/// ```
98/// use evidentsource_core::domain::{DatabaseError, OperationContext};
99///
100/// fn perform_operation() -> Result<(), DatabaseError> {
101///     Err(DatabaseError::NotFound("my-db".to_string()))
102/// }
103///
104/// let result = perform_operation()
105///     .map_err(|e| e.with_context(OperationContext::new("transact", "my-db")));
106/// ```
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct OperationContext {
109    /// The name of the operation that was being performed.
110    pub operation: String,
111    /// The database being operated on.
112    pub database: String,
113    /// Optional additional details about the operation.
114    pub details: Option<String>,
115}
116
117impl OperationContext {
118    /// Create a new operation context.
119    pub fn new(operation: impl Into<String>, database: impl Into<String>) -> Self {
120        Self {
121            operation: operation.into(),
122            database: database.into(),
123            details: None,
124        }
125    }
126
127    /// Add details to the context.
128    pub fn with_details(mut self, details: impl Into<String>) -> Self {
129        self.details = Some(details.into());
130        self
131    }
132}
133
134impl std::fmt::Display for OperationContext {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        write!(f, "{}(db={})", self.operation, self.database)?;
137        if let Some(ref details) = self.details {
138            write!(f, " [{}]", details)?;
139        }
140        Ok(())
141    }
142}
143
144/// Errors related to database operations
145#[derive(Error, Debug, Clone)]
146pub enum DatabaseError {
147    #[error("database '{0}' not found")]
148    NotFound(String),
149
150    #[error("database '{0}' already exists")]
151    AlreadyExists(String),
152
153    #[error("concurrent write collision")]
154    ConcurrentWriteCollision,
155
156    #[error("operation timed out")]
157    Timeout,
158
159    #[error("server error: {0}")]
160    ServerError(String),
161
162    #[error("{context}: {source}")]
163    WithContext {
164        context: OperationContext,
165        #[source]
166        source: Box<DatabaseError>,
167    },
168
169    #[error(transparent)]
170    Identifier(#[from] IdentifierError),
171
172    #[error(transparent)]
173    Transaction(#[from] TransactionError),
174}
175
176impl DatabaseError {
177    /// Add context to this error.
178    ///
179    /// This wraps the error with additional context about the operation
180    /// that was being performed when the error occurred.
181    ///
182    /// # Example
183    ///
184    /// ```
185    /// use evidentsource_core::domain::{DatabaseError, OperationContext};
186    ///
187    /// let err = DatabaseError::NotFound("my-db".to_string());
188    /// let with_ctx = err.with_context(OperationContext::new("transact", "my-db"));
189    ///
190    /// assert!(with_ctx.to_string().contains("transact"));
191    /// ```
192    pub fn with_context(self, context: OperationContext) -> Self {
193        DatabaseError::WithContext {
194            context,
195            source: Box::new(self),
196        }
197    }
198
199    /// Get the inner error, stripping any context wrappers.
200    pub fn inner(&self) -> &DatabaseError {
201        match self {
202            DatabaseError::WithContext { source, .. } => source.inner(),
203            _ => self,
204        }
205    }
206}
207
208/// Errors related to state view operations
209#[derive(Error, Debug, Clone)]
210pub enum StateViewError {
211    #[error("state view {name}.v{version} not found")]
212    NotFound { name: String, version: u64 },
213
214    #[error("error evolving state view: {0}")]
215    EvolveError(String),
216
217    #[error("server error: {0}")]
218    ServerError(String),
219
220    #[error(transparent)]
221    Identifier(#[from] IdentifierError),
222}
223
224/// Errors related to state change operations
225#[derive(Error, Debug, Clone)]
226pub enum StateChangeError {
227    #[error("state change {name}.v{version} not found")]
228    NotFound { name: String, version: u64 },
229
230    #[error("execution error: {0}")]
231    ExecutionError(String),
232
233    #[error("server error: {0}")]
234    ServerError(String),
235
236    #[error(transparent)]
237    Identifier(#[from] IdentifierError),
238
239    #[error(transparent)]
240    Database(#[from] DatabaseError),
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_identifier_error_display() {
249        let err = IdentifierError::DatabaseName("bad-name!".to_string());
250        assert_eq!(err.to_string(), "invalid database name 'bad-name!'");
251    }
252
253    #[test]
254    fn test_constraint_error_from_identifier() {
255        let id_err = IdentifierError::StreamName(Some("bad".to_string()));
256        let constraint_err: ConstraintError = id_err.into();
257        assert!(matches!(constraint_err, ConstraintError::Identifier(_)));
258    }
259
260    #[test]
261    fn test_database_error_from_transaction() {
262        let txn_err = TransactionError::Empty;
263        let db_err: DatabaseError = txn_err.into();
264        assert!(matches!(db_err, DatabaseError::Transaction(_)));
265    }
266
267    #[test]
268    fn test_operation_context_display() {
269        let ctx = OperationContext::new("transact", "my-db");
270        assert_eq!(ctx.to_string(), "transact(db=my-db)");
271
272        let ctx_with_details = OperationContext::new("execute_state_change", "my-db")
273            .with_details("create-account.v1");
274        assert_eq!(
275            ctx_with_details.to_string(),
276            "execute_state_change(db=my-db) [create-account.v1]"
277        );
278    }
279
280    #[test]
281    fn test_database_error_with_context() {
282        let err = DatabaseError::NotFound("my-db".to_string());
283        let ctx = OperationContext::new("transact", "my-db");
284        let with_ctx = err.with_context(ctx);
285
286        let msg = with_ctx.to_string();
287        assert!(msg.contains("transact"));
288        assert!(msg.contains("my-db"));
289        assert!(msg.contains("not found"));
290    }
291
292    #[test]
293    fn test_database_error_inner() {
294        let err = DatabaseError::NotFound("my-db".to_string());
295        let ctx = OperationContext::new("transact", "my-db");
296        let with_ctx = err.with_context(ctx);
297
298        // inner() should return the unwrapped error
299        let inner = with_ctx.inner();
300        assert!(matches!(inner, DatabaseError::NotFound(_)));
301    }
302
303    #[test]
304    fn test_database_error_nested_context() {
305        let err = DatabaseError::NotFound("my-db".to_string());
306        let ctx1 = OperationContext::new("inner_op", "my-db");
307        let ctx2 = OperationContext::new("outer_op", "my-db");
308        let nested = err.with_context(ctx1).with_context(ctx2);
309
310        // inner() should recursively unwrap
311        let inner = nested.inner();
312        assert!(matches!(inner, DatabaseError::NotFound(_)));
313    }
314}