Skip to main content

rusty_rich/
log_render.rs

1//! Log record rendering — equivalent to Rich's `_log_render.py`.
2//!
3//! Provides the [`LogRender`] class for formatting log records into Rich
4//! tables with columns for timestamp, level, message, and file path.
5//!
6//! Unlike [`RichHandler`] (which is a handler for the `log` crate),
7//! `LogRender` is a standalone formatter that can be used with any logging
8//! framework to produce styled terminal output.
9
10use crate::color::Color;
11use crate::console::{ConsoleOptions, RenderResult, Renderable};
12use crate::segment::Segment;
13use crate::style::Style;
14use crate::table::{Column, Table};
15
16// ---------------------------------------------------------------------------
17// LogRender
18// ---------------------------------------------------------------------------
19
20/// A standalone formatter for rendering log records as Rich-styled tables.
21///
22/// Configurable with column visibility, time format, and level width.
23/// Produces a [`Table`] with columns for time, level, message, and path.
24///
25/// # Examples
26///
27/// ```rust,no_run
28/// use rusty_rich::log_render::LogRender;
29///
30/// let mut renderer = LogRender::new()
31///     .show_time(true)
32///     .show_level(true)
33///     .show_path(true);
34///
35/// let output = renderer.render_log(
36///     Some("2024-01-15T10:30:00"),
37///     "INFO",
38///     "Server started successfully",
39///     Some("src/main.rs"),
40///     Some(42),
41/// );
42/// ```
43#[derive(Debug, Clone)]
44pub struct LogRender {
45    /// Whether to show the timestamp column.
46    show_time: bool,
47    /// Whether to show the log level column.
48    show_level: bool,
49    /// Whether to show the file path column.
50    show_path: bool,
51    /// Time format string (strftime-style, not parsed — passed through as-is).
52    time_format: String,
53    /// Width of the level column in characters.
54    level_width: usize,
55    /// If true, omit timestamps when they repeat across consecutive records.
56    omit_repeated_times: bool,
57    /// The last-rendered timestamp (used for dedup).
58    last_time: Option<String>,
59}
60
61impl LogRender {
62    /// Create a new `LogRender` with default settings.
63    pub fn new() -> Self {
64        Self {
65            show_time: true,
66            show_level: true,
67            show_path: true,
68            time_format: "[%x %X]".to_string(),
69            level_width: 8,
70            omit_repeated_times: true,
71            last_time: None,
72        }
73    }
74
75    /// Builder: show or hide the timestamp column.
76    pub fn show_time(mut self, value: bool) -> Self {
77        self.show_time = value;
78        self
79    }
80
81    /// Builder: show or hide the level column.
82    pub fn show_level(mut self, value: bool) -> Self {
83        self.show_level = value;
84        self
85    }
86
87    /// Builder: show or hide the file path column.
88    pub fn show_path(mut self, value: bool) -> Self {
89        self.show_path = value;
90        self
91    }
92
93    /// Builder: set the time format string (displayed as-is in column header).
94    pub fn time_format(mut self, format: impl Into<String>) -> Self {
95        self.time_format = format.into();
96        self
97    }
98
99    /// Builder: set the minimum width of the level column.
100    pub fn level_width(mut self, width: usize) -> Self {
101        self.level_width = width;
102        self
103    }
104
105    /// Builder: enable or disable omitting repeated timestamps.
106    pub fn omit_repeated_times(mut self, value: bool) -> Self {
107        self.omit_repeated_times = value;
108        self
109    }
110
111    /// Get the Rich style for a given log level name.
112    ///
113    /// Looks up `"logging.level.<lowercase_level>"` in the default theme.
114    pub fn get_level_style(level: &str) -> Style {
115        use crate::theme::default_theme;
116        let theme = default_theme();
117        let key = format!("logging.level.{}", level.to_lowercase());
118        theme
119            .get(&key)
120            .cloned()
121            .unwrap_or_else(|| match level.to_lowercase().as_str() {
122                "debug" => Style::new().color(
123                    crate::color::Color::parse("bright_black").unwrap_or_else(|_| Color::default()),
124                ),
125                "info" => Style::new().color(
126                    crate::color::Color::parse("bright_cyan").unwrap_or_else(|_| Color::default()),
127                ),
128                "warning" => Style::new().color(
129                    crate::color::Color::parse("bright_yellow")
130                        .unwrap_or_else(|_| Color::default()),
131                ),
132                "error" => Style::new()
133                    .color(
134                        crate::color::Color::parse("bright_red")
135                            .unwrap_or_else(|_| Color::default()),
136                    )
137                    .bold(true),
138                "critical" => Style::new()
139                    .color(crate::color::Color::parse("red").unwrap_or_else(|_| Color::default()))
140                    .bold(true)
141                    .reverse(true),
142                _ => Style::new(),
143            })
144    }
145
146    /// Get the style for the time column.
147    pub fn get_time_style() -> Style {
148        Style::new()
149            .color(crate::color::Color::parse("bright_black").unwrap_or_else(|_| Color::default()))
150    }
151
152    /// Get the style for the message column.
153    pub fn get_message_style() -> Style {
154        Style::new()
155    }
156
157    /// Get the style for the path column.
158    pub fn get_path_style() -> Style {
159        Style::new()
160            .color(crate::color::Color::parse("bright_black").unwrap_or_else(|_| Color::default()))
161    }
162
163    /// Format a single log record and return a [`LogRecord`] renderable.
164    ///
165    /// Parameters:
166    /// - `time`: the timestamp string (or `None`)
167    /// - `level`: the log level name (e.g. `"INFO"`, `"ERROR"`)
168    /// - `message`: the log message content
169    /// - `path`: the source file path (or `None`)
170    /// - `line_no`: the source line number (or `None`)
171    pub fn render_log(
172        &mut self,
173        time: Option<&str>,
174        level: &str,
175        message: &str,
176        path: Option<&str>,
177        line_no: Option<u32>,
178    ) -> LogRecord {
179        // Handle timestamp dedup
180        let time_str = if self.show_time {
181            let ts = time.unwrap_or("");
182            if self.omit_repeated_times {
183                if let Some(ref last) = self.last_time {
184                    if last == ts {
185                        "".to_string()
186                    } else {
187                        self.last_time = Some(ts.to_string());
188                        ts.to_string()
189                    }
190                } else {
191                    self.last_time = Some(ts.to_string());
192                    ts.to_string()
193                }
194            } else {
195                ts.to_string()
196            }
197        } else {
198            String::new()
199        };
200
201        // Format path + line
202        let path_str = if self.show_path {
203            match (path, line_no) {
204                (Some(p), Some(l)) => format!("{p}:{l}"),
205                (Some(p), None) => p.to_string(),
206                (None, Some(l)) => format!("<unknown>:{l}"),
207                (None, None) => String::new(),
208            }
209        } else {
210            String::new()
211        };
212
213        // Pad level to level_width
214        let padded_level = if self.show_level {
215            format!("{level:>width$}", width = self.level_width)
216        } else {
217            String::new()
218        };
219
220        LogRecord {
221            time: time_str,
222            level: padded_level,
223            message: message.to_string(),
224            path: path_str,
225            show_time: self.show_time,
226            show_level: self.show_level,
227            show_path: self.show_path,
228        }
229    }
230
231    /// Render multiple log records as a single table.
232    #[allow(clippy::type_complexity)]
233    pub fn render_batch(
234        &mut self,
235        records: &[(Option<&str>, &str, &str, Option<&str>, Option<u32>)],
236    ) -> LogTable {
237        let rendered: Vec<LogRecord> = records
238            .iter()
239            .map(|(time, level, msg, path, line)| self.render_log(*time, level, msg, *path, *line))
240            .collect();
241        LogTable { records: rendered }
242    }
243
244    /// Reset the last-time cache (e.g. between rendering sessions).
245    pub fn reset_time_cache(&mut self) {
246        self.last_time = None;
247    }
248}
249
250impl Default for LogRender {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256// ---------------------------------------------------------------------------
257// LogRecord — a single formatted log entry renderable
258// ---------------------------------------------------------------------------
259
260/// A single formatted log record, ready for rendering.
261#[derive(Debug, Clone)]
262pub struct LogRecord {
263    time: String,
264    level: String,
265    message: String,
266    path: String,
267    show_time: bool,
268    show_level: bool,
269    show_path: bool,
270}
271
272impl Renderable for LogRecord {
273    fn render(&self, _options: &ConsoleOptions) -> RenderResult {
274        let time_style = LogRender::get_time_style();
275        let level_style = LogRender::get_level_style(self.level.trim());
276        let msg_style = LogRender::get_message_style();
277        let path_style = LogRender::get_path_style();
278
279        let mut line: Vec<Segment> = Vec::new();
280
281        if self.show_time && !self.time.is_empty() {
282            line.push(Segment::styled(&self.time, time_style.clone()));
283            line.push(Segment::new(" "));
284        }
285
286        if self.show_level && !self.level.is_empty() {
287            line.push(Segment::styled(&self.level, level_style));
288            line.push(Segment::new(" "));
289        }
290
291        line.push(Segment::styled(&self.message, msg_style));
292
293        if self.show_path && !self.path.is_empty() {
294            line.push(Segment::new(" "));
295            line.push(Segment::styled(&self.path, path_style));
296        }
297
298        line.push(Segment::line());
299
300        RenderResult {
301            lines: vec![line],
302            items: Vec::new(),
303        }
304    }
305}
306
307// ---------------------------------------------------------------------------
308// LogTable — multiple log records as a table
309// ---------------------------------------------------------------------------
310
311/// A collection of [`LogRecord`]s rendered as a Rich table.
312#[derive(Debug, Clone)]
313pub struct LogTable {
314    records: Vec<LogRecord>,
315}
316
317impl Renderable for LogTable {
318    fn render(&self, options: &ConsoleOptions) -> RenderResult {
319        if self.records.is_empty() {
320            return RenderResult {
321                lines: Vec::new(),
322                items: Vec::new(),
323            };
324        }
325
326        let mut table = Table::new();
327        table.show_header = false;
328        table.show_edge = false;
329        table.show_lines = false;
330
331        // Determine which columns are needed (based on first record)
332        let first = &self.records[0];
333        if first.show_time {
334            table.add_column(Column::new("Time"));
335        }
336        if first.show_level {
337            table.add_column(Column::new("Level"));
338        }
339        table.add_column(Column::new("Message"));
340        if first.show_path {
341            table.add_column(Column::new("Path"));
342        }
343
344        for record in &self.records {
345            let mut cells: Vec<crate::table::Cell> = Vec::new();
346
347            if record.show_time {
348                let time_str = if record.time.is_empty() {
349                    String::new()
350                } else {
351                    LogRender::get_time_style().render(&record.time)
352                };
353                cells.push(crate::table::Cell::new(time_str));
354            }
355
356            if record.show_level {
357                let level_str =
358                    LogRender::get_level_style(record.level.trim()).render(&record.level);
359                cells.push(crate::table::Cell::new(level_str));
360            }
361
362            cells.push(crate::table::Cell::new(
363                LogRender::get_message_style().render(&record.message),
364            ));
365
366            if record.show_path && !record.path.is_empty() {
367                cells.push(crate::table::Cell::new(
368                    LogRender::get_path_style().render(&record.path),
369                ));
370            }
371
372            table.add_row(cells);
373        }
374
375        table.render(options)
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_log_render_defaults() {
385        let lr = LogRender::new();
386        assert!(lr.show_time);
387        assert!(lr.show_level);
388        assert!(lr.show_path);
389    }
390
391    #[test]
392    fn test_log_render_builder() {
393        let lr = LogRender::new()
394            .show_time(false)
395            .show_level(false)
396            .show_path(false)
397            .level_width(10)
398            .omit_repeated_times(false);
399        assert!(!lr.show_time);
400        assert!(!lr.show_level);
401        assert!(!lr.show_path);
402        assert_eq!(lr.level_width, 10);
403        assert!(!lr.omit_repeated_times);
404    }
405
406    #[test]
407    fn test_log_render_single() {
408        let mut lr = LogRender::new();
409        let record = lr.render_log(
410            Some("2024-01-15 10:30:00"),
411            "INFO",
412            "Hello world",
413            Some("src/main.rs"),
414            Some(42),
415        );
416        let opts = ConsoleOptions::default();
417        let result = record.render(&opts);
418        let ansi = result.to_ansi();
419        assert!(ansi.contains("Hello world"));
420    }
421
422    #[test]
423    fn test_log_render_no_path() {
424        let mut lr = LogRender::new().show_path(false);
425        let record = lr.render_log(None, "DEBUG", "debug message", None, None);
426        let opts = ConsoleOptions::default();
427        let result = record.render(&opts);
428        let ansi = result.to_ansi();
429        assert!(ansi.contains("debug message"));
430    }
431
432    #[test]
433    fn test_log_render_level_styles() {
434        let debug_style = LogRender::get_level_style("DEBUG");
435        let info_style = LogRender::get_level_style("INFO");
436        let warn_style = LogRender::get_level_style("WARNING");
437        let error_style = LogRender::get_level_style("ERROR");
438        let critical_style = LogRender::get_level_style("CRITICAL");
439        // All should produce valid styles (not panic)
440        assert!(!debug_style.is_null() || true);
441        assert!(!info_style.is_null() || true);
442        assert!(!warn_style.is_null() || true);
443        assert!(!error_style.is_null() || true);
444        assert!(!critical_style.is_null() || true);
445    }
446
447    #[test]
448    fn test_log_render_batch() {
449        let mut lr = LogRender::new().show_path(false).show_time(false);
450        let records = vec![
451            (None, "INFO", "first", None, None),
452            (None, "ERROR", "second", None, None),
453        ];
454        let table = lr.render_batch(&records);
455        let opts = ConsoleOptions::default();
456        let result = table.render(&opts);
457        let ansi = result.to_ansi();
458        assert!(ansi.contains("first"));
459        assert!(ansi.contains("second"));
460    }
461
462    #[test]
463    fn test_log_render_time_dedup() {
464        let mut lr = LogRender::new().show_path(false).show_level(false);
465        let r1 = lr.render_log(Some("2024-01-01"), "INFO", "msg1", None, None);
466        let r2 = lr.render_log(Some("2024-01-01"), "INFO", "msg2", None, None);
467        // Second should have empty time (dedup)
468        assert!(!r1.time.is_empty());
469        assert!(r2.time.is_empty());
470    }
471
472    #[test]
473    fn test_log_render_reset_cache() {
474        let mut lr = LogRender::new().show_path(false).show_level(false);
475        lr.render_log(Some("ts"), "INFO", "msg1", None, None);
476        lr.reset_time_cache();
477        let r = lr.render_log(Some("ts"), "INFO", "msg2", None, None);
478        // After reset, timestamp should appear again
479        assert!(!r.time.is_empty());
480    }
481}