Skip to main content

studiole_report/
report.rs

1//! Error report with diagnostic rendering.
2use crate::prelude::*;
3
4/// Error report that wraps a typed context with an optional source chain.
5pub struct Report<T> {
6    /// Typed error context.
7    pub(crate) context: T,
8    /// Key-value pairs of additional context.
9    pub(crate) attachments: Vec<(String, String)>,
10    /// Underlying error in the source chain.
11    pub(crate) source: Option<Box<dyn StdError + Send + Sync>>,
12}
13
14impl<T: StdError + Send + Sync + 'static> Report<T> {
15    /// Create a report from the given error context with no source.
16    pub fn new(context: T) -> Self {
17        Self {
18            context,
19            attachments: Vec::new(),
20            source: None,
21        }
22    }
23
24    /// The typed context stored in this report.
25    pub fn current_context(&self) -> &T {
26        &self.context
27    }
28
29    /// Wrap this report as the source of a new context.
30    pub fn change_context<U: StdError + Send + Sync + 'static>(self, new_context: U) -> Report<U> {
31        Report {
32            context: new_context,
33            attachments: Vec::new(),
34            source: Some(Box::new(self)),
35        }
36    }
37
38    /// Add a key-value pair of additional context.
39    #[must_use]
40    pub fn attach(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
41        self.attachments.push((key.into(), value.into()));
42        self
43    }
44
45    /// Add a path as additional context under the key `"path"`.
46    #[must_use]
47    pub fn attach_path(mut self, value: impl AsRef<Path>) -> Self {
48        self.attachments.push((
49            "path".to_owned(),
50            value.as_ref().to_string_lossy().to_string(),
51        ));
52        self
53    }
54
55    /// Add a key-value pair of additional context with a lazily evaluated value.
56    #[must_use]
57    pub fn attach_with(mut self, key: impl Into<String>, value: impl FnOnce() -> String) -> Self {
58        self.attachments.push((key.into(), value()));
59        self
60    }
61}
62
63impl<T: StdError + Send + Sync + 'static> Debug for Report<T> {
64    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
65        Display::fmt(self, f)?;
66        let mut source = self.source();
67        while let Some(err) = source {
68            write!(f, "\n  Caused by: {err}")?;
69            source = err.source();
70        }
71        Ok(())
72    }
73}
74
75impl<T: Display> Display for Report<T> {
76    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
77        Display::fmt(&self.context, f)?;
78        for (key, value) in &self.attachments {
79            write!(f, "\n▷ {key}: {value}")?;
80        }
81        Ok(())
82    }
83}
84
85impl<T: StdError + Send + Sync + 'static> StdError for Report<T> {
86    #[expect(
87        clippy::as_conversions,
88        reason = "cast from boxed trait object to trait reference"
89    )]
90    fn source(&self) -> Option<&(dyn StdError + 'static)> {
91        self.source
92            .as_ref()
93            .map(|s| s.as_ref() as &(dyn StdError + 'static))
94    }
95}
96
97impl<T: StdError + Send + Sync + 'static> Diagnostic for Report<T> {
98    fn code<'a>(&'a self) -> Option<Box<dyn Display + 'a>> {
99        Some(Box::new(short_code(&self.context)))
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn report_display() {
109        // Arrange
110        let report = Report::new(OuterError::Operation);
111        // Act
112        let display = report.to_string();
113        // Assert
114        assert_snapshot!(display, @"Outer operation failed");
115    }
116
117    #[test]
118    fn report_display__with_attach() {
119        // Arrange
120        let report = Report::new(OuterError::Operation)
121            .attach("path", "/tmp/file.txt")
122            .attach("retries", "3");
123        // Act
124        let display = report.to_string();
125        // Assert
126        assert_snapshot!(display);
127    }
128
129    #[test]
130    fn report_display__with_attach_with() {
131        // Arrange
132        let report = Report::new(OuterError::Operation).attach_with("count", || String::from("42"));
133        // Act
134        let display = report.to_string();
135        // Assert
136        assert_snapshot!(display);
137    }
138
139    #[test]
140    fn report_debug() {
141        // Arrange
142        let report = Report::new(OuterError::Operation);
143        // Act
144        let debug = format!("{report:?}");
145        // Assert
146        assert_snapshot!(debug, @"Outer operation failed");
147    }
148
149    #[test]
150    fn report_debug__with_source() {
151        // Arrange
152        let inner = Report::new(InnerError::Operation);
153        let outer = inner.change_context(OuterError::Operation);
154        // Act
155        let debug = format!("{outer:?}");
156        // Assert
157        assert_snapshot!(debug);
158    }
159
160    #[test]
161    fn report_debug__with_additional_and_source() {
162        // Arrange
163        let inner = Report::new(InnerError::Operation).attach("key", "inner_val");
164        let outer = inner
165            .change_context(OuterError::Operation)
166            .attach("key", "outer_val");
167        // Act
168        let debug = format!("{outer:?}");
169        // Assert
170        assert_snapshot!(debug);
171    }
172
173    #[test]
174    fn report_source__none_without_wrapping() {
175        // Arrange
176        let report = Report::new(OuterError::Operation);
177        // Act
178        let source = report.source();
179        // Assert
180        assert!(source.is_none());
181    }
182
183    #[test]
184    fn report_source__set_after_change_context() {
185        // Arrange
186        let inner = Report::new(InnerError::Operation);
187        let outer = inner.change_context(OuterError::Operation);
188        // Act
189        let source = outer.source().expect("should have source");
190        // Assert
191        assert_eq!(source.to_string(), "Inner operation failed");
192    }
193
194    #[test]
195    fn report_change_context__preserves_context() {
196        // Arrange
197        let inner = Report::new(InnerError::Operation);
198        // Act
199        let outer = inner.change_context(OuterError::Operation);
200        // Assert
201        assert_eq!(*outer.current_context(), OuterError::Operation);
202    }
203
204    #[test]
205    fn report_change_context__clears_additional() {
206        // Arrange
207        let inner = Report::new(InnerError::Operation).attach("key", "value");
208        // Act
209        let outer = inner.change_context(OuterError::Operation);
210        // Assert
211        assert!(outer.attachments.is_empty());
212    }
213
214    #[test]
215    fn report_attach_path() {
216        // Arrange
217        let report = Report::new(OuterError::Operation).attach_path("/tmp/data.bin");
218        // Act
219        let display = report.to_string();
220        // Assert
221        assert_snapshot!(display);
222    }
223}