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 Display) -> Self {
41        self.attachments.push((key.into(), value.to_string()));
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<D: Display>(
58        mut self,
59        key: impl Into<String>,
60        value: impl FnOnce() -> D,
61    ) -> Self {
62        self.attachments.push((key.into(), value().to_string()));
63        self
64    }
65}
66
67impl<T: StdError + Send + Sync + 'static> From<T> for Report<T> {
68    fn from(error: T) -> Self {
69        Self::new(error)
70    }
71}
72
73impl<T: StdError + Send + Sync + 'static> Debug for Report<T> {
74    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
75        Display::fmt(self, f)?;
76        let mut source = self.source();
77        while let Some(err) = source {
78            write!(f, "\n  Caused by: {err}")?;
79            source = err.source();
80        }
81        Ok(())
82    }
83}
84
85impl<T: Display> Display for Report<T> {
86    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
87        Display::fmt(&self.context, f)?;
88        for (key, value) in &self.attachments {
89            write!(f, "\n▷ {key}: {value}")?;
90        }
91        Ok(())
92    }
93}
94
95impl<T: StdError + Send + Sync + 'static> StdError for Report<T> {
96    #[expect(
97        clippy::as_conversions,
98        reason = "cast from boxed trait object to trait reference"
99    )]
100    fn source(&self) -> Option<&(dyn StdError + 'static)> {
101        self.source
102            .as_ref()
103            .map(|s| s.as_ref() as &(dyn StdError + 'static))
104    }
105}
106
107impl<T: StdError + Send + Sync + 'static> Diagnostic for Report<T> {
108    fn code<'a>(&'a self) -> Option<Box<dyn Display + 'a>> {
109        Some(Box::new(short_code(&self.context)))
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn report_display() {
119        // Arrange
120        let report = Report::new(OuterError::Operation);
121        // Act
122        let display = report.to_string();
123        // Assert
124        assert_snapshot!(display, @"Outer operation failed");
125    }
126
127    #[test]
128    fn report_display__with_attach() {
129        // Arrange
130        let report = Report::new(OuterError::Operation)
131            .attach("path", "/tmp/file.txt")
132            .attach("retries", 3);
133        // Act
134        let display = report.to_string();
135        // Assert
136        assert_snapshot!(display);
137    }
138
139    #[test]
140    fn report_display__with_attach_with() {
141        // Arrange
142        let report = Report::new(OuterError::Operation).attach_with("count", || 42);
143        // Act
144        let display = report.to_string();
145        // Assert
146        assert_snapshot!(display);
147    }
148
149    #[test]
150    fn report_debug() {
151        // Arrange
152        let report = Report::new(OuterError::Operation);
153        // Act
154        let debug = format!("{report:?}");
155        // Assert
156        assert_snapshot!(debug, @"Outer operation failed");
157    }
158
159    #[test]
160    fn report_debug__with_source() {
161        // Arrange
162        let inner = Report::new(InnerError::Operation);
163        let outer = inner.change_context(OuterError::Operation);
164        // Act
165        let debug = format!("{outer:?}");
166        // Assert
167        assert_snapshot!(debug);
168    }
169
170    #[test]
171    fn report_debug__with_additional_and_source() {
172        // Arrange
173        let inner = Report::new(InnerError::Operation).attach("key", "inner_val");
174        let outer = inner
175            .change_context(OuterError::Operation)
176            .attach("key", "outer_val");
177        // Act
178        let debug = format!("{outer:?}");
179        // Assert
180        assert_snapshot!(debug);
181    }
182
183    #[test]
184    fn report_source__none_without_wrapping() {
185        // Arrange
186        let report = Report::new(OuterError::Operation);
187        // Act
188        let source = report.source();
189        // Assert
190        assert!(source.is_none());
191    }
192
193    #[test]
194    fn report_source__set_after_change_context() {
195        // Arrange
196        let inner = Report::new(InnerError::Operation);
197        let outer = inner.change_context(OuterError::Operation);
198        // Act
199        let source = outer.source().expect("should have source");
200        // Assert
201        assert_eq!(source.to_string(), "Inner operation failed");
202    }
203
204    #[test]
205    fn report_change_context__preserves_context() {
206        // Arrange
207        let inner = Report::new(InnerError::Operation);
208        // Act
209        let outer = inner.change_context(OuterError::Operation);
210        // Assert
211        assert_eq!(*outer.current_context(), OuterError::Operation);
212    }
213
214    #[test]
215    fn report_change_context__clears_additional() {
216        // Arrange
217        let inner = Report::new(InnerError::Operation).attach("key", "value");
218        // Act
219        let outer = inner.change_context(OuterError::Operation);
220        // Assert
221        assert!(outer.attachments.is_empty());
222    }
223
224    #[test]
225    fn report_attach_path() {
226        // Arrange
227        let report = Report::new(OuterError::Operation).attach_path("/tmp/data.bin");
228        // Act
229        let display = report.to_string();
230        // Assert
231        assert_snapshot!(display);
232    }
233
234    #[test]
235    fn report_from__converts_error_via_question_mark() {
236        // Arrange
237        fn fallible() -> Result<(), OuterError> {
238            Err(OuterError::Operation)
239        }
240        fn wrapper() -> Result<(), Report<OuterError>> {
241            fallible()?;
242            Ok(())
243        }
244        // Act
245        let report = wrapper().expect_err("should be err");
246        // Assert
247        assert_eq!(*report.current_context(), OuterError::Operation);
248        assert!(report.source().is_none());
249    }
250}