Skip to main content

error_path/
lib.rs

1//! `error-path` adds stable, structured operational addresses to Rust errors.
2//!
3//! The crate is intentionally tiny:
4//! - [`WithErrorPath`] is the compatibility trait used by macros.
5//! - [`ErrorPath`] is a structured operational address.
6//! - Optional features adapt `anyhow`, `eyre`, `error-stack`, and `stacked_errors`.
7//!
8//! # Example
9//!
10//! ```rust
11//! use error_path::ErrorPath;
12//!
13//! let mut error_path = ErrorPath::from_segment("http.0405");
14//! error_path.prepend_path("login.api.request_login");
15//!
16//! assert_eq!(error_path.to_string(), "login.api.request_login.http.0405");
17//! ```
18
19use std::error::Error;
20use std::fmt;
21use std::sync::OnceLock;
22
23#[cfg(any(feature = "anyhow", feature = "eyre"))]
24use std::sync::Mutex;
25
26#[cfg(feature = "macros")]
27pub use error_path_macros::{error_path, error_path_impl, error_path_skip};
28
29static PATH_DELIMITER: OnceLock<String> = OnceLock::new();
30
31/// Error returned when the process-wide path delimiter cannot be configured.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum SetPathDelimiterError {
35    /// An empty delimiter was provided.
36    Empty,
37    /// The delimiter was already configured.
38    AlreadySet,
39}
40
41impl fmt::Display for SetPathDelimiterError {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::Empty => f.write_str("path delimiter must not be empty"),
45            Self::AlreadySet => f.write_str("path delimiter is already set"),
46        }
47    }
48}
49
50impl Error for SetPathDelimiterError {}
51
52/// Set the process-wide delimiter used by [`ErrorPath`] `Display`.
53///
54/// The delimiter can be set only once. Call this near application startup,
55/// before errors are formatted. Reading or formatting a path before this call
56/// initializes the delimiter to `"."`.
57pub fn set_path_delimiter(delimiter: impl Into<String>) -> Result<(), SetPathDelimiterError> {
58    let delimiter = delimiter.into();
59    if delimiter.is_empty() {
60        return Err(SetPathDelimiterError::Empty);
61    }
62
63    PATH_DELIMITER
64        .set(delimiter)
65        .map_err(|_| SetPathDelimiterError::AlreadySet)
66}
67
68/// Return the process-wide path delimiter.
69///
70/// The first call initializes an unset delimiter to `"."`.
71pub fn path_delimiter() -> &'static str {
72    PATH_DELIMITER.get_or_init(|| ".".to_owned()).as_str()
73}
74
75/// Compatibility trait used by the generated macro code.
76///
77/// Implement this trait for any error type that wants to work with
78/// `#[error_path]` or `#[error_path_impl]`.
79pub trait WithErrorPath: Sized {
80    /// Return the same error value enriched with `path`.
81    fn with_error_path(self, path: &'static str) -> Self;
82}
83
84/// Read a structured operational address from an error.
85///
86/// Implement this trait for an application error type that stores an
87/// [`ErrorPath`]. Optional adapters implement it to expose only segments added
88/// by [`WithErrorPath`]; they never infer a path or error code from a message.
89/// The default implementation returns `None` when no structured address exists.
90pub trait ErrorPathExt {
91    /// Return this error's structured operational address, when available.
92    fn error_path(&self) -> Option<ErrorPath> {
93        None
94    }
95}
96
97/// A structured operational error address.
98///
99/// Segments are kept separately so callers can use them in logs, JSON, or
100/// storage without parsing a rendered string.
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
102pub struct ErrorPath {
103    segments: Vec<&'static str>,
104}
105
106impl ErrorPath {
107    /// Create an empty error path.
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Create a path from segments separated by [`path_delimiter`], ignoring
113    /// empty segments.
114    pub fn from_path(path: &'static str) -> Self {
115        Self {
116            segments: path
117                .split(path_delimiter())
118                .filter(|segment| !segment.is_empty())
119                .collect(),
120        }
121    }
122
123    /// Create a path using the configured delimiter.
124    ///
125    /// Prefer [`ErrorPath::from_path`]. This compatibility alias no longer
126    /// assumes a dot delimiter.
127    pub fn from_dot_separated(path: &'static str) -> Self {
128        Self::from_path(path)
129    }
130
131    /// Create a path containing one atomic segment.
132    ///
133    /// Dots in `segment` are preserved. This is useful for a stable error code
134    /// such as `http.0405`.
135    pub fn from_segment(segment: &'static str) -> Self {
136        let mut path = Self::new();
137        path.push(segment);
138        path
139    }
140
141    /// Path segments in outermost-to-innermost order.
142    pub fn segments(&self) -> &[&'static str] {
143        &self.segments
144    }
145
146    /// Render this path with `delimiter` between segments.
147    pub fn to_string_with(&self, delimiter: &str) -> String {
148        self.segments.join(delimiter)
149    }
150
151    /// Prepend segments separated by [`path_delimiter`], ignoring empty
152    /// segments.
153    pub fn prepend_path(&mut self, path: &'static str) {
154        let prefix = Self::from_path(path);
155        self.segments.splice(0..0, prefix.segments);
156    }
157
158    /// Prepend segments using the configured delimiter.
159    ///
160    /// Prefer [`ErrorPath::prepend_path`]. This compatibility alias no longer
161    /// assumes a dot delimiter.
162    pub fn prepend_dot_separated(&mut self, path: &'static str) {
163        self.prepend_path(path);
164    }
165
166    /// Prepend one atomic segment.
167    ///
168    /// Dots in `segment` are preserved.
169    pub fn prepend(&mut self, segment: &'static str) {
170        if !segment.is_empty() {
171            self.segments.insert(0, segment);
172        }
173    }
174
175    /// Append one atomic segment.
176    ///
177    /// Dots in `segment` are preserved.
178    pub fn push(&mut self, segment: &'static str) {
179        if !segment.is_empty() {
180            self.segments.push(segment);
181        }
182    }
183}
184
185impl fmt::Display for ErrorPath {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        f.write_str(&self.to_string_with(path_delimiter()))
188    }
189}
190
191/// Internal typed context used by adapters that expose downcasting.
192///
193/// The marker holds only segments added through [`WithErrorPath`]. It never
194/// attempts to interpret an adapter's root error or text as an error code.
195#[cfg(any(feature = "anyhow", feature = "eyre"))]
196#[derive(Debug)]
197struct AdapterErrorPath(Mutex<ErrorPath>);
198
199#[cfg(any(feature = "anyhow", feature = "eyre"))]
200impl AdapterErrorPath {
201    fn new(path: &'static str) -> Self {
202        Self(Mutex::new(ErrorPath::from_path(path)))
203    }
204
205    fn prepend(&self, path: &'static str) {
206        self.0
207            .lock()
208            .unwrap_or_else(|poisoned| poisoned.into_inner())
209            .prepend_path(path);
210    }
211
212    fn path(&self) -> ErrorPath {
213        self.0
214            .lock()
215            .unwrap_or_else(|poisoned| poisoned.into_inner())
216            .clone()
217    }
218}
219
220#[cfg(any(feature = "anyhow", feature = "eyre"))]
221impl fmt::Display for AdapterErrorPath {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        self.path().fmt(f)
224    }
225}
226
227#[cfg(feature = "anyhow")]
228impl WithErrorPath for anyhow::Error {
229    fn with_error_path(self, path: &'static str) -> Self {
230        if let Some(error_path) = self.downcast_ref::<AdapterErrorPath>() {
231            error_path.prepend(path);
232            self
233        } else {
234            self.context(AdapterErrorPath::new(path))
235        }
236    }
237}
238
239#[cfg(feature = "anyhow")]
240impl ErrorPathExt for anyhow::Error {
241    fn error_path(&self) -> Option<ErrorPath> {
242        self.downcast_ref::<AdapterErrorPath>()
243            .map(AdapterErrorPath::path)
244    }
245}
246
247#[cfg(feature = "eyre")]
248impl WithErrorPath for eyre::Report {
249    fn with_error_path(self, path: &'static str) -> Self {
250        if let Some(error_path) = self.downcast_ref::<AdapterErrorPath>() {
251            error_path.prepend(path);
252            self
253        } else {
254            self.wrap_err(AdapterErrorPath::new(path))
255        }
256    }
257}
258
259#[cfg(feature = "eyre")]
260impl ErrorPathExt for eyre::Report {
261    fn error_path(&self) -> Option<ErrorPath> {
262        self.downcast_ref::<AdapterErrorPath>()
263            .map(AdapterErrorPath::path)
264    }
265}
266
267/// Typed attachment used by the `error-stack` adapter.
268#[cfg(feature = "error-stack")]
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub struct ErrorPathSegment(pub &'static str);
271
272#[cfg(feature = "error-stack")]
273impl<C> WithErrorPath for error_stack::Report<C> {
274    fn with_error_path(self, path: &'static str) -> Self {
275        self.attach(ErrorPathSegment(path))
276    }
277}
278
279#[cfg(feature = "error-stack")]
280impl<C> ErrorPathExt for error_stack::Report<C> {
281    fn error_path(&self) -> Option<ErrorPath> {
282        let segments: Vec<_> = self
283            .frames()
284            .filter_map(|frame| frame.downcast_ref::<ErrorPathSegment>())
285            .flat_map(|segment| {
286                segment
287                    .0
288                    .split(path_delimiter())
289                    .filter(|part| !part.is_empty())
290            })
291            .collect();
292
293        (!segments.is_empty()).then_some(ErrorPath { segments })
294    }
295}
296
297/// Internal typed error stored in `stacked_errors` frames.
298#[cfg(feature = "stacked-errors")]
299#[derive(Debug)]
300struct StackedErrorPathSegment(&'static str);
301
302#[cfg(feature = "stacked-errors")]
303impl fmt::Display for StackedErrorPathSegment {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        f.write_str(self.0)
306    }
307}
308
309#[cfg(feature = "stacked-errors")]
310impl Error for StackedErrorPathSegment {}
311
312#[cfg(feature = "stacked-errors")]
313impl WithErrorPath for stacked_errors::Error {
314    fn with_error_path(self, path: &'static str) -> Self {
315        self.add_kind_locationless(stacked_errors::ErrorKind::from_err(
316            StackedErrorPathSegment(path),
317        ))
318    }
319}
320
321#[cfg(feature = "stacked-errors")]
322impl ErrorPathExt for stacked_errors::Error {
323    fn error_path(&self) -> Option<ErrorPath> {
324        let segments: Vec<_> = self
325            .stack
326            .iter()
327            .rev()
328            .filter_map(|(kind, _)| match kind {
329                stacked_errors::ErrorKind::BoxedError(error) => {
330                    error.downcast_ref::<StackedErrorPathSegment>()
331                }
332                _ => None,
333            })
334            .flat_map(|segment| {
335                segment
336                    .0
337                    .split(path_delimiter())
338                    .filter(|part| !part.is_empty())
339            })
340            .collect();
341
342        (!segments.is_empty()).then_some(ErrorPath { segments })
343    }
344}
345
346#[cfg(all(
347    test,
348    any(feature = "anyhow", feature = "eyre", feature = "stacked-errors")
349))]
350mod tests {
351    use super::*;
352
353    #[cfg(feature = "anyhow")]
354    #[test]
355    fn anyhow_adapter_adds_path_context() {
356        let err = anyhow::anyhow!("root").with_error_path("service.load");
357
358        assert_eq!(err.to_string(), "service.load");
359    }
360
361    #[cfg(feature = "anyhow")]
362    #[test]
363    fn anyhow_adapter_exposes_only_macro_path() {
364        let err = anyhow::anyhow!("HTTP communication failed")
365            .with_error_path("request_login")
366            .with_error_path("login.api");
367
368        assert_eq!(
369            err.error_path().unwrap().to_string(),
370            "login.api.request_login"
371        );
372    }
373
374    #[cfg(feature = "eyre")]
375    #[test]
376    fn eyre_adapter_adds_path_context() {
377        let err = eyre::eyre!("root").with_error_path("service.load");
378
379        assert_eq!(err.to_string(), "service.load");
380    }
381
382    #[cfg(feature = "eyre")]
383    #[test]
384    fn eyre_adapter_exposes_only_macro_path() {
385        let err = eyre::eyre!("HTTP communication failed")
386            .with_error_path("request_login")
387            .with_error_path("login.api");
388
389        assert_eq!(
390            err.error_path().unwrap().to_string(),
391            "login.api.request_login"
392        );
393    }
394
395    #[cfg(feature = "error-stack")]
396    #[test]
397    fn error_stack_adapter_exposes_only_macro_path() {
398        let err = error_stack::Report::new(std::io::Error::other("HTTP communication failed"))
399            .with_error_path("request_login")
400            .with_error_path("login.api");
401
402        assert_eq!(
403            err.error_path().unwrap().to_string(),
404            "login.api.request_login"
405        );
406    }
407
408    #[cfg(feature = "stacked-errors")]
409    #[test]
410    fn stacked_errors_adapter_adds_path_context() {
411        let err =
412            stacked_errors::Error::from_kind_locationless("root").with_error_path("service.load");
413
414        let rendered = err.to_string();
415        assert!(rendered.contains("root"));
416        assert!(rendered.contains("service.load"));
417    }
418
419    #[cfg(feature = "stacked-errors")]
420    #[test]
421    fn stacked_errors_adapter_exposes_only_macro_path() {
422        let err = stacked_errors::Error::from_kind_locationless("HTTP communication failed")
423            .with_error_path("request_login")
424            .with_error_path("login.api");
425
426        assert_eq!(
427            err.error_path().unwrap().to_string(),
428            "login.api.request_login"
429        );
430    }
431}