Skip to main content

rich/
log_render.rs

1//! Rendering log records.
2//!
3//! Rust-native reimagining of `rich/_log_render.py` + `rich/logging.py`.
4//! Upstream integrates with Python's `logging`; [`LogRender`] instead formats a
5//! single log record — an optional time, a severity-colored level, the message,
6//! and an optional source path — into a styled line, using the same column
7//! styles (`log.time`, `logging.level.*`, `log.path`).
8//!
9//! The formatter takes a [`LogLevel`] enum + strings rather than depending on the
10//! `log`/`tracing` crates, keeping the core dependency-light. Wiring it into a
11//! `log::Log` handler is a `rich-ext` follow-up. See docs/DIVERGENCES.md #19.
12
13use crate::console::{Console, ConsoleOptions};
14use crate::measure::Measurement;
15use crate::protocol::Renderable;
16use crate::segment::Segment;
17use crate::style::Style;
18use crate::text::Text;
19
20/// Log severity, mirroring the `log` crate's five levels.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum LogLevel {
23    Trace,
24    Debug,
25    Info,
26    Warn,
27    Error,
28}
29
30impl LogLevel {
31    /// The uppercase name and its style spec (from `logging.level.*`).
32    fn styled(self) -> (&'static str, &'static str) {
33        match self {
34            LogLevel::Trace => ("TRACE", "dim"),
35            LogLevel::Debug => ("DEBUG", "green"),
36            LogLevel::Info => ("INFO", "blue"),
37            LogLevel::Warn => ("WARN", "yellow"),
38            LogLevel::Error => ("ERROR", "bold red"),
39        }
40    }
41}
42
43/// A single formatted log record. Mirrors the role of
44/// `rich._log_render.LogRender` for one row.
45pub struct LogRender {
46    time: Option<String>,
47    level: LogLevel,
48    message: String,
49    path: Option<String>,
50}
51
52impl LogRender {
53    /// A record at `level` with `message`.
54    pub fn new(level: LogLevel, message: impl Into<String>) -> Self {
55        LogRender {
56            time: None,
57            level,
58            message: message.into(),
59            path: None,
60        }
61    }
62
63    /// Add a leading time column (styled `log.time` = dim cyan).
64    pub fn time(mut self, time: impl Into<String>) -> Self {
65        self.time = Some(time.into());
66        self
67    }
68
69    /// Add a trailing source path (styled `log.path` = dim).
70    pub fn path(mut self, path: impl Into<String>) -> Self {
71        self.path = Some(path.into());
72        self
73    }
74
75    fn content(&self) -> Text {
76        let mut text = Text::new("");
77        if let Some(time) = &self.time {
78            text.append(time, Style::parse("dim cyan").ok().map(Into::into));
79            text.append(" ", None);
80        }
81        let (name, spec) = self.level.styled();
82        // Pad the level name so messages line up (level names are ≤ 5 cells).
83        text.append(
84            &format!("{name:<5}"),
85            Style::parse(spec).ok().map(Into::into),
86        );
87        text.append(" ", None);
88        text.append(&self.message, None);
89        if let Some(path) = &self.path {
90            text.append(
91                &format!("  {path}"),
92                Style::parse("dim").ok().map(Into::into),
93            );
94        }
95        text
96    }
97}
98
99impl Renderable for LogRender {
100    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
101        self.content().rich_render(console, options)
102    }
103
104    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
105        self.content().measure(console, options)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::color::ColorSystem;
113
114    fn render(record: &LogRender) -> String {
115        Console::builder()
116            .force_terminal(true)
117            .color_system(Some(ColorSystem::Truecolor))
118            .width(80)
119            .no_color(false)
120            .build()
121            .render_to_string(record)
122    }
123
124    #[test]
125    fn info_record_is_blue_level() {
126        let out = render(&LogRender::new(LogLevel::Info, "server started"));
127        // "INFO " padded, styled blue (34).
128        assert!(out.contains("\x1b[34mINFO \x1b[0m"), "got {out:?}");
129        assert!(out.contains("server started"));
130    }
131
132    #[test]
133    fn error_level_is_bold_red() {
134        let out = render(&LogRender::new(LogLevel::Error, "boom"));
135        assert!(out.contains("\x1b[1;31mERROR\x1b[0m"), "got {out:?}");
136    }
137
138    #[test]
139    fn time_and_path_columns() {
140        let out = render(
141            &LogRender::new(LogLevel::Warn, "low disk")
142                .time("12:00:00")
143                .path("main.rs:42"),
144        );
145        // Time is dim cyan (2;36), path is dim (2).
146        assert!(out.contains("\x1b[2;36m12:00:00\x1b[0m"), "got {out:?}");
147        assert!(out.contains("main.rs:42"));
148        assert!(out.contains("\x1b[33mWARN \x1b[0m"));
149    }
150}