Skip to main content

oopsie_core/erased/
backtrace.rs

1//! Serializable backtrace representation.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7/// A serializable, type-erased representation of a backtrace.
8#[derive(Clone, Debug, Serialize, Deserialize)]
9#[non_exhaustive]
10pub struct ErasedBacktrace {
11    frames: Box<[ErasedFrame]>,
12}
13
14/// A single frame in an erased backtrace.
15#[derive(Clone, Debug, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct ErasedFrame {
18    name: Option<Box<str>>,
19    filename: Option<Box<str>>,
20    line: Option<u32>,
21    column: Option<u32>,
22}
23
24impl ErasedFrame {
25    /// The resolved symbol name, if any.
26    #[must_use]
27    #[inline]
28    pub fn name(&self) -> Option<&str> {
29        self.name.as_deref()
30    }
31
32    /// The source file the frame resolved to, if any.
33    #[must_use]
34    #[inline]
35    pub fn filename(&self) -> Option<&str> {
36        self.filename.as_deref()
37    }
38
39    /// The line within the source file, if any.
40    #[must_use]
41    #[inline]
42    pub const fn line(&self) -> Option<u32> {
43        self.line
44    }
45
46    /// The column within the line, if any.
47    #[must_use]
48    #[inline]
49    pub const fn column(&self) -> Option<u32> {
50        self.column
51    }
52}
53
54impl ErasedBacktrace {
55    /// Create an `ErasedBacktrace` from a live `Backtrace`.
56    ///
57    /// Captures every resolved symbol verbatim — including capture machinery,
58    /// OS/libc entry points, and unresolvable frames. Hiding
59    /// implementation/platform detail is a render-time concern; this snapshot
60    /// stays raw so a consumer can still render the full stack later.
61    #[must_use]
62    pub fn from_backtrace(bt: &crate::Backtrace) -> Self {
63        bt.resolve();
64
65        let mut frames = Vec::new();
66        for frame in bt.frames() {
67            let symbols = frame.symbols();
68            // A frame that failed to resolve has no symbols; keep a placeholder
69            // so the stack shape survives erasure.
70            if symbols.is_empty() {
71                frames.push(ErasedFrame {
72                    name: None,
73                    filename: None,
74                    line: None,
75                    column: None,
76                });
77            }
78            frames.extend(symbols.iter().map(|sym| ErasedFrame {
79                name: sym.name().map(|n| n.to_string().into_boxed_str()),
80                filename: sym.filename().map(|p| p.to_string_lossy().into()),
81                line: sym.lineno(),
82                column: sym.colno(),
83            }));
84        }
85        Self {
86            frames: frames.into(),
87        }
88    }
89
90    /// Returns a slice of all frames.
91    #[must_use]
92    #[inline]
93    pub fn frames(&self) -> &[ErasedFrame] {
94        &self.frames
95    }
96}
97
98impl From<&crate::Backtrace> for ErasedBacktrace {
99    #[inline]
100    fn from(bt: &crate::Backtrace) -> Self {
101        Self::from_backtrace(bt)
102    }
103}
104impl From<crate::Backtrace> for ErasedBacktrace {
105    #[inline]
106    fn from(bt: crate::Backtrace) -> Self {
107        Self::from_backtrace(&bt)
108    }
109}
110
111impl fmt::Display for ErasedBacktrace {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        for (i, frame) in self.frames.iter().enumerate() {
114            write!(f, "{:>3}: ", i + 1)?;
115            if let Some(name) = &frame.name {
116                writeln!(f, "{name}")?;
117            } else {
118                writeln!(f, "<unknown>")?;
119            }
120            if let Some(filename) = &frame.filename {
121                write!(f, "           at {filename}")?;
122                if let Some(line) = frame.line {
123                    write!(f, ":{line}")?;
124                    if let Some(col) = frame.column {
125                        write!(f, ":{col}")?;
126                    }
127                }
128                writeln!(f)?;
129            }
130        }
131        Ok(())
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn frame(
140        name: Option<&str>,
141        filename: Option<&str>,
142        line: Option<u32>,
143        column: Option<u32>,
144    ) -> ErasedFrame {
145        ErasedFrame {
146            name: name.map(|s| s.to_owned().into_boxed_str()),
147            filename: filename.map(Into::into),
148            line,
149            column,
150        }
151    }
152
153    fn single(f: ErasedFrame) -> ErasedBacktrace {
154        ErasedBacktrace {
155            frames: Box::from([f]),
156        }
157    }
158
159    #[test]
160    fn display_unknown_frame_without_location() {
161        // name == None -> `<unknown>`; filename == None -> no `at` line.
162        let bt = single(frame(None, None, None, None));
163        assert_eq!(bt.to_string(), "  1: <unknown>\n");
164    }
165
166    #[test]
167    fn display_full_location_with_column() {
168        let bt = single(frame(Some("a::b"), Some("/x/y.rs"), Some(12), Some(3)));
169        assert_eq!(bt.to_string(), "  1: a::b\n           at /x/y.rs:12:3\n");
170    }
171
172    #[test]
173    fn display_location_with_line_no_column() {
174        let bt = single(frame(Some("a::b"), Some("/x/y.rs"), Some(12), None));
175        assert_eq!(bt.to_string(), "  1: a::b\n           at /x/y.rs:12\n");
176    }
177
178    #[test]
179    fn display_location_with_filename_no_line() {
180        // filename present but no line -> bare path, no trailing `:`.
181        let bt = single(frame(Some("a::b"), Some("/x/y.rs"), None, None));
182        assert_eq!(bt.to_string(), "  1: a::b\n           at /x/y.rs\n");
183    }
184
185    #[test]
186    fn from_backtrace_retains_raw_internal_frames() {
187        crate::set_rust_backtrace_override(crate::RustBacktrace::Enabled);
188        let bt = <crate::Backtrace as crate::Capturable>::capture();
189        crate::clear_rust_backtrace_override();
190
191        let erased = ErasedBacktrace::from_backtrace(&bt);
192
193        // The capture path itself runs through this crate, so a raw snapshot
194        // must retain its frame — proving filtering is deferred to render
195        // time rather than applied here.
196        assert!(
197            erased.frames().iter().any(|fr| {
198                fr.filename
199                    .as_deref()
200                    .is_some_and(|f| f.starts_with(crate::__private::CORE_SRC_PATH))
201            }),
202            "from_backtrace should retain raw internal frames, not strip them at capture"
203        );
204    }
205
206    #[test]
207    fn from_backtrace_keeps_at_least_one_erased_frame_per_source_frame() {
208        crate::set_rust_backtrace_override(crate::RustBacktrace::Enabled);
209        let bt = <crate::Backtrace as crate::Capturable>::capture();
210        crate::clear_rust_backtrace_override();
211
212        let erased = ErasedBacktrace::from_backtrace(&bt);
213        assert!(
214            erased.frames().len() >= bt.frames().len(),
215            "every source frame must survive erasure (unresolved ones as placeholders): \
216             {} erased < {} source",
217            erased.frames().len(),
218            bt.frames().len()
219        );
220    }
221}