1mod 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
22const MAX_SOURCE_CHAIN_DEPTH: usize = 128;
25
26#[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 #[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#[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#[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#[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 #[must_use]
156 #[inline]
157 pub fn file(&self) -> &str {
158 &self.file
159 }
160
161 #[must_use]
163 #[inline]
164 pub const fn line(&self) -> u32 {
165 self.line
166 }
167
168 #[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 #[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 #[must_use]
201 #[inline]
202 pub fn code(&self) -> Option<&str> {
203 self.code.as_deref()
204 }
205 #[must_use]
207 #[inline]
208 pub fn help(&self) -> Option<&str> {
209 self.help.as_deref()
210 }
211 #[must_use]
213 #[inline]
214 pub const fn exit_code(&self) -> Option<NonZeroU8> {
215 self.exit_code
216 }
217}
218
219impl ErasedError {
224 #[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 pub fn from_error_ref<E: crate::Diagnostic>(err: &E) -> Self {
241 let message = err.to_string().into();
242
243 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 #[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 #[must_use]
294 #[inline]
295 pub fn message(&self) -> &str {
296 &self.message
297 }
298
299 #[must_use]
301 #[inline]
302 pub fn source_chain(&self) -> &[Box<str>] {
303 &self.source_chain
304 }
305
306 #[must_use]
308 #[inline]
309 pub const fn diagnostics(&self) -> &Diagnostics {
310 &self.diagnostics
311 }
312
313 #[must_use]
315 #[inline]
316 pub const fn location(&self) -> Option<&ErasedLocation> {
317 self.location.as_ref()
318 }
319
320 #[must_use]
322 #[inline]
323 pub const fn spantrace(&self) -> Option<&ErasedSpanTrace> {
324 self.spantrace.as_ref()
325 }
326
327 #[must_use]
329 #[inline]
330 pub const fn backtrace(&self) -> Option<&ErasedBacktrace> {
331 self.backtrace.as_ref()
332 }
333
334 #[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 #[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 fn render_text(&self, f: &mut impl fmt::Write) -> fmt::Result {
359 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 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 if let Some(help) = self.diagnostics.help() {
385 write!(f, "\n help: ")?;
386 writeln!(f, "{help}")?;
387 }
388
389 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 #[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 #[must_use]
424 pub fn format_short(&self) -> String {
425 use core::fmt::Write as _;
426 let mut out = String::new();
427
428 if let Some(code) = self.diagnostics.code() {
430 let _ = writeln!(out, "{code}");
431 let _ = writeln!(out);
432 }
433
434 let _ = write!(out, " \u{00d7} {}", self.message);
436
437 if let Some(location) = &self.location {
439 let _ = write!(out, "\n at {location}");
440 }
441
442 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 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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}