Skip to main content

rh_foundation/
error.rs

1//! Error types and utilities for the workspace.
2//!
3//! This module provides the foundation error type that can be extended
4//! by domain-specific error types in other crates.
5
6use std::collections::HashMap;
7use std::fmt;
8use thiserror::Error;
9
10/// Foundation error type providing common error variants.
11///
12/// This enum covers the most common error cases across the workspace.
13/// Domain-specific crates can extend this by wrapping it in their own
14/// error types.
15///
16/// # Example
17/// ```
18/// use rh_foundation::{FoundationError, ErrorContext};
19///
20/// fn example() -> rh_foundation::Result<()> {
21///     std::fs::read_to_string("config.json")
22///         .context("Failed to read config file")?;
23///     Ok(())
24/// }
25/// ```
26#[derive(Error, Debug)]
27pub enum FoundationError {
28    /// Configuration error with a descriptive message
29    #[error("Configuration error: {message}")]
30    Config { message: String },
31
32    /// I/O error
33    #[error("IO error: {0}")]
34    Io(#[from] std::io::Error),
35
36    /// JSON serialization/deserialization error
37    #[error("Serialization error: {0}")]
38    Serialization(#[from] serde_json::Error),
39
40    /// Generic error with context
41    #[error("Error: {0}")]
42    Other(#[from] anyhow::Error),
43
44    /// Invalid input with descriptive message
45    #[error("Invalid input: {0}")]
46    InvalidInput(String),
47
48    /// Parsing error
49    #[error("Parse error: {0}")]
50    Parse(String),
51
52    /// HTTP request error (available with `http` feature)
53    #[error("HTTP error: {0}")]
54    Http(String),
55
56    /// URL parsing error
57    #[error("URL parse error: {0}")]
58    UrlParse(String),
59
60    /// Authentication error
61    #[error("Authentication error: {0}")]
62    Authentication(String),
63}
64
65impl FoundationError {
66    /// Add context to this error
67    pub fn with_context(self, context: &str) -> Self {
68        FoundationError::Other(anyhow::Error::new(self).context(context.to_string()))
69    }
70}
71
72/// Result type alias using FoundationError
73pub type Result<T> = std::result::Result<T, FoundationError>;
74
75/// Helper to wrap IO errors with path context.
76///
77/// Creates a new IO error that preserves the original error kind but adds
78/// descriptive context including the action being performed and the file path.
79///
80/// # Example
81/// ```
82/// use rh_foundation::error::io_error_with_path;
83/// use std::path::Path;
84///
85/// fn example() -> rh_foundation::Result<()> {
86///     let path = Path::new("config.json");
87///     std::fs::read_to_string(path).map_err(|e| {
88///         io_error_with_path(e, path, "read config from")
89///     })?;
90///     Ok(())
91/// }
92/// ```
93pub fn io_error_with_path(
94    err: std::io::Error,
95    path: &std::path::Path,
96    action: &str,
97) -> FoundationError {
98    FoundationError::Io(std::io::Error::new(
99        err.kind(),
100        format!("Failed to {action} {}: {err}", path.display()),
101    ))
102}
103
104/// Trait for adding context to errors.
105///
106/// This trait allows chaining contextual information to errors,
107/// making it easier to track the source and cause of errors.
108///
109/// # Example
110/// ```
111/// use rh_foundation::ErrorContext;
112///
113/// fn read_config() -> rh_foundation::Result<String> {
114///     std::fs::read_to_string("config.json")
115///         .context("Failed to read configuration file")?;
116///     Ok("config".to_string())
117/// }
118/// ```
119pub trait ErrorContext<T, E> {
120    /// Add context to an error
121    fn context(self, context: impl fmt::Display) -> Result<T>;
122
123    /// Add context with a function (lazy evaluation)
124    fn with_context<F>(self, f: F) -> Result<T>
125    where
126        F: FnOnce() -> String;
127}
128
129impl<T, E> ErrorContext<T, E> for std::result::Result<T, E>
130where
131    E: std::error::Error + Send + Sync + 'static,
132{
133    fn context(self, context: impl fmt::Display) -> Result<T> {
134        self.map_err(|e| FoundationError::Other(anyhow::Error::new(e).context(context.to_string())))
135    }
136
137    fn with_context<F>(self, f: F) -> Result<T>
138    where
139        F: FnOnce() -> String,
140    {
141        self.map_err(|e| FoundationError::Other(anyhow::Error::new(e).context(f())))
142    }
143}
144
145/// Error with additional metadata for better debugging and error tracking.
146///
147/// This structure wraps any error and allows attaching key-value metadata
148/// that can be useful for logging, debugging, or displaying detailed error
149/// information to users.
150#[derive(Debug)]
151pub struct ErrorWithMetadata {
152    /// The underlying error
153    pub error: Box<dyn std::error::Error + Send + Sync>,
154    /// Additional metadata as key-value pairs
155    pub metadata: HashMap<String, String>,
156}
157
158impl ErrorWithMetadata {
159    /// Create a new error with metadata
160    pub fn new(error: impl std::error::Error + Send + Sync + 'static) -> Self {
161        Self {
162            error: Box::new(error),
163            metadata: HashMap::new(),
164        }
165    }
166
167    /// Add a metadata entry
168    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
169        self.metadata.insert(key.into(), value.into());
170        self
171    }
172
173    /// Get metadata value by key
174    pub fn get_metadata(&self, key: &str) -> Option<&str> {
175        self.metadata.get(key).map(|s| s.as_str())
176    }
177}
178
179impl fmt::Display for ErrorWithMetadata {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        write!(f, "{}", self.error)?;
182        if !self.metadata.is_empty() {
183            write!(f, " [")?;
184            let mut first = true;
185            for (key, value) in &self.metadata {
186                if !first {
187                    write!(f, ", ")?;
188                }
189                write!(f, "{key}={value}")?;
190                first = false;
191            }
192            write!(f, "]")?;
193        }
194        Ok(())
195    }
196}
197
198impl std::error::Error for ErrorWithMetadata {
199    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
200        Some(self.error.as_ref())
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_error_with_metadata() {
210        let err = ErrorWithMetadata::new(FoundationError::InvalidInput("test".to_string()))
211            .with_metadata("file", "config.json")
212            .with_metadata("line", "42");
213
214        assert_eq!(err.get_metadata("file"), Some("config.json"));
215        assert_eq!(err.get_metadata("line"), Some("42"));
216        assert_eq!(err.get_metadata("missing"), None);
217
218        let display = format!("{err}");
219        assert!(display.contains("Invalid input"));
220        assert!(display.contains("file=config.json"));
221        assert!(display.contains("line=42"));
222    }
223
224    #[test]
225    fn test_error_context() {
226        let result: std::result::Result<(), std::io::Error> = Err(std::io::Error::new(
227            std::io::ErrorKind::NotFound,
228            "file not found",
229        ));
230
231        let with_context = result.context("Reading configuration");
232        assert!(with_context.is_err());
233
234        let err_msg = format!("{}", with_context.unwrap_err());
235        assert!(err_msg.contains("Reading configuration"));
236    }
237}