Skip to main content

headless_lms_base/error/clean_format/
frames.rs

1/*!
2Extract, classify and render backtrace frames for the clean error formatter.
3
4Shows only our-code frames: the raise line (from the captured [`std::panic::Location`]
5when meaningful, else the backtrace) plus our-code callers, with runs of third-party and
6runtime frames collapsed into a "N framework frames hidden" marker. Pure functions over
7[`FrameView`] so they unit-test without a real backtrace.
8*/
9
10use core::fmt;
11
12use backtrace::Backtrace;
13
14use super::color::dim;
15
16/// A stack frame reduced to what we display and classify on.
17#[derive(Debug, Clone)]
18pub struct FrameView {
19    /// Function name: last path segment, closures stripped.
20    pub function: String,
21    /// Full demangled symbol, used for classification.
22    pub raw_symbol: String,
23    /// Source file, workspace-relative when possible.
24    pub file: Option<String>,
25    /// Source line.
26    pub line: Option<u32>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum Kind {
31    /// Error construction/conversion machinery; always hidden.
32    Infra,
33    /// Third-party, std or async runtime; collapsed.
34    Foreign,
35    /// Our code; shown.
36    Ours,
37}
38
39/// Path fragments identifying error-infrastructure files.
40///
41/// `macros.rs` is deliberately excluded: the `*_err!` expansion attributes the raise
42/// site to it, and we keep that frame (relabelled with the real [`Location`]).
43const INFRA_FILE_FRAGMENTS: &[&str] = &[
44    "backend_error.rs",
45    "/clean_format/",
46    "chatbot_error.rs",
47    "util_error.rs",
48    "/domain/error.rs",
49    "/models/src/error.rs",
50];
51
52/// Symbol substrings marking a frame as error construction/conversion.
53const INFRA_SYMBOL_FRAGMENTS: &[&str] = &[
54    "BackendError",
55    "convert::From<",
56    "backtrace::Backtrace",
57    "SpanTrace",
58    "clean_format",
59];
60
61/// True if a path is error infrastructure, so a `Location` there (e.g. a `From` body)
62/// is not a meaningful raise site.
63pub fn is_infra_path(path: &str) -> bool {
64    INFRA_FILE_FRAGMENTS.iter().any(|frag| path.contains(frag))
65}
66
67fn is_infra(symbol: &str, file: Option<&str>) -> bool {
68    if INFRA_SYMBOL_FRAGMENTS
69        .iter()
70        .any(|frag| symbol.contains(frag))
71    {
72        return true;
73    }
74    matches!(file, Some(f) if is_infra_path(f))
75}
76
77fn is_ours(symbol: &str) -> bool {
78    symbol.contains("headless_lms")
79}
80
81fn classify(symbol: &str, file: Option<&str>) -> Kind {
82    if is_infra(symbol, file) {
83        Kind::Infra
84    } else if is_ours(symbol) {
85        Kind::Ours
86    } else {
87        Kind::Foreign
88    }
89}
90
91/// Reduce a demangled symbol to its last segment, dropping `::{{closure}}` and the
92/// `::hXXXX` hash suffix.
93pub fn clean_function(symbol: &str) -> String {
94    if symbol.is_empty() {
95        return "<unknown>".to_string();
96    }
97    let mut s = symbol;
98    // Strip repeated `::{{closure}}` suffixes.
99    while let Some(stripped) = s.strip_suffix("::{{closure}}") {
100        s = stripped;
101    }
102    // Strip a trailing `::h<hex>` disambiguator.
103    if let Some(idx) = s.rfind("::h") {
104        let tail = &s[idx + 3..];
105        if !tail.is_empty() && tail.bytes().all(|b| b.is_ascii_hexdigit()) {
106            s = &s[..idx];
107        }
108    }
109    // Keep the last path segment.
110    match s.rsplit_once("::") {
111        Some((_, last)) if !last.is_empty() => last.to_string(),
112        _ => s.to_string(),
113    }
114}
115
116/// Clean a source path to a workspace-relative form for display.
117pub fn clean_path(path: &str) -> String {
118    if let Some(idx) = path.find("headless-lms/") {
119        return path[idx + "headless-lms/".len()..].to_string();
120    }
121    if let Ok(cwd) = std::env::current_dir()
122        && let Ok(stripped) = std::path::Path::new(path).strip_prefix(&cwd)
123    {
124        return stripped.display().to_string();
125    }
126    path.to_string()
127}
128
129/// Extract [`FrameView`]s from a backtrace. Resolves a clone so an unresolved capture
130/// is symbolized only when formatted.
131pub fn extract_frames(backtrace: &Backtrace) -> Vec<FrameView> {
132    let mut backtrace = backtrace.clone();
133    backtrace.resolve();
134    backtrace
135        .frames()
136        .iter()
137        .map(|frame| {
138            let symbol = frame.symbols().first();
139            let raw_symbol = symbol
140                .and_then(|s| s.name())
141                .map(|n| format!("{n}"))
142                .unwrap_or_default();
143            let file = symbol
144                .and_then(|s| s.filename())
145                .map(|p| clean_path(&p.display().to_string()));
146            let line = symbol.and_then(|s| s.lineno());
147            FrameView {
148                function: clean_function(&raw_symbol),
149                raw_symbol,
150                file,
151                line,
152            }
153        })
154        .collect()
155}
156
157fn hidden_marker(n: usize) -> String {
158    let word = if n == 1 { "frame" } else { "frames" };
159    format!("⋯ {n} framework {word} hidden ⋯")
160}
161
162fn format_frame(file: &str, line: Option<u32>, function: &str) -> String {
163    match line {
164        Some(line) => format!("{file}:{line}  {function}"),
165        None => format!("{file}  {function}"),
166    }
167}
168
169/// Render one node's raise line plus caller frames.
170///
171/// `raise_override` is the `(file, line)` used for the raise line (the caller passes the
172/// captured `Location` when meaningful; see [`is_infra_path`]), else the raise frame's
173/// own file:line is used. `frames` are innermost-first; caller frames indent three
174/// spaces past `indent`.
175pub fn render_stack(
176    out: &mut dyn fmt::Write,
177    frames: &[FrameView],
178    raise_override: Option<(&str, u32)>,
179    indent: &str,
180    colored: bool,
181) -> fmt::Result {
182    let raise_idx = frames
183        .iter()
184        .position(|f| classify(&f.raw_symbol, f.file.as_deref()) == Kind::Ours);
185
186    match (raise_idx, raise_override) {
187        (Some(i), over) => {
188            let frame = &frames[i];
189            let (file, line) = match over {
190                Some((file, line)) => (clean_path(file), Some(line)),
191                None => (
192                    frame
193                        .file
194                        .clone()
195                        .unwrap_or_else(|| "<unknown>".to_string()),
196                    frame.line,
197                ),
198            };
199            writeln!(
200                out,
201                "{indent}{} {}",
202                dim("at", colored),
203                format_frame(&file, line, &frame.function)
204            )?;
205            render_callers(out, &frames[i + 1..], indent, colored)?;
206        }
207        (None, Some((file, line))) => {
208            writeln!(
209                out,
210                "{indent}{} {}:{}",
211                dim("at", colored),
212                clean_path(file),
213                line
214            )?;
215        }
216        (None, None) => {}
217    }
218    Ok(())
219}
220
221fn render_callers(
222    out: &mut dyn fmt::Write,
223    frames: &[FrameView],
224    indent: &str,
225    colored: bool,
226) -> fmt::Result {
227    let caller_indent = format!("{indent}   ");
228    let mut hidden = 0usize;
229    for frame in frames {
230        match classify(&frame.raw_symbol, frame.file.as_deref()) {
231            Kind::Ours => {
232                if hidden > 0 {
233                    writeln!(
234                        out,
235                        "{caller_indent}{}",
236                        dim(&hidden_marker(hidden), colored)
237                    )?;
238                    hidden = 0;
239                }
240                let file = frame.file.as_deref().unwrap_or("<unknown>");
241                writeln!(
242                    out,
243                    "{caller_indent}{}",
244                    format_frame(file, frame.line, &frame.function)
245                )?;
246            }
247            Kind::Foreign | Kind::Infra => hidden += 1,
248        }
249    }
250    if hidden > 0 {
251        writeln!(
252            out,
253            "{caller_indent}{}",
254            dim(&hidden_marker(hidden), colored)
255        )?;
256    }
257    Ok(())
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn frame(symbol: &str, file: Option<&str>, line: Option<u32>) -> FrameView {
265        FrameView {
266            function: clean_function(symbol),
267            raw_symbol: symbol.to_string(),
268            file: file.map(|f| f.to_string()),
269            line,
270        }
271    }
272
273    #[test]
274    fn clean_function_strips_closures_hashes_and_path() {
275        assert_eq!(
276            clean_function(
277                "headless_lms_chatbot::azure_chatbot::send_msg::{{closure}}::{{closure}}"
278            ),
279            "send_msg"
280        );
281        assert_eq!(
282            clean_function("headless_lms_server::foo::bar::h1a2b3c4d"),
283            "bar"
284        );
285        assert_eq!(clean_function(""), "<unknown>");
286    }
287
288    #[test]
289    fn classify_recognises_ours_infra_and_foreign() {
290        // Our code, even though the file is the macro definition.
291        assert_eq!(
292            classify(
293                "headless_lms_chatbot::azure_chatbot::send_msg",
294                Some("utils/src/error/macros.rs")
295            ),
296            Kind::Ours
297        );
298        // Construction machinery by symbol.
299        assert_eq!(
300            classify(
301                "<headless_lms_chatbot::chatbot_error::ChatbotError as headless_lms_base::error::backend_error::BackendError>::new",
302                Some("chatbot/src/chatbot_error.rs")
303            ),
304            Kind::Infra
305        );
306        // Third-party or runtime.
307        assert_eq!(
308            classify(
309                "tokio::runtime::task::poll",
310                Some("/root/.cargo/registry/tokio/task.rs")
311            ),
312            Kind::Foreign
313        );
314    }
315
316    #[test]
317    fn is_infra_path_matches_error_files_but_not_macros() {
318        assert!(is_infra_path(
319            "services/headless-lms/base/src/error/backend_error.rs"
320        ));
321        assert!(is_infra_path("chatbot/src/chatbot_error.rs"));
322        assert!(is_infra_path("server/src/domain/error.rs"));
323        assert!(!is_infra_path("utils/src/error/macros.rs"));
324        assert!(!is_infra_path("chatbot/src/azure_chatbot.rs"));
325    }
326
327    fn render(frames: &[FrameView], raise_override: Option<(&str, u32)>) -> String {
328        let mut s = String::new();
329        render_stack(&mut s, frames, raise_override, "  ", false).unwrap();
330        s
331    }
332
333    #[test]
334    fn raise_line_uses_location_and_our_frames_function() {
335        // Frame 0 = construction (hidden), frame 1 = our raise frame attributed to
336        // macros.rs, frame 2 = runtime (hidden), frame 3 = our caller.
337        let frames = vec![
338            frame(
339                "<ChatbotError as headless_lms_base::error::backend_error::BackendError>::new",
340                Some("chatbot/src/chatbot_error.rs"),
341                Some(131),
342            ),
343            frame(
344                "headless_lms_chatbot::azure_chatbot::send_chat_request::{{closure}}",
345                Some("utils/src/error/macros.rs"),
346                Some(141),
347            ),
348            frame(
349                "async_stream::poll",
350                Some("/root/.cargo/registry/async-stream/x.rs"),
351                Some(56),
352            ),
353            frame(
354                "headless_lms_server::controllers::chatbot::send_message",
355                Some("server/src/controllers/chatbot.rs"),
356                Some(120),
357            ),
358        ];
359        // A meaningful location overrides the raise frame's (macros.rs) file:line.
360        let out = render(&frames, Some(("chatbot/src/azure_chatbot.rs", 824)));
361
362        // Raise line shows the real function name and the location, NOT the macro path.
363        assert!(out.contains("send_chat_request"), "got: {out}");
364        assert!(out.contains("azure_chatbot.rs:824"), "got: {out}");
365        assert!(!out.contains("macros.rs"), "got: {out}");
366        assert!(out.contains("at "), "got: {out}");
367        // The caller is shown and the runtime frame between is collapsed.
368        assert!(out.contains("send_message"), "got: {out}");
369        assert!(out.contains("1 framework frame hidden"), "got: {out}");
370    }
371
372    #[test]
373    fn trailing_foreign_frames_collapse_into_one_marker() {
374        let frames = vec![
375            frame(
376                "headless_lms_server::foo::handler",
377                Some("server/src/foo.rs"),
378                Some(10),
379            ),
380            frame(
381                "tokio::a",
382                Some("/root/.cargo/registry/tokio/a.rs"),
383                Some(1),
384            ),
385            frame(
386                "tokio::b",
387                Some("/root/.cargo/registry/tokio/b.rs"),
388                Some(2),
389            ),
390            frame("std::rt", Some("/rustc/lib.rs"), Some(3)),
391        ];
392        let out = render(&frames, None);
393        assert!(out.contains("3 framework frames hidden"), "got: {out}");
394    }
395}