Skip to main content

exomonad_core/effects/
error.rs

1//! Effect-specific error types for the extensible effects system.
2//!
3//! These error types are designed to be:
4//! - Serializable across the WASM boundary
5//! - Semantically meaningful for effect handlers
6//! - Extensible via the `Custom` variant for domain-specific errors
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11/// Effect-specific error with common variants and Custom fallback.
12///
13/// This error type is returned by effect handlers when operations fail.
14/// It provides structured information that can be serialized across the
15/// WASM boundary and handled programmatically by the guest.
16///
17/// # Common Patterns
18///
19/// - `NotFound`: Resource doesn't exist (file, issue, entity)
20/// - `InvalidInput`: Request parameters failed validation
21/// - `NetworkError`: External service unreachable or returned error
22/// - `Custom`: Domain-specific errors with extensible structure
23///
24/// # Example
25///
26/// ```rust,ignore
27/// use crate::effects::EffectError;
28///
29/// fn find_entity(id: &str) -> Result<(), EffectError> {
30///     // Simulate a lookup that fails
31///     if id == "not_found" {
32///         Err(EffectError::not_found(format!("entity/{}", id)))
33///     } else {
34///         Ok(())
35///     }
36/// }
37/// ```
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39#[serde(tag = "kind")]
40pub enum EffectError {
41    /// Resource not found.
42    #[serde(rename = "not_found")]
43    NotFound {
44        /// Description of the missing resource (e.g., "issue/123", "file/README.md").
45        resource: String,
46    },
47
48    /// Invalid input parameters.
49    #[serde(rename = "invalid_input")]
50    InvalidInput {
51        /// Human-readable description of the validation failure.
52        message: String,
53    },
54
55    /// Network or external service error.
56    #[serde(rename = "network_error")]
57    NetworkError {
58        /// Description of the network failure.
59        message: String,
60    },
61
62    /// Permission denied.
63    #[serde(rename = "permission_denied")]
64    PermissionDenied {
65        /// Description of the permission failure.
66        message: String,
67    },
68
69    /// Operation timed out.
70    #[serde(rename = "timeout")]
71    Timeout {
72        /// Description of what timed out.
73        message: String,
74    },
75
76    /// Custom domain-specific error.
77    ///
78    /// Use this for errors that don't fit the common patterns above.
79    /// External consumers can define their own error codes and structures.
80    #[serde(rename = "custom")]
81    Custom {
82        /// Application-specific error code (e.g., "egregore.signal_failed").
83        code: String,
84        /// Human-readable error message.
85        message: String,
86        /// Optional structured data for debugging/handling.
87        #[serde(skip_serializing_if = "Option::is_none")]
88        data: Option<Value>,
89    },
90}
91
92impl std::fmt::Display for EffectError {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            EffectError::NotFound { resource } => write!(f, "Not found: {}", resource),
96            EffectError::InvalidInput { message } => write!(f, "Invalid input: {}", message),
97            EffectError::NetworkError { message } => write!(f, "Network error: {}", message),
98            EffectError::PermissionDenied { message } => {
99                write!(f, "Permission denied: {}", message)
100            }
101            EffectError::Timeout { message } => write!(f, "Timeout: {}", message),
102            EffectError::Custom { code, message, .. } => write!(f, "[{}] {}", code, message),
103        }
104    }
105}
106
107impl std::error::Error for EffectError {}
108
109impl EffectError {
110    /// Create a not found error.
111    pub fn not_found(resource: impl Into<String>) -> Self {
112        EffectError::NotFound {
113            resource: resource.into(),
114        }
115    }
116
117    /// Create an invalid input error.
118    pub fn invalid_input(message: impl Into<String>) -> Self {
119        EffectError::InvalidInput {
120            message: message.into(),
121        }
122    }
123
124    /// Create a network error.
125    pub fn network_error(message: impl Into<String>) -> Self {
126        EffectError::NetworkError {
127            message: message.into(),
128        }
129    }
130
131    /// Create a permission denied error.
132    pub fn permission_denied(message: impl Into<String>) -> Self {
133        EffectError::PermissionDenied {
134            message: message.into(),
135        }
136    }
137
138    /// Create a timeout error.
139    pub fn timeout(message: impl Into<String>) -> Self {
140        EffectError::Timeout {
141            message: message.into(),
142        }
143    }
144
145    /// Create a custom error with code and message.
146    pub fn custom(code: impl Into<String>, message: impl Into<String>) -> Self {
147        EffectError::Custom {
148            code: code.into(),
149            message: message.into(),
150            data: None,
151        }
152    }
153
154    /// Create a custom error with additional data.
155    pub fn custom_with_data(
156        code: impl Into<String>,
157        message: impl Into<String>,
158        data: Value,
159    ) -> Self {
160        EffectError::Custom {
161            code: code.into(),
162            message: message.into(),
163            data: Some(data),
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_error_serialization() {
174        let err = EffectError::NotFound {
175            resource: "issue/123".to_string(),
176        };
177        let json = serde_json::to_string(&err).unwrap();
178        assert!(json.contains("not_found"));
179        assert!(json.contains("issue/123"));
180    }
181
182    #[test]
183    fn test_custom_error_with_data() {
184        let err = EffectError::custom_with_data(
185            "egregore.signal_failed",
186            "Signal propagation failed",
187            serde_json::json!({"retry_count": 3}),
188        );
189        let json = serde_json::to_string(&err).unwrap();
190        assert!(json.contains("custom"));
191        assert!(json.contains("egregore.signal_failed"));
192        assert!(json.contains("retry_count"));
193    }
194
195    #[test]
196    fn test_error_display() {
197        let err = EffectError::not_found("file/README.md");
198        assert_eq!(err.to_string(), "Not found: file/README.md");
199    }
200}