Skip to main content

rich/
traceback.rs

1//! Rendering errors.
2//!
3//! Rust-native reimagining of `rich/traceback.py`. Upstream renders a *Python*
4//! exception traceback (stack frames + highlighted source); Rust errors don't
5//! carry frames, so [`Traceback`] instead renders an error's message and its
6//! [`Error::source`] chain (`Caused by:`) inside a red-bordered panel — the same
7//! "here's what went wrong, clearly" utility.
8//!
9//! **Divergence:** no stack frames / source code (Rust errors don't expose
10//! them). Pair with `std::backtrace::Backtrace` at the call site if you want a
11//! frame list. See docs/DIVERGENCES.md #19.
12
13use std::error::Error;
14
15use crate::console::{Console, ConsoleOptions};
16use crate::panel::Panel;
17use crate::protocol::Renderable;
18use crate::r#box::HEAVY;
19use crate::segment::Segment;
20use crate::style::Style;
21use crate::text::Text;
22
23/// A rendered error: the top-level message plus its `Caused by:` chain. Mirrors
24/// the role of `rich.traceback.Traceback`.
25pub struct Traceback {
26    message: String,
27    causes: Vec<String>,
28}
29
30impl Traceback {
31    /// Build from an error, walking its [`Error::source`] chain.
32    pub fn new(error: &dyn Error) -> Self {
33        let mut causes = Vec::new();
34        let mut source = error.source();
35        while let Some(err) = source {
36            causes.push(err.to_string());
37            source = err.source();
38        }
39        Traceback {
40            message: error.to_string(),
41            causes,
42        }
43    }
44
45    /// Build from a plain message (e.g. a captured panic string), with no chain.
46    pub fn from_message(message: impl Into<String>) -> Self {
47        Traceback {
48            message: message.into(),
49            causes: Vec::new(),
50        }
51    }
52
53    /// The styled inner content: the message, then the `Caused by:` chain.
54    fn content(&self) -> Text {
55        let error_style = Style::parse("bold red").expect("valid style");
56        let label_style = Style::parse("dim").expect("valid style");
57        let cause_style = Style::parse("red").expect("valid style");
58
59        let mut text = Text::new("");
60        text.append(&self.message, Some(error_style.into()));
61        if !self.causes.is_empty() {
62            text.append("\n\nCaused by:", Some(label_style.into()));
63            for (index, cause) in self.causes.iter().enumerate() {
64                text.append(&format!("\n  {}: ", index + 1), None);
65                text.append(cause, Some(cause_style.clone().into()));
66            }
67        }
68        text
69    }
70
71    fn panel(&self) -> Panel {
72        Panel::new(Box::new(self.content()))
73            .box_set(HEAVY)
74            .border_style(Style::parse("red").expect("valid style"))
75            .title("Traceback (most recent call last)")
76    }
77}
78
79impl Renderable for Traceback {
80    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
81        self.panel().rich_render(console, options)
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::color::ColorSystem;
89    use std::fmt;
90
91    #[derive(Debug)]
92    struct Inner;
93    impl fmt::Display for Inner {
94        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95            write!(f, "disk full")
96        }
97    }
98    impl Error for Inner {}
99
100    #[derive(Debug)]
101    struct Outer;
102    static INNER: Inner = Inner;
103    impl fmt::Display for Outer {
104        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105            write!(f, "failed to save file")
106        }
107    }
108    impl Error for Outer {
109        fn source(&self) -> Option<&(dyn Error + 'static)> {
110            Some(&INNER)
111        }
112    }
113
114    fn render(tb: &Traceback) -> String {
115        Console::builder()
116            .force_terminal(true)
117            .color_system(Some(ColorSystem::Truecolor))
118            .width(40)
119            .no_color(false)
120            .build()
121            .render_to_string(tb)
122    }
123
124    #[test]
125    fn renders_error_chain() {
126        let out = render(&Traceback::new(&Outer));
127        assert!(out.contains("Traceback (most recent call last)"));
128        assert!(out.contains("failed to save file"));
129        assert!(out.contains("Caused by:"));
130        assert!(out.contains("disk full"));
131        // Red bordered (SGR 31 present).
132        assert!(out.contains("\x1b[31m"), "expected red border/message");
133    }
134
135    #[test]
136    fn from_message_has_no_chain() {
137        let out = render(&Traceback::from_message("something broke"));
138        assert!(out.contains("something broke"));
139        assert!(!out.contains("Caused by:"));
140    }
141}