Skip to main content

oopsie_core/erased/
mod.rs

1//! Serializable, type-erased error representations (the `serde` feature).
2mod backtrace;
3pub use backtrace::{ErasedBacktrace, ErasedFrame};
4
5mod spantrace;
6pub use spantrace::{ErasedMetadata, ErasedSpan, ErasedSpanTrace, TracingLevel};
7
8use alloc::boxed::Box;
9use alloc::string::{String, ToString};
10use alloc::vec::Vec;
11use core::fmt;
12use core::num::NonZeroU8;
13#[cfg(feature = "std")]
14use std::io;
15#[cfg(feature = "std")]
16use std::sync::OnceLock;
17
18use serde::{Deserialize, Serialize};
19
20use crate::{ErrorCode, HelpText};
21
22/// Upper bound on real source entries stored by `from_error_ref`; a sentinel
23/// entry is appended when the walk would exceed this limit.
24const MAX_SOURCE_CHAIN_DEPTH: usize = 128;
25
26/// A serializable, cloneable error representation that preserves the full
27/// error context including backtrace, spantrace, and source chain.
28///
29/// This type is designed for API error responses where the original error
30/// cannot be directly serialized. Construct one with [`from_error`] or
31/// [`from_error_ref`], or by deserializing a transported payload; read its
32/// contents through the accessor methods.
33///
34/// The captured backtrace, span trace, and caller location are exposed via
35/// [`backtrace`], [`spantrace`], and [`location`] as [`ErasedBacktrace`] /
36/// [`ErasedSpanTrace`] / [`ErasedLocation`] snapshots. They are *not* surfaced
37/// through the [`Diagnostic`] impl: those accessors return references to live
38/// [`Backtrace`]/[`SpanTrace`] values and a `&'static` [`Location`], which a
39/// transported snapshot cannot reconstruct. Re-erasing an `ErasedError` (via
40/// [`from_error_ref`]) preserves the message, source chain, code, help, and exit
41/// code, but the trace and location snapshots stay reachable only through this
42/// type's own accessors.
43///
44/// [`from_error`]: ErasedError::from_error
45/// [`from_error_ref`]: ErasedError::from_error_ref
46/// [`backtrace`]: ErasedError::backtrace
47/// [`spantrace`]: ErasedError::spantrace
48/// [`location`]: ErasedError::location
49/// [`Diagnostic`]: crate::Diagnostic
50/// [`Backtrace`]: crate::Backtrace
51/// [`SpanTrace`]: crate::SpanTrace
52/// [`Location`]: std::panic::Location
53#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
54#[non_exhaustive]
55pub struct ErasedError {
56    message: Box<str>,
57
58    #[serde(default)]
59    source_chain: Vec<Box<str>>,
60
61    #[serde(default)]
62    diagnostics: Diagnostics,
63
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    location: Option<ErasedLocation>,
66
67    spantrace: Option<ErasedSpanTrace>,
68
69    backtrace: Option<ErasedBacktrace>,
70
71    /// Source-chain re-materialization, populated lazily on the first
72    /// `Error::source()` call. `source_chain` is immutable after construction,
73    /// so this snapshot can never go stale.
74    #[cfg(feature = "std")]
75    #[serde(skip)]
76    source: OnceLock<Option<Box<ChainNode>>>,
77}
78
79impl core::error::Error for ErasedError {
80    #[cfg(feature = "std")]
81    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
82        self.source
83            .get_or_init(|| ChainNode::build(&self.source_chain))
84            .as_deref()
85            .map(|node| node as &(dyn core::error::Error + 'static))
86    }
87
88    #[cfg(not(feature = "std"))]
89    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
90        None
91    }
92}
93
94/// One transported cause, re-materialized as a real error value so generic
95/// `Error::source()` walkers see the chain instead of bare data.
96#[cfg(feature = "std")]
97#[derive(Clone, Debug)]
98struct ChainNode {
99    message: Box<str>,
100    source: Option<Box<Self>>,
101}
102
103#[cfg(feature = "std")]
104impl ChainNode {
105    fn build(messages: &[Box<str>]) -> Option<Box<Self>> {
106        messages.iter().rev().fold(None, |source, message| {
107            Some(Box::new(Self {
108                message: message.clone(),
109                source,
110            }))
111        })
112    }
113}
114
115#[cfg(feature = "std")]
116impl fmt::Display for ChainNode {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.write_str(&self.message)
119    }
120}
121
122#[cfg(feature = "std")]
123impl core::error::Error for ChainNode {
124    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
125        self.source
126            .as_deref()
127            .map(|node| node as &(dyn core::error::Error + 'static))
128    }
129}
130
131/// Transported diagnostic metadata (error code, help text, and exit code).
132#[derive(Clone, Debug, Default, Serialize, Deserialize)]
133#[non_exhaustive]
134pub struct Diagnostics {
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    code: Option<ErrorCode>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    help: Option<HelpText>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    exit_code: Option<NonZeroU8>,
141}
142
143/// Transported caller-location snapshot: the `file:line:column` of the call
144/// site where the error was built, captured from `Diagnostic::oopsie_location`.
145#[derive(Clone, Debug, Serialize, Deserialize)]
146#[non_exhaustive]
147pub struct ErasedLocation {
148    file: Box<str>,
149    line: u32,
150    column: u32,
151}
152
153impl ErasedLocation {
154    /// The source file of the call site.
155    #[must_use]
156    #[inline]
157    pub fn file(&self) -> &str {
158        &self.file
159    }
160
161    /// The line within the source file.
162    #[must_use]
163    #[inline]
164    pub const fn line(&self) -> u32 {
165        self.line
166    }
167
168    /// The column within the line.
169    #[must_use]
170    #[inline]
171    pub const fn column(&self) -> u32 {
172        self.column
173    }
174}
175
176impl fmt::Display for ErasedLocation {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        write!(f, "{}:{}:{}", self.file, self.line, self.column)
179    }
180}
181
182impl From<&'static core::panic::Location<'static>> for ErasedLocation {
183    fn from(location: &'static core::panic::Location<'static>) -> Self {
184        Self {
185            file: location.file().into(),
186            line: location.line(),
187            column: location.column(),
188        }
189    }
190}
191
192impl Diagnostics {
193    /// Whether no diagnostic metadata was transported (no code, help, or exit code).
194    #[must_use]
195    #[inline]
196    pub const fn is_none(&self) -> bool {
197        self.code.is_none() && self.help.is_none() && self.exit_code.is_none()
198    }
199    /// The error code, if one was transported.
200    #[must_use]
201    #[inline]
202    pub fn code(&self) -> Option<&str> {
203        self.code.as_deref()
204    }
205    /// The help text, if one was transported.
206    #[must_use]
207    #[inline]
208    pub fn help(&self) -> Option<&str> {
209        self.help.as_deref()
210    }
211    /// The process exit code, if one was transported.
212    #[must_use]
213    #[inline]
214    pub const fn exit_code(&self) -> Option<NonZeroU8> {
215        self.exit_code
216    }
217}
218
219// ─────────────────────────────────────────────────────────────────────────────
220// ErasedError construction
221// ─────────────────────────────────────────────────────────────────────────────
222
223impl ErasedError {
224    /// Create an `ErasedError` from any error implementing `Diagnostic`.
225    ///
226    /// This extracts the message, source chain, backtrace, spantrace,
227    /// error code, and help text via the `Diagnostic` trait.
228    #[expect(
229        clippy::needless_pass_by_value,
230        reason = "owned input mirrors the from_error_ref ergonomics"
231    )]
232    pub fn from_error<E: crate::Diagnostic>(err: E) -> Self {
233        Self::from_error_ref(&err)
234    }
235
236    /// Create an `ErasedError` from a reference to any error implementing `Diagnostic`.
237    ///
238    /// Like `from_error` but takes a reference, useful when ownership cannot be transferred
239    /// (e.g., in `Serialize` implementations).
240    pub fn from_error_ref<E: crate::Diagnostic>(err: &E) -> Self {
241        let message = err.to_string().into();
242
243        // `Error::source` is user-implemented and may form a cycle (returning
244        // `self` or an ancestor); the std contract does not forbid it. Cap the
245        // eager walk so a foreign cyclic chain can't hang or OOM this
246        // serialization entry point.
247        let mut source_chain: Vec<Box<str>> = core::iter::successors(err.source(), |e| e.source())
248            .take(MAX_SOURCE_CHAIN_DEPTH + 1)
249            .map(ToString::to_string)
250            .map(Box::from)
251            .collect();
252        if source_chain.len() > MAX_SOURCE_CHAIN_DEPTH {
253            source_chain.truncate(MAX_SOURCE_CHAIN_DEPTH);
254            source_chain.push("\u{2026} source chain truncated".into());
255        }
256
257        let diagnostics = Diagnostics {
258            code: err.oopsie_error_code(),
259            help: err.oopsie_help_text(),
260            exit_code: err.oopsie_exit_code(),
261        };
262        let location = err.oopsie_location().map(ErasedLocation::from);
263        #[cfg(feature = "tracing")]
264        let spantrace = err
265            .oopsie_spantrace()
266            .filter(|st| st.is_captured())
267            .map(ErasedSpanTrace::from);
268        #[cfg(not(feature = "tracing"))]
269        let spantrace: Option<ErasedSpanTrace> = None;
270        // Omit a captured-but-empty backtrace: with no frames there is nothing
271        // to render, only a bare header.
272        #[cfg(feature = "std")]
273        let backtrace = err
274            .oopsie_backtrace()
275            .map(ErasedBacktrace::from_backtrace)
276            .filter(|bt| !bt.frames().is_empty());
277        #[cfg(not(feature = "std"))]
278        let backtrace: Option<ErasedBacktrace> = None;
279
280        Self {
281            message,
282            source_chain,
283            diagnostics,
284            location,
285            spantrace,
286            backtrace,
287            #[cfg(feature = "std")]
288            source: OnceLock::new(),
289        }
290    }
291
292    /// The primary error message (the original error's `Display`).
293    #[must_use]
294    #[inline]
295    pub fn message(&self) -> &str {
296        &self.message
297    }
298
299    /// The `Display` of each transported cause, outermost first.
300    #[must_use]
301    #[inline]
302    pub fn source_chain(&self) -> &[Box<str>] {
303        &self.source_chain
304    }
305
306    /// The transported diagnostic metadata (error code and help text).
307    #[must_use]
308    #[inline]
309    pub const fn diagnostics(&self) -> &Diagnostics {
310        &self.diagnostics
311    }
312
313    /// The captured caller-location snapshot, if one was transported.
314    #[must_use]
315    #[inline]
316    pub const fn location(&self) -> Option<&ErasedLocation> {
317        self.location.as_ref()
318    }
319
320    /// The captured span trace snapshot, if one was transported.
321    #[must_use]
322    #[inline]
323    pub const fn spantrace(&self) -> Option<&ErasedSpanTrace> {
324        self.spantrace.as_ref()
325    }
326
327    /// The captured backtrace snapshot, if one was transported.
328    #[must_use]
329    #[inline]
330    pub const fn backtrace(&self) -> Option<&ErasedBacktrace> {
331        self.backtrace.as_ref()
332    }
333
334    /// Serialize the error as pretty-printed JSON to a writer.
335    ///
336    /// # Errors
337    ///
338    /// Returns the serialization error, including any underlying I/O failure
339    /// from the writer.
340    #[cfg(feature = "std")]
341    pub fn write_json<W: io::Write>(&self, f: &mut W) -> Result<(), serde_json::Error> {
342        use io::Write as _;
343        let mut buf = io::BufWriter::new(f);
344        serde_json::to_writer_pretty(&mut buf, self)?;
345        buf.flush().map_err(serde_json::Error::io)?;
346        Ok(())
347    }
348
349    /// Write the error in a text format similar to `Report`.
350    #[cfg(feature = "std")]
351    pub fn write_text<W: io::Write>(&self, f: &mut W) -> io::Result<()> {
352        f.write_all(self.to_text().as_bytes())
353    }
354
355    /// Renders the same text format as [`write_text`](Self::write_text) into a
356    /// `fmt::Write` sink, so both the `io::Write`-based writer and the no_std
357    /// `to_text`/`String` path share one implementation.
358    fn render_text(&self, f: &mut impl fmt::Write) -> fmt::Result {
359        // Write main error header
360        match self.diagnostics.code() {
361            Some(code) => writeln!(f, "Error[{code}]:")?,
362            None => writeln!(f, "Error:")?,
363        }
364
365        writeln!(f, "\n  \u{00d7} {}", self.message)?;
366
367        if let Some(location) = &self.location {
368            writeln!(f, "  at {location}")?;
369        }
370
371        // Write source chain with box-drawing characters
372        let chain_len = self.source_chain.len();
373        for (i, cause) in self.source_chain.iter().enumerate() {
374            let arrow = if i < chain_len - 1 {
375                "\u{251c}\u{2500}\u{25b6}"
376            } else {
377                "\u{2570}\u{2500}\u{25b6}"
378            };
379            write!(f, "  {arrow} ")?;
380            writeln!(f, "{cause}")?;
381        }
382
383        // Write help text if present
384        if let Some(help) = self.diagnostics.help() {
385            write!(f, "\n  help: ")?;
386            writeln!(f, "{help}")?;
387        }
388
389        // An empty trace renders as a lone banner with no body; empty traces
390        // are reachable directly from the wire format, so gate here too.
391        if let Some(spantrace) = &self.spantrace
392            && !spantrace.is_empty()
393        {
394            writeln!(f)?;
395            writeln!(f, "{:━^80}", " SPANTRACE ")?;
396            writeln!(f, "{spantrace}")?;
397        }
398
399        if let Some(backtrace) = &self.backtrace
400            && !backtrace.frames().is_empty()
401        {
402            writeln!(f)?;
403            writeln!(f, "{:━^80}", " BACKTRACE ")?;
404            write!(f, "{backtrace}")?;
405        }
406
407        Ok(())
408    }
409
410    /// The full multi-line text report (header, source chain, help, traces)
411    /// as a `String`. `Display` intentionally prints only the message so an
412    /// `ErasedError` embeds cleanly in another error's source chain.
413    #[must_use]
414    #[expect(clippy::missing_panics_doc, reason = "writing to a String cannot fail")]
415    pub fn to_text(&self) -> String {
416        let mut out = String::new();
417        self.render_text(&mut out)
418            .expect("String writes are infallible");
419        out
420    }
421
422    /// Format the error as a short string without backtrace or spantrace.
423    #[must_use]
424    pub fn format_short(&self) -> String {
425        use core::fmt::Write as _;
426        let mut out = String::new();
427
428        // Code
429        if let Some(code) = self.diagnostics.code() {
430            let _ = writeln!(out, "{code}");
431            let _ = writeln!(out);
432        }
433
434        // Message
435        let _ = write!(out, "  \u{00d7} {}", self.message);
436
437        // Location
438        if let Some(location) = &self.location {
439            let _ = write!(out, "\n  at {location}");
440        }
441
442        // Source chain
443        let chain_len = self.source_chain.len();
444        for (i, cause) in self.source_chain.iter().enumerate() {
445            let arrow = if i < chain_len - 1 {
446                "\u{251c}\u{2500}\u{25b6}"
447            } else {
448                "\u{2570}\u{2500}\u{25b6}"
449            };
450            let _ = write!(out, "\n  {arrow} {cause}");
451        }
452
453        // Help
454        if let Some(help) = self.diagnostics.help() {
455            let _ = write!(out, "\n  help: {help}");
456        }
457
458        let _ = writeln!(out);
459        out
460    }
461}
462
463impl fmt::Display for ErasedError {
464    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
465        f.write_str(&self.message)
466    }
467}
468
469impl crate::Diagnostic for ErasedError {
470    fn oopsie_error_code(&self) -> Option<ErrorCode> {
471        self.diagnostics.code.clone()
472    }
473
474    fn oopsie_help_text(&self) -> Option<HelpText> {
475        self.diagnostics.help.clone()
476    }
477
478    fn oopsie_exit_code(&self) -> Option<NonZeroU8> {
479        self.diagnostics.exit_code
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn test_diagnostics_struct() {
489        let empty = Diagnostics::default();
490        assert!(empty.is_none());
491        assert_eq!(empty.code(), None);
492        assert_eq!(empty.help(), None);
493
494        let with_both = Diagnostics {
495            code: Some("test::code".into()),
496            help: Some("try this".into()),
497            exit_code: None,
498        };
499        assert!(!with_both.is_none());
500        assert_eq!(with_both.code(), Some("test::code"));
501        assert_eq!(with_both.help(), Some("try this"));
502
503        let code_only = Diagnostics {
504            code: Some("test::code".into()),
505            help: None,
506            exit_code: None,
507        };
508        assert!(!code_only.is_none());
509        assert_eq!(code_only.help(), None);
510    }
511
512    // ─────────────────────────────────────────────────────────────────────
513    // Tests for from_error / from_error_ref
514    // ─────────────────────────────────────────────────────────────────────
515
516    /// A simple chained error type for testing from_error / from_error_ref.
517    #[derive(Debug)]
518    struct ChainedError {
519        msg: &'static str,
520        source: Option<Box<Self>>,
521    }
522
523    impl fmt::Display for ChainedError {
524        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
525            f.write_str(self.msg)
526        }
527    }
528
529    impl std::error::Error for ChainedError {
530        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
531            self.source
532                .as_ref()
533                .map(|s| s.as_ref() as &dyn std::error::Error)
534        }
535    }
536
537    impl crate::Diagnostic for ChainedError {}
538
539    #[test]
540    fn test_from_error_preserves_message_and_chain() {
541        let error = ChainedError {
542            msg: "outer error",
543            source: Some(Box::new(ChainedError {
544                msg: "inner cause",
545                source: None,
546            })),
547        };
548
549        let erased = ErasedError::from_error(error);
550        assert_eq!(&*erased.message, "outer error");
551        assert_eq!(erased.source_chain.len(), 1);
552        assert_eq!(&*erased.source_chain[0], "inner cause");
553    }
554
555    #[test]
556    fn location_is_captured_and_survives_round_trip() {
557        #[derive(Debug)]
558        struct WithLoc(&'static std::panic::Location<'static>);
559        impl fmt::Display for WithLoc {
560            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
561                f.write_str("located")
562            }
563        }
564        impl std::error::Error for WithLoc {}
565        impl crate::Diagnostic for WithLoc {
566            fn oopsie_location(&self) -> Option<&'static std::panic::Location<'static>> {
567                Some(self.0)
568            }
569        }
570
571        let here = std::panic::Location::caller();
572        let erased = ErasedError::from_error(WithLoc(here));
573        let loc = erased.location().expect("location captured");
574        assert_eq!(loc.file(), here.file());
575        assert_eq!(loc.line(), here.line());
576        assert_eq!(loc.column(), here.column());
577
578        let json = serde_json::to_string(&erased).unwrap();
579        let restored: ErasedError = serde_json::from_str(&json).unwrap();
580        let restored_loc = restored.location().expect("location survives round-trip");
581        assert_eq!(restored_loc.file(), here.file());
582        assert_eq!(restored_loc.line(), here.line());
583        assert_eq!(restored_loc.column(), here.column());
584
585        // A plain error has no location.
586        let plain = ErasedError::from_error(ChainedError {
587            msg: "plain",
588            source: None,
589        });
590        assert!(plain.location().is_none());
591    }
592
593    #[test]
594    fn test_from_error_ref_preserves_message_and_chain() {
595        let error = ChainedError {
596            msg: "outer error",
597            source: Some(Box::new(ChainedError {
598                msg: "inner cause",
599                source: None,
600            })),
601        };
602
603        let erased = ErasedError::from_error_ref(&error);
604        assert_eq!(&*erased.message, "outer error");
605        assert_eq!(erased.source_chain.len(), 1);
606        assert_eq!(&*erased.source_chain[0], "inner cause");
607    }
608
609    // ─────────────────────────────────────────────────────────────────────
610    // Tests for write_json
611    // ─────────────────────────────────────────────────────────────────────
612
613    #[test]
614    fn test_write_json_produces_valid_json() {
615        let erased = ErasedError {
616            message: "something broke".into(),
617            source_chain: vec!["inner cause".into()],
618            diagnostics: Diagnostics::default(),
619            location: None,
620            spantrace: None,
621            backtrace: None,
622            source: OnceLock::new(),
623        };
624
625        let mut buf = Vec::new();
626        erased.write_json(&mut buf).unwrap();
627        assert!(!buf.is_empty(), "write_json must produce output");
628
629        let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
630        assert_eq!(json["message"], "something broke");
631        assert_eq!(json["source_chain"][0], "inner cause");
632    }
633
634    struct FailingWriter;
635    impl io::Write for FailingWriter {
636        fn write(&mut self, _: &[u8]) -> io::Result<usize> {
637            Err(io::Error::new(io::ErrorKind::BrokenPipe, "peer gone"))
638        }
639        fn flush(&mut self) -> io::Result<()> {
640            Err(io::Error::new(io::ErrorKind::BrokenPipe, "peer gone"))
641        }
642    }
643
644    #[test]
645    fn write_json_surfaces_io_errors() {
646        let erased = ErasedError {
647            message: "x".into(),
648            source_chain: vec![],
649            diagnostics: Diagnostics::default(),
650            location: None,
651            spantrace: None,
652            backtrace: None,
653            source: OnceLock::new(),
654        };
655        let err = erased.write_json(&mut FailingWriter).unwrap_err();
656        assert!(err.is_io(), "{err}");
657    }
658
659    // ─────────────────────────────────────────────────────────────────────
660    // Tests for write_text arrow logic
661    // ─────────────────────────────────────────────────────────────────────
662
663    #[test]
664    fn test_write_text_single_cause_uses_last_arrow() {
665        let erased = ErasedError {
666            message: "top".into(),
667            source_chain: vec!["only cause".into()],
668            diagnostics: Diagnostics::default(),
669            location: None,
670            spantrace: None,
671            backtrace: None,
672            source: OnceLock::new(),
673        };
674
675        let mut buf = Vec::new();
676        erased.write_text(&mut buf).unwrap();
677        let output = String::from_utf8(buf).unwrap();
678
679        assert!(
680            output.contains("\u{2570}\u{2500}\u{25b6}"),
681            "single cause should use last-arrow"
682        );
683        assert!(
684            !output.contains("\u{251c}\u{2500}\u{25b6}"),
685            "single cause should NOT use middle-arrow"
686        );
687    }
688
689    #[test]
690    fn test_write_text_two_causes_arrows() {
691        let erased = ErasedError {
692            message: "top".into(),
693            source_chain: vec!["middle".into(), "root".into()],
694            diagnostics: Diagnostics::default(),
695            location: None,
696            spantrace: None,
697            backtrace: None,
698            source: OnceLock::new(),
699        };
700
701        let mut buf = Vec::new();
702        erased.write_text(&mut buf).unwrap();
703        let output = String::from_utf8(buf).unwrap();
704
705        assert!(
706            output.contains("\u{251c}\u{2500}\u{25b6}"),
707            "first of two causes should use middle-arrow"
708        );
709        assert!(
710            output.contains("\u{2570}\u{2500}\u{25b6}"),
711            "last cause should use last-arrow"
712        );
713    }
714
715    #[test]
716    fn test_write_text_three_causes_arrows() {
717        let erased = ErasedError {
718            message: "top".into(),
719            source_chain: vec!["first".into(), "second".into(), "third".into()],
720            diagnostics: Diagnostics::default(),
721            location: None,
722            spantrace: None,
723            backtrace: None,
724            source: OnceLock::new(),
725        };
726
727        let mut buf = Vec::new();
728        erased.write_text(&mut buf).unwrap();
729        let output = String::from_utf8(buf).unwrap();
730
731        // Count occurrences of each arrow
732        let middle_count = output.matches("\u{251c}\u{2500}\u{25b6}").count();
733        let last_count = output.matches("\u{2570}\u{2500}\u{25b6}").count();
734        assert_eq!(middle_count, 2, "first two causes should use middle-arrow");
735        assert_eq!(last_count, 1, "only last cause should use last-arrow");
736    }
737
738    // ─────────────────────────────────────────────────────────────────────
739    // Tests for format_short arrow logic
740    // ─────────────────────────────────────────────────────────────────────
741
742    #[test]
743    fn test_format_short_single_cause_uses_last_arrow() {
744        let erased = ErasedError {
745            message: "top".into(),
746            source_chain: vec!["only cause".into()],
747            diagnostics: Diagnostics::default(),
748            location: None,
749            spantrace: None,
750            backtrace: None,
751            source: OnceLock::new(),
752        };
753
754        let short = erased.format_short();
755        assert!(
756            short.contains("\u{2570}\u{2500}\u{25b6}"),
757            "single cause should use last-arrow"
758        );
759        assert!(
760            !short.contains("\u{251c}\u{2500}\u{25b6}"),
761            "single cause should NOT use middle-arrow"
762        );
763    }
764
765    #[test]
766    fn test_format_short_two_causes_arrows() {
767        let erased = ErasedError {
768            message: "top".into(),
769            source_chain: vec!["middle".into(), "root".into()],
770            diagnostics: Diagnostics::default(),
771            location: None,
772            spantrace: None,
773            backtrace: None,
774            source: OnceLock::new(),
775        };
776
777        let short = erased.format_short();
778        assert!(
779            short.contains("\u{251c}\u{2500}\u{25b6}"),
780            "first of two causes should use middle-arrow"
781        );
782        assert!(
783            short.contains("\u{2570}\u{2500}\u{25b6}"),
784            "last cause should use last-arrow"
785        );
786    }
787
788    #[test]
789    fn test_format_short_three_causes_arrows() {
790        let erased = ErasedError {
791            message: "top".into(),
792            source_chain: vec!["first".into(), "second".into(), "third".into()],
793            diagnostics: Diagnostics::default(),
794            location: None,
795            spantrace: None,
796            backtrace: None,
797            source: OnceLock::new(),
798        };
799
800        let short = erased.format_short();
801        let middle_count = short.matches("\u{251c}\u{2500}\u{25b6}").count();
802        let last_count = short.matches("\u{2570}\u{2500}\u{25b6}").count();
803        assert_eq!(middle_count, 2, "first two causes should use middle-arrow");
804        assert_eq!(last_count, 1, "only last cause should use last-arrow");
805    }
806
807    // ─────────────────────────────────────────────────────────────────────
808    // Tests for Display impl and to_text
809    // ─────────────────────────────────────────────────────────────────────
810
811    #[test]
812    fn test_display_is_exactly_the_message() {
813        let erased = ErasedError {
814            message: "display test".into(),
815            source_chain: vec!["cause".into()],
816            diagnostics: Diagnostics {
817                code: Some("app::code".into()),
818                help: Some("try again".into()),
819                exit_code: None,
820            },
821            location: None,
822            spantrace: None,
823            backtrace: None,
824            source: OnceLock::new(),
825        };
826
827        let displayed = erased.to_string();
828        assert_eq!(
829            displayed, "display test",
830            "Display must be only the message so an ErasedError embeds \
831             cleanly in another error's source chain"
832        );
833        assert!(
834            !displayed.contains('\n'),
835            "Display must be single-line, got {displayed:?}"
836        );
837    }
838
839    #[test]
840    fn test_to_text_contains_full_report() {
841        let erased = ErasedError {
842            message: "top".into(),
843            source_chain: vec!["middle".into(), "root".into()],
844            diagnostics: Diagnostics {
845                code: Some("app::db::timeout".into()),
846                help: Some("retry later".into()),
847                exit_code: None,
848            },
849            location: None,
850            spantrace: None,
851            backtrace: None,
852            source: OnceLock::new(),
853        };
854
855        let text = erased.to_text();
856        assert!(text.contains("top"), "to_text must contain the message");
857        assert!(
858            text.contains("\u{251c}\u{2500}\u{25b6} middle"),
859            "to_text must render the source chain"
860        );
861        assert!(
862            text.contains("\u{2570}\u{2500}\u{25b6} root"),
863            "to_text must render the last cause"
864        );
865        assert!(
866            text.contains("help: retry later"),
867            "to_text must render the help text"
868        );
869    }
870
871    #[test]
872    fn test_write_text_renders_code_in_header() {
873        let erased = ErasedError {
874            message: "query timed out".into(),
875            source_chain: vec![],
876            diagnostics: Diagnostics {
877                code: Some("app::db::timeout".into()),
878                help: None,
879                exit_code: None,
880            },
881            location: None,
882            spantrace: None,
883            backtrace: None,
884            source: OnceLock::new(),
885        };
886
887        let text = erased.to_text();
888        assert_eq!(
889            text.lines().next(),
890            Some("Error[app::db::timeout]:"),
891            "header must carry the error code"
892        );
893    }
894
895    #[test]
896    fn test_write_text_header_without_code() {
897        let erased = ErasedError {
898            message: "plain".into(),
899            source_chain: vec![],
900            diagnostics: Diagnostics::default(),
901            location: None,
902            spantrace: None,
903            backtrace: None,
904            source: OnceLock::new(),
905        };
906
907        assert_eq!(erased.to_text().lines().next(), Some("Error:"));
908    }
909
910    #[test]
911    fn source_walk_yields_transported_chain_in_order() {
912        let erased: ErasedError =
913            serde_json::from_str(r#"{"message":"outer","source_chain":["middle","root"]}"#)
914                .unwrap();
915
916        let mut walked = Vec::new();
917        let mut src = std::error::Error::source(&erased);
918        while let Some(e) = src {
919            walked.push(e.to_string());
920            src = e.source();
921        }
922        assert_eq!(walked, ["middle", "root"]);
923
924        let empty: ErasedError = serde_json::from_str(r#"{"message":"x"}"#).unwrap();
925        assert!(std::error::Error::source(&empty).is_none());
926    }
927
928    #[test]
929    fn write_text_suppresses_banners_for_empty_traces() {
930        let erased: ErasedError = serde_json::from_str(
931            r#"{"message":"transported","spantrace":{"spans":[]},"backtrace":{"frames":[]}}"#,
932        )
933        .unwrap();
934
935        let text = erased.to_text();
936        assert!(
937            !text.contains("SPANTRACE"),
938            "empty spantrace must not banner:\n{text}"
939        );
940        assert!(
941            !text.contains("BACKTRACE"),
942            "empty backtrace must not banner:\n{text}"
943        );
944    }
945
946    #[test]
947    fn write_text_backtrace_only_gets_blank_line_before_banner() {
948        let erased: ErasedError = serde_json::from_str(
949            r#"{"message":"m","source_chain":["c"],"spantrace":null,
950                "backtrace":{"frames":[{"name":"f","filename":null,"line":null,"column":null}]}}"#,
951        )
952        .unwrap();
953        let text = erased.to_text();
954        assert!(
955            text.contains("c\n\n━"),
956            "blank line must separate the chain from the BACKTRACE banner:\n{text}"
957        );
958    }
959
960    #[test]
961    fn test_extract_backtrace_returns_none_for_plain_errors() {
962        let error = ChainedError {
963            msg: "plain error",
964            source: None,
965        };
966        let bt = crate::Diagnostic::oopsie_backtrace(&error);
967        assert!(
968            bt.is_none(),
969            "extract_backtrace should return None for plain errors"
970        );
971    }
972
973    #[test]
974    fn test_extract_error_code_returns_none_for_plain_errors() {
975        let erased = ErasedError {
976            message: "plain error".into(),
977            source_chain: vec![],
978            diagnostics: Diagnostics::default(),
979            location: None,
980            spantrace: None,
981            backtrace: None,
982            source: OnceLock::new(),
983        };
984        assert!(
985            erased.diagnostics.code().is_none(),
986            "plain error should have no error code"
987        );
988    }
989
990    // ─────────────────────────────────────────────────────────────────────
991    // Serde tolerance: missing optional fields deserialize without error.
992    // ─────────────────────────────────────────────────────────────────────
993
994    #[test]
995    fn serde_message_only_payload_deserializes() {
996        let erased: ErasedError = serde_json::from_str(r#"{"message":"x"}"#)
997            .expect("missing optional fields must not fail");
998        assert_eq!(&*erased.message, "x");
999        assert!(erased.source_chain.is_empty());
1000        assert!(erased.diagnostics.is_none());
1001        assert!(erased.spantrace.is_none());
1002        assert!(erased.backtrace.is_none());
1003    }
1004
1005    // ─────────────────────────────────────────────────────────────────────
1006    // Truncation marker: cyclic source yields MAX+1 entries, last is the
1007    // sentinel.
1008    // ─────────────────────────────────────────────────────────────────────
1009
1010    #[test]
1011    fn from_error_ref_appends_truncation_sentinel_on_cyclic_source() {
1012        #[derive(Debug)]
1013        struct Cyclic;
1014        impl fmt::Display for Cyclic {
1015            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1016                f.write_str("cyclic")
1017            }
1018        }
1019        impl std::error::Error for Cyclic {
1020            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1021                Some(self)
1022            }
1023        }
1024        impl crate::Diagnostic for Cyclic {}
1025
1026        let erased = ErasedError::from_error_ref(&Cyclic);
1027        assert_eq!(
1028            erased.source_chain.len(),
1029            MAX_SOURCE_CHAIN_DEPTH + 1,
1030            "cyclic chain must produce exactly MAX+1 entries (MAX real + sentinel)"
1031        );
1032        assert_eq!(
1033            &*erased.source_chain[MAX_SOURCE_CHAIN_DEPTH], "\u{2026} source chain truncated",
1034            "last entry must be the truncation sentinel"
1035        );
1036    }
1037
1038    // Constructs an ErasedError without tracing (spantrace: None) and verifies
1039    // that Clone preserves message, source chain, and diagnostics.
1040    #[test]
1041    fn erased_error_clone_without_spantrace() {
1042        let original = ErasedError {
1043            message: "clone test".into(),
1044            source_chain: vec!["cause one".into(), "cause two".into()],
1045            diagnostics: Diagnostics {
1046                code: Some("app::clone".into()),
1047                help: Some("check again".into()),
1048                exit_code: None,
1049            },
1050            location: None,
1051            spantrace: None,
1052            backtrace: None,
1053            source: OnceLock::new(),
1054        };
1055
1056        let cloned = original.clone();
1057
1058        assert_eq!(&*cloned.message, &*original.message);
1059        assert_eq!(cloned.source_chain.len(), original.source_chain.len());
1060        assert_eq!(&*cloned.source_chain[0], &*original.source_chain[0]);
1061        assert_eq!(&*cloned.source_chain[1], &*original.source_chain[1]);
1062        assert_eq!(cloned.diagnostics.code(), original.diagnostics.code());
1063        assert_eq!(cloned.diagnostics.help(), original.diagnostics.help());
1064        assert!(cloned.spantrace.is_none());
1065        assert!(cloned.backtrace.is_none());
1066    }
1067
1068    // ─────────────────────────────────────────────────────────────────────
1069    // Lossy filename: ErasedFrame.filename is now Box<str>, not Box<Path>.
1070    // Smoke-test that a real backtrace produces str filenames.
1071    // ─────────────────────────────────────────────────────────────────────
1072    #[test]
1073    fn erased_frame_filename_is_str() {
1074        crate::set_rust_backtrace_override(crate::RustBacktrace::Enabled);
1075        let bt = <crate::Backtrace as crate::Capturable>::capture();
1076        crate::clear_rust_backtrace_override();
1077
1078        let erased = crate::erased::backtrace::ErasedBacktrace::from_backtrace(&bt);
1079        // Verify at least one frame has a non-empty filename string.
1080        let has_filename = erased
1081            .frames()
1082            .iter()
1083            .any(|fr| fr.filename().is_some_and(|s| !s.is_empty()));
1084        assert!(has_filename, "at least one frame should have a filename");
1085    }
1086}