Skip to main content

error_forge/
registry.rs

1use crate::error::ForgeError;
2use std::collections::HashMap;
3use std::fmt;
4use std::sync::OnceLock;
5use std::sync::RwLock;
6
7/// A central registry for error codes and metadata
8pub struct ErrorRegistry {
9    /// Maps error codes to their descriptions
10    codes: RwLock<HashMap<String, ErrorCodeInfo>>,
11}
12
13/// Metadata for a registered error code.
14///
15/// Marked `#[non_exhaustive]` so future minor releases can add new
16/// fields (e.g. severity, tags, owner) without breaking callers.
17#[derive(Clone, Debug)]
18#[non_exhaustive]
19pub struct ErrorCodeInfo {
20    /// The error code (e.g. "AUTH-001")
21    pub code: String,
22    /// A human-readable description of this error type
23    pub description: String,
24    /// A URL to documentation about this error, if available
25    pub documentation_url: Option<String>,
26    /// Whether this error is expected to be retryable
27    pub retryable: bool,
28}
29
30impl ErrorRegistry {
31    /// Create a new empty error registry
32    fn new() -> Self {
33        Self {
34            codes: RwLock::new(HashMap::new()),
35        }
36    }
37
38    /// Register an error code with metadata
39    pub fn register_code(
40        &self,
41        code: String,
42        description: String,
43        documentation_url: Option<String>,
44        retryable: bool,
45    ) -> Result<(), String> {
46        let mut codes = match self.codes.write() {
47            Ok(codes) => codes,
48            Err(_) => return Err("Failed to acquire write lock on error registry".to_string()),
49        };
50
51        if codes.contains_key(&code) {
52            return Err(format!("Error code '{code}' is already registered"));
53        }
54
55        codes.insert(
56            code.clone(),
57            ErrorCodeInfo {
58                code,
59                description,
60                documentation_url,
61                retryable,
62            },
63        );
64
65        Ok(())
66    }
67
68    /// Get info about a registered error code
69    pub fn get_code_info(&self, code: &str) -> Option<ErrorCodeInfo> {
70        match self.codes.read() {
71            Ok(codes) => codes.get(code).cloned(),
72            Err(_) => None,
73        }
74    }
75
76    /// Check if an error code is registered
77    pub fn is_registered(&self, code: &str) -> bool {
78        match self.codes.read() {
79            Ok(codes) => codes.contains_key(code),
80            Err(_) => false,
81        }
82    }
83
84    /// Get the global error registry instance
85    pub fn global() -> &'static ErrorRegistry {
86        static REGISTRY: OnceLock<ErrorRegistry> = OnceLock::new();
87        REGISTRY.get_or_init(ErrorRegistry::new)
88    }
89}
90
91/// An error with an associated error code.
92///
93/// Marked `#[non_exhaustive]` so future minor releases can add new
94/// fields without breaking callers. External code must not
95/// construct `CodedError` via struct-literal syntax; use
96/// [`CodedError::new`] or the [`WithErrorCode::with_code`]
97/// extension method.
98#[derive(Debug)]
99#[non_exhaustive]
100pub struct CodedError<E> {
101    /// The original error
102    pub error: E,
103    /// The error code
104    pub code: String,
105    /// Per-instance override for retryability
106    pub retryable: Option<bool>,
107    /// Whether this error is fatal
108    pub fatal: bool,
109    /// Per-instance override for status code
110    pub status: Option<u16>,
111}
112
113impl<E> CodedError<E> {
114    /// Wrap an error with a stable code.
115    ///
116    /// The code is **not** auto-registered in the global registry.
117    /// Pre-register the code at startup with
118    /// [`register_error_code`] if you want documentation URLs,
119    /// per-code descriptions, or retryability metadata to flow
120    /// through [`CodedError::code_info`] / [`CodedError::is_retryable`].
121    ///
122    /// # Behaviour change since `1.0.0`
123    ///
124    /// Prior `0.9.x` releases auto-registered the code from inside
125    /// `CodedError::new`, which took a write lock on the global
126    /// registry on the first occurrence of every new code per
127    /// process. That lazy-registration step is gone in `1.0` — the
128    /// hot path is now a single allocation (the `String` from
129    /// `code.into()`) and zero locking. Code metadata that was
130    /// pre-registered via [`register_error_code`] continues to be
131    /// consulted via [`CodedError::code_info`] / `is_retryable`.
132    pub fn new(error: E, code: impl Into<String>) -> Self {
133        Self {
134            error,
135            code: code.into(),
136            retryable: None,
137            fatal: false,
138            status: None,
139        }
140    }
141
142    /// Get information about this error code from the registry
143    pub fn code_info(&self) -> Option<ErrorCodeInfo> {
144        ErrorRegistry::global().get_code_info(&self.code)
145    }
146
147    /// Set whether this error is retryable
148    pub fn with_retryable(mut self, retryable: bool) -> Self {
149        self.retryable = Some(retryable);
150        self
151    }
152
153    /// Set whether this error is fatal
154    pub fn with_fatal(mut self, fatal: bool) -> Self {
155        self.fatal = fatal;
156        self
157    }
158
159    /// Set the HTTP status code for this error
160    pub fn with_status(mut self, status: u16) -> Self {
161        self.status = Some(status);
162        self
163    }
164}
165
166impl<E: fmt::Display> fmt::Display for CodedError<E> {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        write!(f, "[{}] {}", self.code, self.error)
169    }
170}
171
172impl<E: std::error::Error + 'static> std::error::Error for CodedError<E> {
173    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
174        Some(&self.error)
175    }
176}
177
178// Implement ForgeError for CodedError when the inner error implements ForgeError
179impl<E: ForgeError> ForgeError for CodedError<E> {
180    fn kind(&self) -> &'static str {
181        self.error.kind()
182    }
183
184    fn caption(&self) -> &'static str {
185        self.error.caption()
186    }
187
188    fn is_retryable(&self) -> bool {
189        self.retryable.unwrap_or_else(|| {
190            self.code_info()
191                .map_or_else(|| self.error.is_retryable(), |info| info.retryable)
192        })
193    }
194
195    fn is_fatal(&self) -> bool {
196        self.fatal || self.error.is_fatal()
197    }
198
199    fn status_code(&self) -> u16 {
200        self.status.unwrap_or_else(|| self.error.status_code())
201    }
202
203    fn exit_code(&self) -> i32 {
204        self.error.exit_code()
205    }
206
207    fn user_message(&self) -> String {
208        format!("[{}] {}", self.code, self.error.user_message())
209    }
210
211    fn dev_message(&self) -> String {
212        if let Some(info) = self.code_info() {
213            if let Some(url) = info.documentation_url {
214                return format!("[{}] {} ({})", self.code, self.error.dev_message(), url);
215            }
216        }
217        format!("[{}] {}", self.code, self.error.dev_message())
218    }
219
220    fn backtrace(&self) -> Option<&std::backtrace::Backtrace> {
221        self.error.backtrace()
222    }
223}
224
225/// Extension trait for adding error codes
226pub trait WithErrorCode<E> {
227    /// Attach an error code to an error
228    fn with_code(self, code: impl Into<String>) -> CodedError<E>;
229}
230
231impl<E> WithErrorCode<E> for E {
232    fn with_code(self, code: impl Into<String>) -> CodedError<E> {
233        CodedError::new(self, code)
234    }
235}
236
237/// Register an error code in the global registry
238pub fn register_error_code(
239    code: impl Into<String>,
240    description: impl Into<String>,
241    documentation_url: Option<impl Into<String>>,
242    retryable: bool,
243) -> Result<(), String> {
244    ErrorRegistry::global().register_code(
245        code.into(),
246        description.into(),
247        documentation_url.map(|url| url.into()),
248        retryable,
249    )
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::AppError;
256
257    #[test]
258    fn test_error_with_code() {
259        let error = AppError::config("Invalid config").with_code("CONFIG-001");
260
261        assert_eq!(
262            error.to_string(),
263            "[CONFIG-001] ⚙️ Configuration Error: Invalid config"
264        );
265    }
266
267    #[test]
268    fn test_register_error_code() {
269        let _ = register_error_code(
270            "AUTH-001",
271            "Authentication failed due to invalid credentials",
272            Some("https://docs.example.com/errors/auth-001"),
273            true,
274        );
275
276        let info = ErrorRegistry::global().get_code_info("AUTH-001");
277        assert!(info.is_some());
278        let info = info.unwrap();
279        assert_eq!(info.code, "AUTH-001");
280        assert_eq!(
281            info.description,
282            "Authentication failed due to invalid credentials"
283        );
284        assert_eq!(
285            info.documentation_url,
286            Some("https://docs.example.com/errors/auth-001".to_string())
287        );
288        assert!(info.retryable);
289    }
290}