Skip to main content

test_better_core/
structured.rs

1//! The structured (plain-data) form of a [`TestError`].
2//!
3//! [`TestError`] holds borrowed data (`&'static Location`, `Cow<'static, str>`)
4//! and a non-cloneable `Box<dyn Error>` payload, which makes it awkward to
5//! serialize, compare, or send across a process boundary. [`StructuredError`]
6//! is its fully-owned, `PartialEq`, optionally-`serde` mirror.
7//!
8//! This is the form tooling and the runner consume: no consumer ever
9//! recovers structure by parsing rendered text.
10
11use std::panic::Location;
12
13use crate::error::{ErrorKind, Payload, TestError};
14use crate::trace::TraceEntry;
15
16/// A source location, owned and serializable (the plain-data form of
17/// [`std::panic::Location`]).
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub struct SourceLocation {
21    /// Source file path, as reported by the compiler.
22    pub file: String,
23    /// 1-based line number.
24    pub line: u32,
25    /// 1-based column number.
26    pub column: u32,
27}
28
29impl SourceLocation {
30    fn from_std(location: &Location<'_>) -> Self {
31        Self {
32            file: location.file().to_string(),
33            line: location.line(),
34            column: location.column(),
35        }
36    }
37}
38
39/// The plain-data form of [`crate::ContextFrame`].
40#[derive(Debug, Clone, PartialEq, Eq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub struct StructuredContextFrame {
43    /// The "while doing X" description.
44    pub message: String,
45    /// Where the frame was attached, when known.
46    pub location: Option<SourceLocation>,
47}
48
49/// The plain-data form of [`Payload`].
50///
51/// [`Payload::Other`] holds a `Box<dyn Error>`, which cannot be serialized; it
52/// is flattened here into its `Display` string plus its source chain.
53#[derive(Debug, Clone, PartialEq, Eq)]
54#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
55pub enum StructuredPayload {
56    /// Mirrors [`Payload::ExpectedActual`].
57    ExpectedActual {
58        /// `Debug`-rendered expected value.
59        expected: String,
60        /// `Debug`-rendered actual value.
61        actual: String,
62        /// Optional pre-rendered diff between the two.
63        diff: Option<String>,
64    },
65    /// Mirrors [`Payload::Multiple`].
66    Multiple(Vec<StructuredError>),
67    /// Mirrors [`Payload::Other`], flattened to strings.
68    Other {
69        /// `Display` of the wrapped error.
70        message: String,
71        /// `Display` of each error in the wrapped error's source chain.
72        chain: Vec<String>,
73    },
74}
75
76impl StructuredPayload {
77    fn from_payload(payload: &Payload) -> Self {
78        match payload {
79            Payload::ExpectedActual {
80                expected,
81                actual,
82                diff,
83            } => StructuredPayload::ExpectedActual {
84                expected: expected.clone(),
85                actual: actual.clone(),
86                diff: diff.clone(),
87            },
88            Payload::Multiple(errors) => {
89                StructuredPayload::Multiple(errors.iter().map(TestError::to_structured).collect())
90            }
91            Payload::Other(inner) => {
92                let mut chain = Vec::new();
93                let mut source = inner.source();
94                while let Some(current) = source {
95                    chain.push(current.to_string());
96                    source = current.source();
97                }
98                StructuredPayload::Other {
99                    message: inner.to_string(),
100                    chain,
101                }
102            }
103        }
104    }
105}
106
107/// The plain-data, owned, serializable mirror of [`TestError`].
108#[derive(Debug, Clone, PartialEq, Eq)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub struct StructuredError {
111    /// The failure category.
112    pub kind: ErrorKind,
113    /// What failed, when stated concisely.
114    pub message: Option<String>,
115    /// Where the failure originated.
116    pub location: SourceLocation,
117    /// The context chain, outermost first.
118    pub context: Vec<StructuredContextFrame>,
119    /// The in-test breadcrumbs active when the error was built, oldest first.
120    /// [`TraceEntry`] is already plain data, so it is its own structured form.
121    pub trace: Vec<TraceEntry>,
122    /// Structured detail, when applicable.
123    pub payload: Option<StructuredPayload>,
124}
125
126impl TestError {
127    /// Converts this error into its structured, owned, serializable form.
128    ///
129    /// This is the boundary between `test-better` and any tooling that consumes
130    /// failures: tooling reads the structured form, never the rendered text.
131    #[must_use]
132    pub fn to_structured(&self) -> StructuredError {
133        StructuredError {
134            kind: self.kind,
135            message: self.message.as_ref().map(ToString::to_string),
136            location: SourceLocation::from_std(self.location),
137            context: self
138                .context
139                .iter()
140                .map(|frame| StructuredContextFrame {
141                    message: frame.message.to_string(),
142                    location: frame.location.map(SourceLocation::from_std),
143                })
144                .collect(),
145            trace: self.trace.clone(),
146            payload: self.payload.as_deref().map(StructuredPayload::from_payload),
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::error::ContextFrame;
155    use crate::{OrFail, TestResult, Trace};
156    use test_better_matchers::{eq, expect, is_true};
157
158    fn all_kinds() -> [ErrorKind; 6] {
159        [
160            ErrorKind::Assertion,
161            ErrorKind::Setup,
162            ErrorKind::Timeout,
163            ErrorKind::Snapshot,
164            ErrorKind::Property,
165            ErrorKind::Custom,
166        ]
167    }
168
169    #[test]
170    fn every_kind_round_trips_through_structured() -> TestResult {
171        for kind in all_kinds() {
172            let error = TestError::new(kind).with_message("boom");
173            let structured = error.to_structured();
174            expect!(structured.kind).to(eq(kind)).or_fail()?;
175            expect!(structured.message.as_deref())
176                .to(eq(Some("boom")))
177                .or_fail()?;
178        }
179        Ok(())
180    }
181
182    #[test]
183    fn structured_captures_location_and_context() -> TestResult {
184        let error =
185            TestError::new(ErrorKind::Assertion).with_context_frame(ContextFrame::new("step one"));
186        let structured = error.to_structured();
187        expect!(structured.context.len()).to(eq(1)).or_fail()?;
188        expect!(structured.context[0].message.as_str())
189            .to(eq("step one"))
190            .or_fail()?;
191        expect!(structured.location.file.ends_with("structured.rs"))
192            .to(is_true())
193            .or_fail()?;
194        expect!(structured.location.line > 0)
195            .to(is_true())
196            .or_fail()?;
197        Ok(())
198    }
199
200    #[test]
201    fn structured_carries_the_trace() -> TestResult {
202        let mut trace = Trace::new();
203        trace.step("step one");
204        trace.kv("answer", 42);
205        let error = TestError::new(ErrorKind::Assertion);
206        drop(trace);
207
208        let structured = error.to_structured();
209        expect!(structured.trace.len()).to(eq(2)).or_fail()?;
210        expect!(structured.trace[0].clone())
211            .to(eq(TraceEntry::Step("step one".into())))
212            .or_fail()?;
213        Ok(())
214    }
215
216    #[test]
217    fn expected_actual_payload_round_trips() -> TestResult {
218        let error = TestError::new(ErrorKind::Assertion).with_payload(Payload::ExpectedActual {
219            expected: "1".to_string(),
220            actual: "2".to_string(),
221            diff: Some("- 1\n+ 2".to_string()),
222        });
223        match error.to_structured().payload {
224            Some(StructuredPayload::ExpectedActual {
225                expected,
226                actual,
227                diff,
228            }) => {
229                expect!(expected).to(eq("1".to_string())).or_fail()?;
230                expect!(actual).to(eq("2".to_string())).or_fail()?;
231                expect!(diff.as_deref())
232                    .to(eq(Some("- 1\n+ 2")))
233                    .or_fail()?;
234            }
235            other => panic!("expected ExpectedActual, got {other:?}"),
236        }
237        Ok(())
238    }
239
240    #[test]
241    fn multiple_payload_round_trips_recursively() -> TestResult {
242        let error = TestError::new(ErrorKind::Assertion).with_payload(Payload::Multiple(vec![
243            TestError::new(ErrorKind::Assertion).with_message("a"),
244            TestError::new(ErrorKind::Setup).with_message("b"),
245        ]));
246        match error.to_structured().payload {
247            Some(StructuredPayload::Multiple(subs)) => {
248                expect!(subs.len()).to(eq(2)).or_fail()?;
249                expect!(subs[0].message.as_deref())
250                    .to(eq(Some("a")))
251                    .or_fail()?;
252                expect!(subs[1].kind).to(eq(ErrorKind::Setup)).or_fail()?;
253            }
254            other => panic!("expected Multiple, got {other:?}"),
255        }
256        Ok(())
257    }
258
259    #[test]
260    fn other_payload_flattens_error_chain() -> TestResult {
261        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
262        let error = TestError::new(ErrorKind::Custom).with_payload(Payload::Other(Box::new(io)));
263        match error.to_structured().payload {
264            Some(StructuredPayload::Other { message, chain }) => {
265                expect!(message).to(eq("missing".to_string())).or_fail()?;
266                expect!(chain.is_empty()).to(is_true()).or_fail()?;
267            }
268            other => panic!("expected Other, got {other:?}"),
269        }
270        Ok(())
271    }
272
273    #[cfg(feature = "serde")]
274    #[test]
275    fn structured_error_json_round_trips() -> TestResult {
276        let error = TestError::new(ErrorKind::Property)
277            .with_message("shrunk input failed")
278            .with_context_frame(ContextFrame::new("checking the round-trip property"))
279            .with_payload(Payload::ExpectedActual {
280                expected: "Ok(\"x\")".to_string(),
281                actual: "Err(..)".to_string(),
282                diff: None,
283            });
284        let structured = error.to_structured();
285        let json = serde_json::to_string(&structured).or_fail_with("serialize")?;
286        let back: StructuredError = serde_json::from_str(&json).or_fail_with("deserialize")?;
287        expect!(structured).to(eq(back)).or_fail()?;
288        Ok(())
289    }
290}