headless_lms_base/error/clean_format/
frames.rs1use core::fmt;
11
12use backtrace::Backtrace;
13
14use super::color::dim;
15
16#[derive(Debug, Clone)]
18pub struct FrameView {
19 pub function: String,
21 pub raw_symbol: String,
23 pub file: Option<String>,
25 pub line: Option<u32>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum Kind {
31 Infra,
33 Foreign,
35 Ours,
37}
38
39const 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
52const INFRA_SYMBOL_FRAGMENTS: &[&str] = &[
54 "BackendError",
55 "convert::From<",
56 "backtrace::Backtrace",
57 "SpanTrace",
58 "clean_format",
59];
60
61pub 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
91pub fn clean_function(symbol: &str) -> String {
94 if symbol.is_empty() {
95 return "<unknown>".to_string();
96 }
97 let mut s = symbol;
98 while let Some(stripped) = s.strip_suffix("::{{closure}}") {
100 s = stripped;
101 }
102 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 match s.rsplit_once("::") {
111 Some((_, last)) if !last.is_empty() => last.to_string(),
112 _ => s.to_string(),
113 }
114}
115
116pub 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
129pub 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
169pub 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 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 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 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 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 let out = render(&frames, Some(("chatbot/src/azure_chatbot.rs", 824)));
361
362 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 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}