mod backtrace;
pub use backtrace::{ErasedBacktrace, ErasedFrame};
mod spantrace;
pub use spantrace::{ErasedMetadata, ErasedSpan, ErasedSpanTrace, TracingLevel};
use std::fmt;
use std::io;
use std::num::NonZeroU8;
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::{ErrorCode, HelpText};
const MAX_SOURCE_CHAIN_DEPTH: usize = 128;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct ErasedError {
message: Box<str>,
#[serde(default)]
source_chain: Vec<Box<str>>,
#[serde(default)]
diagnostics: Diagnostics,
#[serde(default, skip_serializing_if = "Option::is_none")]
location: Option<ErasedLocation>,
spantrace: Option<ErasedSpanTrace>,
backtrace: Option<ErasedBacktrace>,
#[serde(skip)]
source: OnceLock<Option<Box<ChainNode>>>,
}
impl std::error::Error for ErasedError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.get_or_init(|| ChainNode::build(&self.source_chain))
.as_deref()
.map(|node| node as &(dyn std::error::Error + 'static))
}
}
#[derive(Clone, Debug)]
struct ChainNode {
message: Box<str>,
source: Option<Box<Self>>,
}
impl ChainNode {
fn build(messages: &[Box<str>]) -> Option<Box<Self>> {
messages.iter().rev().fold(None, |source, message| {
Some(Box::new(Self {
message: message.clone(),
source,
}))
})
}
}
impl fmt::Display for ChainNode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ChainNode {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_deref()
.map(|node| node as &(dyn std::error::Error + 'static))
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Diagnostics {
#[serde(default, skip_serializing_if = "Option::is_none")]
code: Option<ErrorCode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
help: Option<HelpText>,
#[serde(default, skip_serializing_if = "Option::is_none")]
exit_code: Option<NonZeroU8>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ErasedLocation {
file: Box<str>,
line: u32,
column: u32,
}
impl ErasedLocation {
#[must_use]
#[inline]
pub fn file(&self) -> &str {
&self.file
}
#[must_use]
#[inline]
pub const fn line(&self) -> u32 {
self.line
}
#[must_use]
#[inline]
pub const fn column(&self) -> u32 {
self.column
}
}
impl fmt::Display for ErasedLocation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}:{}", self.file, self.line, self.column)
}
}
impl From<&'static std::panic::Location<'static>> for ErasedLocation {
fn from(location: &'static std::panic::Location<'static>) -> Self {
Self {
file: location.file().into(),
line: location.line(),
column: location.column(),
}
}
}
impl Diagnostics {
#[must_use]
#[inline]
pub const fn is_none(&self) -> bool {
self.code.is_none() && self.help.is_none() && self.exit_code.is_none()
}
#[must_use]
#[inline]
pub fn code(&self) -> Option<&str> {
self.code.as_deref()
}
#[must_use]
#[inline]
pub fn help(&self) -> Option<&str> {
self.help.as_deref()
}
#[must_use]
#[inline]
pub const fn exit_code(&self) -> Option<NonZeroU8> {
self.exit_code
}
}
impl ErasedError {
#[expect(
clippy::needless_pass_by_value,
reason = "owned input mirrors the from_error_ref ergonomics"
)]
pub fn from_error<E: crate::Diagnostic>(err: E) -> Self {
Self::from_error_ref(&err)
}
pub fn from_error_ref<E: crate::Diagnostic>(err: &E) -> Self {
let message = err.to_string().into();
let mut source_chain: Vec<Box<str>> = std::iter::successors(err.source(), |e| e.source())
.take(MAX_SOURCE_CHAIN_DEPTH + 1)
.map(ToString::to_string)
.map(Box::from)
.collect();
if source_chain.len() > MAX_SOURCE_CHAIN_DEPTH {
source_chain.truncate(MAX_SOURCE_CHAIN_DEPTH);
source_chain.push("\u{2026} source chain truncated".into());
}
let diagnostics = Diagnostics {
code: err.oopsie_error_code(),
help: err.oopsie_help_text(),
exit_code: err.oopsie_exit_code(),
};
let location = err.oopsie_location().map(ErasedLocation::from);
#[cfg(feature = "tracing")]
let spantrace = err
.oopsie_spantrace()
.filter(|st| st.is_captured())
.map(ErasedSpanTrace::from);
#[cfg(not(feature = "tracing"))]
let spantrace: Option<ErasedSpanTrace> = None;
let backtrace = err
.oopsie_backtrace()
.map(ErasedBacktrace::from_backtrace)
.filter(|bt| !bt.frames().is_empty());
Self {
message,
source_chain,
diagnostics,
location,
spantrace,
backtrace,
source: OnceLock::new(),
}
}
#[must_use]
#[inline]
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
#[inline]
pub fn source_chain(&self) -> &[Box<str>] {
&self.source_chain
}
#[must_use]
#[inline]
pub const fn diagnostics(&self) -> &Diagnostics {
&self.diagnostics
}
#[must_use]
#[inline]
pub const fn location(&self) -> Option<&ErasedLocation> {
self.location.as_ref()
}
#[must_use]
#[inline]
pub const fn spantrace(&self) -> Option<&ErasedSpanTrace> {
self.spantrace.as_ref()
}
#[must_use]
#[inline]
pub const fn backtrace(&self) -> Option<&ErasedBacktrace> {
self.backtrace.as_ref()
}
pub fn write_json<W: io::Write>(&self, f: &mut W) -> Result<(), serde_json::Error> {
use io::Write as _;
let mut buf = io::BufWriter::new(f);
serde_json::to_writer_pretty(&mut buf, self)?;
buf.flush().map_err(serde_json::Error::io)?;
Ok(())
}
pub fn write_text<W: io::Write>(&self, f: &mut W) -> io::Result<()> {
match self.diagnostics.code() {
Some(code) => writeln!(f, "Error[{code}]:")?,
None => writeln!(f, "Error:")?,
}
writeln!(f, "\n \u{00d7} {}", self.message)?;
if let Some(location) = &self.location {
writeln!(f, " at {location}")?;
}
let chain_len = self.source_chain.len();
for (i, cause) in self.source_chain.iter().enumerate() {
let arrow = if i < chain_len - 1 {
"\u{251c}\u{2500}\u{25b6}"
} else {
"\u{2570}\u{2500}\u{25b6}"
};
write!(f, " {arrow} ")?;
writeln!(f, "{cause}")?;
}
if let Some(help) = self.diagnostics.help() {
write!(f, "\n help: ")?;
writeln!(f, "{help}")?;
}
if let Some(spantrace) = &self.spantrace
&& !spantrace.is_empty()
{
writeln!(f)?;
writeln!(f, "{:━^80}", " SPANTRACE ")?;
writeln!(f, "{spantrace}")?;
}
if let Some(backtrace) = &self.backtrace
&& !backtrace.frames().is_empty()
{
writeln!(f)?;
writeln!(f, "{:━^80}", " BACKTRACE ")?;
write!(f, "{backtrace}")?;
}
Ok(())
}
#[must_use]
#[expect(
clippy::missing_panics_doc,
reason = "writing to a Vec<u8> cannot fail and write_text emits only UTF-8"
)]
pub fn to_text(&self) -> String {
let mut buf = Vec::new();
self.write_text(&mut buf)
.expect("Vec<u8> writes are infallible");
String::from_utf8(buf).expect("write_text emits UTF-8")
}
#[must_use]
pub fn format_short(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
if let Some(code) = self.diagnostics.code() {
let _ = writeln!(out, "{code}");
let _ = writeln!(out);
}
let _ = write!(out, " \u{00d7} {}", self.message);
if let Some(location) = &self.location {
let _ = write!(out, "\n at {location}");
}
let chain_len = self.source_chain.len();
for (i, cause) in self.source_chain.iter().enumerate() {
let arrow = if i < chain_len - 1 {
"\u{251c}\u{2500}\u{25b6}"
} else {
"\u{2570}\u{2500}\u{25b6}"
};
let _ = write!(out, "\n {arrow} {cause}");
}
if let Some(help) = self.diagnostics.help() {
let _ = write!(out, "\n help: {help}");
}
let _ = writeln!(out);
out
}
}
impl fmt::Display for ErasedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl crate::Diagnostic for ErasedError {
fn oopsie_error_code(&self) -> Option<ErrorCode> {
self.diagnostics.code.clone()
}
fn oopsie_help_text(&self) -> Option<HelpText> {
self.diagnostics.help.clone()
}
fn oopsie_exit_code(&self) -> Option<NonZeroU8> {
self.diagnostics.exit_code
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diagnostics_struct() {
let empty = Diagnostics::default();
assert!(empty.is_none());
assert_eq!(empty.code(), None);
assert_eq!(empty.help(), None);
let with_both = Diagnostics {
code: Some("test::code".into()),
help: Some("try this".into()),
exit_code: None,
};
assert!(!with_both.is_none());
assert_eq!(with_both.code(), Some("test::code"));
assert_eq!(with_both.help(), Some("try this"));
let code_only = Diagnostics {
code: Some("test::code".into()),
help: None,
exit_code: None,
};
assert!(!code_only.is_none());
assert_eq!(code_only.help(), None);
}
#[derive(Debug)]
struct ChainedError {
msg: &'static str,
source: Option<Box<Self>>,
}
impl fmt::Display for ChainedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.msg)
}
}
impl std::error::Error for ChainedError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_ref()
.map(|s| s.as_ref() as &dyn std::error::Error)
}
}
impl crate::Diagnostic for ChainedError {}
#[test]
fn test_from_error_preserves_message_and_chain() {
let error = ChainedError {
msg: "outer error",
source: Some(Box::new(ChainedError {
msg: "inner cause",
source: None,
})),
};
let erased = ErasedError::from_error(error);
assert_eq!(&*erased.message, "outer error");
assert_eq!(erased.source_chain.len(), 1);
assert_eq!(&*erased.source_chain[0], "inner cause");
}
#[test]
fn location_is_captured_and_survives_round_trip() {
#[derive(Debug)]
struct WithLoc(&'static std::panic::Location<'static>);
impl fmt::Display for WithLoc {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("located")
}
}
impl std::error::Error for WithLoc {}
impl crate::Diagnostic for WithLoc {
fn oopsie_location(&self) -> Option<&'static std::panic::Location<'static>> {
Some(self.0)
}
}
let here = std::panic::Location::caller();
let erased = ErasedError::from_error(WithLoc(here));
let loc = erased.location().expect("location captured");
assert_eq!(loc.file(), here.file());
assert_eq!(loc.line(), here.line());
assert_eq!(loc.column(), here.column());
let json = serde_json::to_string(&erased).unwrap();
let restored: ErasedError = serde_json::from_str(&json).unwrap();
let restored_loc = restored.location().expect("location survives round-trip");
assert_eq!(restored_loc.file(), here.file());
assert_eq!(restored_loc.line(), here.line());
assert_eq!(restored_loc.column(), here.column());
let plain = ErasedError::from_error(ChainedError {
msg: "plain",
source: None,
});
assert!(plain.location().is_none());
}
#[test]
fn test_from_error_ref_preserves_message_and_chain() {
let error = ChainedError {
msg: "outer error",
source: Some(Box::new(ChainedError {
msg: "inner cause",
source: None,
})),
};
let erased = ErasedError::from_error_ref(&error);
assert_eq!(&*erased.message, "outer error");
assert_eq!(erased.source_chain.len(), 1);
assert_eq!(&*erased.source_chain[0], "inner cause");
}
#[test]
fn test_write_json_produces_valid_json() {
let erased = ErasedError {
message: "something broke".into(),
source_chain: vec!["inner cause".into()],
diagnostics: Diagnostics::default(),
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let mut buf = Vec::new();
erased.write_json(&mut buf).unwrap();
assert!(!buf.is_empty(), "write_json must produce output");
let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(json["message"], "something broke");
assert_eq!(json["source_chain"][0], "inner cause");
}
struct FailingWriter;
impl io::Write for FailingWriter {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "peer gone"))
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "peer gone"))
}
}
#[test]
fn write_json_surfaces_io_errors() {
let erased = ErasedError {
message: "x".into(),
source_chain: vec![],
diagnostics: Diagnostics::default(),
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let err = erased.write_json(&mut FailingWriter).unwrap_err();
assert!(err.is_io(), "{err}");
}
#[test]
fn test_write_text_single_cause_uses_last_arrow() {
let erased = ErasedError {
message: "top".into(),
source_chain: vec!["only cause".into()],
diagnostics: Diagnostics::default(),
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let mut buf = Vec::new();
erased.write_text(&mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("\u{2570}\u{2500}\u{25b6}"),
"single cause should use last-arrow"
);
assert!(
!output.contains("\u{251c}\u{2500}\u{25b6}"),
"single cause should NOT use middle-arrow"
);
}
#[test]
fn test_write_text_two_causes_arrows() {
let erased = ErasedError {
message: "top".into(),
source_chain: vec!["middle".into(), "root".into()],
diagnostics: Diagnostics::default(),
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let mut buf = Vec::new();
erased.write_text(&mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("\u{251c}\u{2500}\u{25b6}"),
"first of two causes should use middle-arrow"
);
assert!(
output.contains("\u{2570}\u{2500}\u{25b6}"),
"last cause should use last-arrow"
);
}
#[test]
fn test_write_text_three_causes_arrows() {
let erased = ErasedError {
message: "top".into(),
source_chain: vec!["first".into(), "second".into(), "third".into()],
diagnostics: Diagnostics::default(),
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let mut buf = Vec::new();
erased.write_text(&mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
let middle_count = output.matches("\u{251c}\u{2500}\u{25b6}").count();
let last_count = output.matches("\u{2570}\u{2500}\u{25b6}").count();
assert_eq!(middle_count, 2, "first two causes should use middle-arrow");
assert_eq!(last_count, 1, "only last cause should use last-arrow");
}
#[test]
fn test_format_short_single_cause_uses_last_arrow() {
let erased = ErasedError {
message: "top".into(),
source_chain: vec!["only cause".into()],
diagnostics: Diagnostics::default(),
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let short = erased.format_short();
assert!(
short.contains("\u{2570}\u{2500}\u{25b6}"),
"single cause should use last-arrow"
);
assert!(
!short.contains("\u{251c}\u{2500}\u{25b6}"),
"single cause should NOT use middle-arrow"
);
}
#[test]
fn test_format_short_two_causes_arrows() {
let erased = ErasedError {
message: "top".into(),
source_chain: vec!["middle".into(), "root".into()],
diagnostics: Diagnostics::default(),
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let short = erased.format_short();
assert!(
short.contains("\u{251c}\u{2500}\u{25b6}"),
"first of two causes should use middle-arrow"
);
assert!(
short.contains("\u{2570}\u{2500}\u{25b6}"),
"last cause should use last-arrow"
);
}
#[test]
fn test_format_short_three_causes_arrows() {
let erased = ErasedError {
message: "top".into(),
source_chain: vec!["first".into(), "second".into(), "third".into()],
diagnostics: Diagnostics::default(),
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let short = erased.format_short();
let middle_count = short.matches("\u{251c}\u{2500}\u{25b6}").count();
let last_count = short.matches("\u{2570}\u{2500}\u{25b6}").count();
assert_eq!(middle_count, 2, "first two causes should use middle-arrow");
assert_eq!(last_count, 1, "only last cause should use last-arrow");
}
#[test]
fn test_display_is_exactly_the_message() {
let erased = ErasedError {
message: "display test".into(),
source_chain: vec!["cause".into()],
diagnostics: Diagnostics {
code: Some("app::code".into()),
help: Some("try again".into()),
exit_code: None,
},
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let displayed = erased.to_string();
assert_eq!(
displayed, "display test",
"Display must be only the message so an ErasedError embeds \
cleanly in another error's source chain"
);
assert!(
!displayed.contains('\n'),
"Display must be single-line, got {displayed:?}"
);
}
#[test]
fn test_to_text_contains_full_report() {
let erased = ErasedError {
message: "top".into(),
source_chain: vec!["middle".into(), "root".into()],
diagnostics: Diagnostics {
code: Some("app::db::timeout".into()),
help: Some("retry later".into()),
exit_code: None,
},
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let text = erased.to_text();
assert!(text.contains("top"), "to_text must contain the message");
assert!(
text.contains("\u{251c}\u{2500}\u{25b6} middle"),
"to_text must render the source chain"
);
assert!(
text.contains("\u{2570}\u{2500}\u{25b6} root"),
"to_text must render the last cause"
);
assert!(
text.contains("help: retry later"),
"to_text must render the help text"
);
}
#[test]
fn test_write_text_renders_code_in_header() {
let erased = ErasedError {
message: "query timed out".into(),
source_chain: vec![],
diagnostics: Diagnostics {
code: Some("app::db::timeout".into()),
help: None,
exit_code: None,
},
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let text = erased.to_text();
assert_eq!(
text.lines().next(),
Some("Error[app::db::timeout]:"),
"header must carry the error code"
);
}
#[test]
fn test_write_text_header_without_code() {
let erased = ErasedError {
message: "plain".into(),
source_chain: vec![],
diagnostics: Diagnostics::default(),
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
assert_eq!(erased.to_text().lines().next(), Some("Error:"));
}
#[test]
fn source_walk_yields_transported_chain_in_order() {
let erased: ErasedError =
serde_json::from_str(r#"{"message":"outer","source_chain":["middle","root"]}"#)
.unwrap();
let mut walked = Vec::new();
let mut src = std::error::Error::source(&erased);
while let Some(e) = src {
walked.push(e.to_string());
src = e.source();
}
assert_eq!(walked, ["middle", "root"]);
let empty: ErasedError = serde_json::from_str(r#"{"message":"x"}"#).unwrap();
assert!(std::error::Error::source(&empty).is_none());
}
#[test]
fn write_text_suppresses_banners_for_empty_traces() {
let erased: ErasedError = serde_json::from_str(
r#"{"message":"transported","spantrace":{"spans":[]},"backtrace":{"frames":[]}}"#,
)
.unwrap();
let text = erased.to_text();
assert!(
!text.contains("SPANTRACE"),
"empty spantrace must not banner:\n{text}"
);
assert!(
!text.contains("BACKTRACE"),
"empty backtrace must not banner:\n{text}"
);
}
#[test]
fn write_text_backtrace_only_gets_blank_line_before_banner() {
let erased: ErasedError = serde_json::from_str(
r#"{"message":"m","source_chain":["c"],"spantrace":null,
"backtrace":{"frames":[{"name":"f","filename":null,"line":null,"column":null}]}}"#,
)
.unwrap();
let text = erased.to_text();
assert!(
text.contains("c\n\n━"),
"blank line must separate the chain from the BACKTRACE banner:\n{text}"
);
}
#[test]
fn test_extract_backtrace_returns_none_for_plain_errors() {
let error = ChainedError {
msg: "plain error",
source: None,
};
let bt = crate::Diagnostic::oopsie_backtrace(&error);
assert!(
bt.is_none(),
"extract_backtrace should return None for plain errors"
);
}
#[test]
fn test_extract_error_code_returns_none_for_plain_errors() {
let erased = ErasedError {
message: "plain error".into(),
source_chain: vec![],
diagnostics: Diagnostics::default(),
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
assert!(
erased.diagnostics.code().is_none(),
"plain error should have no error code"
);
}
#[test]
fn serde_message_only_payload_deserializes() {
let erased: ErasedError = serde_json::from_str(r#"{"message":"x"}"#)
.expect("missing optional fields must not fail");
assert_eq!(&*erased.message, "x");
assert!(erased.source_chain.is_empty());
assert!(erased.diagnostics.is_none());
assert!(erased.spantrace.is_none());
assert!(erased.backtrace.is_none());
}
#[test]
fn from_error_ref_appends_truncation_sentinel_on_cyclic_source() {
#[derive(Debug)]
struct Cyclic;
impl fmt::Display for Cyclic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("cyclic")
}
}
impl std::error::Error for Cyclic {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self)
}
}
impl crate::Diagnostic for Cyclic {}
let erased = ErasedError::from_error_ref(&Cyclic);
assert_eq!(
erased.source_chain.len(),
MAX_SOURCE_CHAIN_DEPTH + 1,
"cyclic chain must produce exactly MAX+1 entries (MAX real + sentinel)"
);
assert_eq!(
&*erased.source_chain[MAX_SOURCE_CHAIN_DEPTH], "\u{2026} source chain truncated",
"last entry must be the truncation sentinel"
);
}
#[test]
fn erased_error_clone_without_spantrace() {
let original = ErasedError {
message: "clone test".into(),
source_chain: vec!["cause one".into(), "cause two".into()],
diagnostics: Diagnostics {
code: Some("app::clone".into()),
help: Some("check again".into()),
exit_code: None,
},
location: None,
spantrace: None,
backtrace: None,
source: OnceLock::new(),
};
let cloned = original.clone();
assert_eq!(&*cloned.message, &*original.message);
assert_eq!(cloned.source_chain.len(), original.source_chain.len());
assert_eq!(&*cloned.source_chain[0], &*original.source_chain[0]);
assert_eq!(&*cloned.source_chain[1], &*original.source_chain[1]);
assert_eq!(cloned.diagnostics.code(), original.diagnostics.code());
assert_eq!(cloned.diagnostics.help(), original.diagnostics.help());
assert!(cloned.spantrace.is_none());
assert!(cloned.backtrace.is_none());
}
#[test]
fn erased_frame_filename_is_str() {
crate::set_rust_backtrace_override(crate::RustBacktrace::Enabled);
let bt = <crate::Backtrace as crate::Capturable>::capture();
crate::clear_rust_backtrace_override();
let erased = crate::erased::backtrace::ErasedBacktrace::from_backtrace(&bt);
let has_filename = erased
.frames()
.iter()
.any(|fr| fr.filename().is_some_and(|s| !s.is_empty()));
assert!(has_filename, "at least one frame should have a filename");
}
}