headless_lms_base/error/clean_format/
mod.rs1pub mod color;
38pub mod frames;
39pub mod spans;
40
41use core::fmt;
42use std::panic::Location;
43
44use backtrace::Backtrace;
45use tracing_error::SpanTrace;
46
47pub use color::ColorChoice;
48
49use crate::error::backend_error::BackendError;
50use color::{bold, dim};
51
52pub trait ErrorTrace {
54 fn type_name(&self) -> &'static str;
56 fn variant(&self) -> String;
58 fn message(&self) -> &str;
60 fn backtrace(&self) -> Option<&Backtrace>;
62 fn location(&self) -> Option<&'static Location<'static>>;
64 fn span_trace(&self) -> &SpanTrace;
66}
67
68impl<T: BackendError> ErrorTrace for T {
71 fn type_name(&self) -> &'static str {
72 let full = std::any::type_name::<T>();
73 full.rsplit("::").next().unwrap_or(full)
74 }
75
76 fn variant(&self) -> String {
77 format!("{:?}", BackendError::error_type(self))
78 }
79
80 fn message(&self) -> &str {
81 BackendError::message(self)
82 }
83
84 fn backtrace(&self) -> Option<&Backtrace> {
85 BackendError::backtrace(self)
86 }
87
88 fn location(&self) -> Option<&'static Location<'static>> {
89 BackendError::location(self)
90 }
91
92 fn span_trace(&self) -> &SpanTrace {
93 BackendError::span_trace(self)
94 }
95}
96
97pub type Resolver<'a> =
100 dyn for<'e> Fn(&'e (dyn std::error::Error + 'static)) -> Option<&'e dyn ErrorTrace> + 'a;
101
102const MAX_CHAIN: usize = 64;
104
105pub fn render(
107 out: &mut dyn fmt::Write,
108 head: &dyn ErrorTrace,
109 head_source: Option<&(dyn std::error::Error + 'static)>,
110 resolve: &Resolver<'_>,
111 color: ColorChoice,
112) -> fmt::Result {
113 let colored = color.enabled();
114
115 write_node(out, head, " ", colored)?;
116
117 let mut current = head_source;
118 let mut index = 1usize;
119 let mut started_chain = false;
120 while let Some(err) = current {
121 if index > MAX_CHAIN {
122 break;
123 }
124 if !started_chain {
125 writeln!(out)?;
126 writeln!(out, "{}", bold("caused by:", colored))?;
127 started_chain = true;
128 }
129 match resolve(err) {
130 Some(trace) => write_cause_node(out, index, trace, colored)?,
131 None => writeln!(out, " {index}. {err} {}", dim("(external)", colored))?,
132 }
133 index += 1;
134 current = err.source();
135 }
136
137 if let Some(breadcrumb) = spans::breadcrumb(head.span_trace()) {
138 writeln!(out)?;
139 writeln!(out, "{}", bold("spans", colored))?;
140 writeln!(out, " {breadcrumb}")?;
141 }
142
143 Ok(())
144}
145
146fn header_line(trace: &dyn ErrorTrace, colored: bool) -> String {
147 format!(
148 "{} · {}: {}",
149 bold(trace.type_name(), colored),
150 trace.variant(),
151 trace.message()
152 )
153}
154
155fn write_node(
156 out: &mut dyn fmt::Write,
157 trace: &dyn ErrorTrace,
158 indent: &str,
159 colored: bool,
160) -> fmt::Result {
161 writeln!(out, "{}", header_line(trace, colored))?;
162 write_stack(out, trace, indent, colored)
163}
164
165fn write_cause_node(
166 out: &mut dyn fmt::Write,
167 index: usize,
168 trace: &dyn ErrorTrace,
169 colored: bool,
170) -> fmt::Result {
171 writeln!(out, " {index}. {}", header_line(trace, colored))?;
172 write_stack(out, trace, " ", colored)
173}
174
175fn write_stack(
176 out: &mut dyn fmt::Write,
177 trace: &dyn ErrorTrace,
178 indent: &str,
179 colored: bool,
180) -> fmt::Result {
181 let raise_override = trace
184 .location()
185 .filter(|location| !frames::is_infra_path(location.file()))
186 .map(|location| (location.file(), location.line()));
187
188 match trace.backtrace() {
189 Some(backtrace) => {
190 let extracted = frames::extract_frames(backtrace);
191 frames::render_stack(out, &extracted, raise_override, indent, colored)
192 }
193 None => {
194 if let Some((file, line)) = raise_override {
195 writeln!(
196 out,
197 "{indent}{} {}:{}",
198 dim("at", colored),
199 frames::clean_path(file),
200 line
201 )?;
202 }
203 Ok(())
204 }
205 }
206}
207
208#[macro_export]
218macro_rules! impl_clean_debug {
219 ($error:ty, [ $( $cause:ty ),* $(,)? ]) => {
220 impl $error {
221 fn render_clean_error(
222 &self,
223 out: &mut dyn ::core::fmt::Write,
224 color: $crate::error::clean_format::ColorChoice,
225 ) -> ::core::fmt::Result {
226 fn resolve<'err>(
227 err: &'err (dyn ::std::error::Error + 'static),
228 ) -> ::core::option::Option<&'err dyn $crate::error::clean_format::ErrorTrace> {
229 $(
230 if let ::core::option::Option::Some(matched) =
231 err.downcast_ref::<$cause>()
232 {
233 return ::core::option::Option::Some(
234 matched as &dyn $crate::error::clean_format::ErrorTrace,
235 );
236 }
237 )*
238 ::core::option::Option::None
239 }
240 $crate::error::clean_format::render(
241 out,
242 self as &dyn $crate::error::clean_format::ErrorTrace,
243 ::std::error::Error::source(self),
244 &resolve,
245 color,
246 )
247 }
248
249 pub fn clean_string(
251 &self,
252 color: $crate::error::clean_format::ColorChoice,
253 ) -> ::std::string::String {
254 let mut buffer = ::std::string::String::new();
255 let _ = self.render_clean_error(&mut buffer, color);
256 buffer
257 }
258 }
259
260 impl ::core::fmt::Debug for $error {
261 fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
262 self.render_clean_error(f, $crate::error::clean_format::ColorChoice::Never)
263 }
264 }
265 };
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 struct FakeError {
274 type_name: &'static str,
275 variant: &'static str,
276 message: String,
277 source: Option<Box<dyn std::error::Error + 'static>>,
278 span_trace: SpanTrace,
279 }
280
281 impl std::fmt::Display for FakeError {
282 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283 write!(f, "{}", self.message)
284 }
285 }
286 impl std::fmt::Debug for FakeError {
287 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288 write!(f, "{}", self.message)
289 }
290 }
291 impl std::error::Error for FakeError {
292 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
293 self.source.as_deref()
294 }
295 }
296 impl ErrorTrace for FakeError {
297 fn type_name(&self) -> &'static str {
298 self.type_name
299 }
300 fn variant(&self) -> String {
301 self.variant.to_string()
302 }
303 fn message(&self) -> &str {
304 &self.message
305 }
306 fn backtrace(&self) -> Option<&Backtrace> {
307 None
308 }
309 fn location(&self) -> Option<&'static Location<'static>> {
310 None
311 }
312 fn span_trace(&self) -> &SpanTrace {
313 &self.span_trace
314 }
315 }
316
317 fn resolver<'err>(
318 err: &'err (dyn std::error::Error + 'static),
319 ) -> Option<&'err dyn ErrorTrace> {
320 err.downcast_ref::<FakeError>()
321 .map(|e| e as &dyn ErrorTrace)
322 }
323
324 fn render_to_string(head: &FakeError) -> String {
325 let mut out = String::new();
326 render(
327 &mut out,
328 head,
329 std::error::Error::source(head),
330 &resolver,
331 ColorChoice::Never,
332 )
333 .unwrap();
334 out
335 }
336
337 #[test]
338 fn renders_header_and_full_chain_without_skipping_levels() {
339 let leaf = FakeError {
341 type_name: "io::Error",
342 variant: "",
343 message: "pool timed out".to_string(),
344 source: None,
345 span_trace: SpanTrace::capture(),
346 };
347 let external: Box<dyn std::error::Error + 'static> =
349 Box::new(std::io::Error::other(leaf.message.clone()));
350 let model = FakeError {
351 type_name: "ModelError",
352 variant: "Database",
353 message: "database call failed".to_string(),
354 source: Some(external),
355 span_trace: SpanTrace::capture(),
356 };
357 let head = FakeError {
358 type_name: "ChatbotError",
359 variant: "StreamingError",
360 message: "stream ended".to_string(),
361 source: Some(Box::new(model)),
362 span_trace: SpanTrace::capture(),
363 };
364
365 let out = render_to_string(&head);
366
367 assert!(
368 out.contains("ChatbotError · StreamingError: stream ended"),
369 "{out}"
370 );
371 assert!(out.contains("caused by:"), "{out}");
372 assert!(
373 out.contains("1. ModelError · Database: database call failed"),
374 "{out}"
375 );
376 assert!(out.contains("2. pool timed out (external)"), "{out}");
378 }
379
380 #[test]
381 fn no_chain_section_without_a_source() {
382 let head = FakeError {
383 type_name: "UtilError",
384 variant: "Other",
385 message: "boom".to_string(),
386 source: None,
387 span_trace: SpanTrace::capture(),
388 };
389 let out = render_to_string(&head);
390 assert!(out.contains("UtilError · Other: boom"), "{out}");
391 assert!(!out.contains("caused by:"), "{out}");
392 }
393}