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,ignore
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.get(&key).cloned().unwrap_or_else(|| match level.to_lowercase().as_str() {
119            "debug" => Style::new().color(
120                crate::color::Color::parse("bright_black").unwrap_or_else(|_| Color::default()),
121            ),
122            "info" => Style::new().color(
123                crate::color::Color::parse("bright_cyan").unwrap_or_else(|_| Color::default()),
124            ),
125            "warning" => Style::new().color(
126                crate::color::Color::parse("bright_yellow").unwrap_or_else(|_| Color::default()),
127            ),
128            "error" => Style::new().color(
129                crate::color::Color::parse("bright_red").unwrap_or_else(|_| Color::default()),
130            ).bold(true),
131            "critical" => Style::new().color(
132                crate::color::Color::parse("red").unwrap_or_else(|_| Color::default()),
133            ).bold(true).reverse(true),
134            _ => Style::new(),
135        })
136    }
137
138    /// Get the style for the time column.
139    pub fn get_time_style() -> Style {
140        Style::new().color(
141            crate::color::Color::parse("bright_black").unwrap_or_else(|_| Color::default()),
142        )
143    }
144
145    /// Get the style for the message column.
146    pub fn get_message_style() -> Style {
147        Style::new()
148    }
149
150    /// Get the style for the path column.
151    pub fn get_path_style() -> Style {
152        Style::new().color(
153            crate::color::Color::parse("bright_black").unwrap_or_else(|_| Color::default()),
154        )
155    }
156
157    /// Format a single log record and return a [`LogRecord`] renderable.
158    ///
159    /// Parameters:
160    /// - `time`: the timestamp string (or `None`)
161    /// - `level`: the log level name (e.g. `"INFO"`, `"ERROR"`)
162    /// - `message`: the log message content
163    /// - `path`: the source file path (or `None`)
164    /// - `line_no`: the source line number (or `None`)
165    pub fn render_log(
166        &mut self,
167        time: Option<&str>,
168        level: &str,
169        message: &str,
170        path: Option<&str>,
171        line_no: Option<u32>,
172    ) -> LogRecord {
173        // Handle timestamp dedup
174        let time_str = if self.show_time {
175            let ts = time.unwrap_or("");
176            if self.omit_repeated_times {
177                if let Some(ref last) = self.last_time {
178                    if last == ts {
179                        "".to_string()
180                    } else {
181                        self.last_time = Some(ts.to_string());
182                        ts.to_string()
183                    }
184                } else {
185                    self.last_time = Some(ts.to_string());
186                    ts.to_string()
187                }
188            } else {
189                ts.to_string()
190            }
191        } else {
192            String::new()
193        };
194
195        // Format path + line
196        let path_str = if self.show_path {
197            match (path, line_no) {
198                (Some(p), Some(l)) => format!("{p}:{l}"),
199                (Some(p), None) => p.to_string(),
200                (None, Some(l)) => format!("<unknown>:{l}"),
201                (None, None) => String::new(),
202            }
203        } else {
204            String::new()
205        };
206
207        // Pad level to level_width
208        let padded_level = if self.show_level {
209            format!("{level:>width$}", width = self.level_width)
210        } else {
211            String::new()
212        };
213
214        LogRecord {
215            time: time_str,
216            level: padded_level,
217            message: message.to_string(),
218            path: path_str,
219            show_time: self.show_time,
220            show_level: self.show_level,
221            show_path: self.show_path,
222        }
223    }
224
225    /// Render multiple log records as a single table.
226    pub fn render_batch(
227        &mut self,
228        records: &[(Option<&str>, &str, &str, Option<&str>, Option<u32>)],
229    ) -> LogTable {
230        let rendered: Vec<LogRecord> = records
231            .iter()
232            .map(|(time, level, msg, path, line)| {
233                self.render_log(*time, level, msg, *path, *line)
234            })
235            .collect();
236        LogTable { records: rendered }
237    }
238
239    /// Reset the last-time cache (e.g. between rendering sessions).
240    pub fn reset_time_cache(&mut self) {
241        self.last_time = None;
242    }
243}
244
245impl Default for LogRender {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251// ---------------------------------------------------------------------------
252// LogRecord — a single formatted log entry renderable
253// ---------------------------------------------------------------------------
254
255/// A single formatted log record, ready for rendering.
256#[derive(Debug, Clone)]
257pub struct LogRecord {
258    time: String,
259    level: String,
260    message: String,
261    path: String,
262    show_time: bool,
263    show_level: bool,
264    show_path: bool,
265}
266
267impl Renderable for LogRecord {
268    fn render(&self, _options: &ConsoleOptions) -> RenderResult {
269        let time_style = LogRender::get_time_style();
270        let level_style = LogRender::get_level_style(&self.level.trim());
271        let msg_style = LogRender::get_message_style();
272        let path_style = LogRender::get_path_style();
273
274        let mut line: Vec<Segment> = Vec::new();
275
276        if self.show_time && !self.time.is_empty() {
277            line.push(Segment::styled(&self.time, time_style.clone()));
278            line.push(Segment::new(" "));
279        }
280
281        if self.show_level && !self.level.is_empty() {
282            line.push(Segment::styled(&self.level, level_style));
283            line.push(Segment::new(" "));
284        }
285
286        line.push(Segment::styled(&self.message, msg_style));
287
288        if self.show_path && !self.path.is_empty() {
289            line.push(Segment::new(" "));
290            line.push(Segment::styled(&self.path, path_style));
291        }
292
293        line.push(Segment::line());
294
295        RenderResult {
296            lines: vec![line],
297            items: Vec::new(),
298        }
299    }
300}
301
302// ---------------------------------------------------------------------------
303// LogTable — multiple log records as a table
304// ---------------------------------------------------------------------------
305
306/// A collection of [`LogRecord`]s rendered as a Rich table.
307#[derive(Debug, Clone)]
308pub struct LogTable {
309    records: Vec<LogRecord>,
310}
311
312impl Renderable for LogTable {
313    fn render(&self, options: &ConsoleOptions) -> RenderResult {
314        if self.records.is_empty() {
315            return RenderResult {
316                lines: Vec::new(),
317                items: Vec::new(),
318            };
319        }
320
321        let mut table = Table::new();
322        table.show_header = false;
323        table.show_edge = false;
324        table.show_lines = false;
325
326        // Determine which columns are needed (based on first record)
327        let first = &self.records[0];
328        if first.show_time {
329            table.add_column(Column::new("Time"));
330        }
331        if first.show_level {
332            table.add_column(Column::new("Level"));
333        }
334        table.add_column(Column::new("Message"));
335        if first.show_path {
336            table.add_column(Column::new("Path"));
337        }
338
339        for record in &self.records {
340            let mut cells: Vec<crate::table::Cell> = Vec::new();
341
342            if record.show_time {
343                let time_str = if record.time.is_empty() {
344                    String::new()
345                } else {
346                    LogRender::get_time_style().render(&record.time)
347                };
348                cells.push(crate::table::Cell::new(time_str));
349            }
350
351            if record.show_level {
352                let level_str = LogRender::get_level_style(record.level.trim()).render(&record.level);
353                cells.push(crate::table::Cell::new(level_str));
354            }
355
356            cells.push(crate::table::Cell::new(
357                LogRender::get_message_style().render(&record.message),
358            ));
359
360            if record.show_path && !record.path.is_empty() {
361                cells.push(crate::table::Cell::new(
362                    LogRender::get_path_style().render(&record.path),
363                ));
364            }
365
366            table.add_row(cells);
367        }
368
369        table.render(options)
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn test_log_render_defaults() {
379        let lr = LogRender::new();
380        assert!(lr.show_time);
381        assert!(lr.show_level);
382        assert!(lr.show_path);
383    }
384
385    #[test]
386    fn test_log_render_builder() {
387        let lr = LogRender::new()
388            .show_time(false)
389            .show_level(false)
390            .show_path(false)
391            .level_width(10)
392            .omit_repeated_times(false);
393        assert!(!lr.show_time);
394        assert!(!lr.show_level);
395        assert!(!lr.show_path);
396        assert_eq!(lr.level_width, 10);
397        assert!(!lr.omit_repeated_times);
398    }
399
400    #[test]
401    fn test_log_render_single() {
402        let mut lr = LogRender::new();
403        let record = lr.render_log(
404            Some("2024-01-15 10:30:00"),
405            "INFO",
406            "Hello world",
407            Some("src/main.rs"),
408            Some(42),
409        );
410        let opts = ConsoleOptions::default();
411        let result = record.render(&opts);
412        let ansi = result.to_ansi();
413        assert!(ansi.contains("Hello world"));
414    }
415
416    #[test]
417    fn test_log_render_no_path() {
418        let mut lr = LogRender::new().show_path(false);
419        let record = lr.render_log(None, "DEBUG", "debug message", None, None);
420        let opts = ConsoleOptions::default();
421        let result = record.render(&opts);
422        let ansi = result.to_ansi();
423        assert!(ansi.contains("debug message"));
424    }
425
426    #[test]
427    fn test_log_render_level_styles() {
428        let debug_style = LogRender::get_level_style("DEBUG");
429        let info_style = LogRender::get_level_style("INFO");
430        let warn_style = LogRender::get_level_style("WARNING");
431        let error_style = LogRender::get_level_style("ERROR");
432        let critical_style = LogRender::get_level_style("CRITICAL");
433        // All should produce valid styles (not panic)
434        assert!(!debug_style.is_null() || true);
435        assert!(!info_style.is_null() || true);
436        assert!(!warn_style.is_null() || true);
437        assert!(!error_style.is_null() || true);
438        assert!(!critical_style.is_null() || true);
439    }
440
441    #[test]
442    fn test_log_render_batch() {
443        let mut lr = LogRender::new().show_path(false).show_time(false);
444        let records = vec![
445            (None, "INFO", "first", None, None),
446            (None, "ERROR", "second", None, None),
447        ];
448        let table = lr.render_batch(&records);
449        let opts = ConsoleOptions::default();
450        let result = table.render(&opts);
451        let ansi = result.to_ansi();
452        assert!(ansi.contains("first"));
453        assert!(ansi.contains("second"));
454    }
455
456    #[test]
457    fn test_log_render_time_dedup() {
458        let mut lr = LogRender::new().show_path(false).show_level(false);
459        let r1 = lr.render_log(Some("2024-01-01"), "INFO", "msg1", None, None);
460        let r2 = lr.render_log(Some("2024-01-01"), "INFO", "msg2", None, None);
461        // Second should have empty time (dedup)
462        assert!(!r1.time.is_empty());
463        assert!(r2.time.is_empty());
464    }
465
466    #[test]
467    fn test_log_render_reset_cache() {
468        let mut lr = LogRender::new().show_path(false).show_level(false);
469        lr.render_log(Some("ts"), "INFO", "msg1", None, None);
470        lr.reset_time_cache();
471        let r = lr.render_log(Some("ts"), "INFO", "msg2", None, None);
472        // After reset, timestamp should appear again
473        assert!(!r.time.is_empty());
474    }
475}