Skip to main content

hyperi_rustlib/logger/
format.rs

1// Project:   hyperi-rustlib
2// File:      src/logger/format.rs
3// Purpose:   Coloured log output formatter using owo-colors
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Custom coloured log formatter for terminal output.
10//!
11//! Provides a [`ColouredFormatter`] implementing tracing-subscriber's
12//! `FormatEvent` trait with HyperI's standard colour scheme:
13//!
14//! - **Timestamp:** dim
15//! - **Level:** ERROR=red bold, WARN=yellow, INFO=green, DEBUG=blue, TRACE=magenta dim
16//! - **Target:** cyan dim
17//! - **Source location:** dim
18//! - **Field names:** bold
19//! - **Message and values:** default
20
21use std::fmt;
22
23use owo_colors::{OwoColorize, Style};
24use tracing::Level;
25use tracing_subscriber::fmt::format::Writer;
26use tracing_subscriber::fmt::time::{FormatTime, UtcTime};
27use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
28use tracing_subscriber::registry::LookupSpan;
29
30/// Coloured log event formatter for terminal output.
31///
32/// When `enable_ansi` is false, outputs plain text without ANSI codes.
33#[derive(Debug, Clone)]
34#[allow(clippy::struct_excessive_bools)]
35pub struct ColouredFormatter {
36    enable_ansi: bool,
37    display_target: bool,
38    display_file: bool,
39    display_line_number: bool,
40}
41
42impl ColouredFormatter {
43    /// Create a new coloured formatter.
44    #[must_use]
45    pub fn new(enable_ansi: bool) -> Self {
46        Self {
47            enable_ansi,
48            display_target: true,
49            display_file: true,
50            display_line_number: true,
51        }
52    }
53
54    /// Set whether to display source file name.
55    #[must_use]
56    pub fn with_file(mut self, display: bool) -> Self {
57        self.display_file = display;
58        self
59    }
60
61    /// Set whether to display source line number.
62    #[must_use]
63    pub fn with_line_number(mut self, display: bool) -> Self {
64        self.display_line_number = display;
65        self
66    }
67}
68
69impl<S, N> FormatEvent<S, N> for ColouredFormatter
70where
71    S: tracing::Subscriber + for<'a> LookupSpan<'a>,
72    N: for<'a> FormatFields<'a> + 'static,
73{
74    fn format_event(
75        &self,
76        ctx: &FmtContext<'_, S, N>,
77        mut writer: Writer<'_>,
78        event: &tracing::Event<'_>,
79    ) -> fmt::Result {
80        let meta = event.metadata();
81        let ansi = self.enable_ansi && writer.has_ansi_escapes();
82
83        // Timestamp (dim)
84        let timer = UtcTime::rfc_3339();
85        let mut ts_buf = String::new();
86        let _ = timer.format_time(&mut Writer::new(&mut ts_buf));
87        if ansi {
88            write!(writer, "{} ", ts_buf.style(dim_style()))?;
89        } else {
90            write!(writer, "{ts_buf} ")?;
91        }
92
93        // Level (coloured)
94        let level = meta.level();
95        let level_str = format!("{level:>5}");
96        if ansi {
97            write!(writer, "{} ", level_str.style(level_style(*level)))?;
98        } else {
99            write!(writer, "{level_str} ")?;
100        }
101
102        // Target (cyan dim)
103        if self.display_target {
104            let target = meta.target();
105            if ansi {
106                write!(writer, "{}:", target.style(target_style()))?;
107            } else {
108                write!(writer, "{target}:")?;
109            }
110        }
111
112        // Span context
113        if let Some(scope) = ctx.event_scope() {
114            for span in scope.from_root() {
115                if ansi {
116                    write!(writer, "{}", span.name().style(span_style()))?;
117                } else {
118                    write!(writer, "{}", span.name())?;
119                }
120                let ext = span.extensions();
121                if let Some(fields) = ext.get::<tracing_subscriber::fmt::FormattedFields<N>>()
122                    && !fields.is_empty()
123                {
124                    write!(writer, "{{{fields}}}")?;
125                }
126                write!(writer, ":")?;
127            }
128        }
129
130        // Space before message
131        write!(writer, " ")?;
132
133        // Event fields (message + structured fields)
134        ctx.format_fields(writer.by_ref(), event)?;
135
136        // Source location (dim)
137        if self.display_file || self.display_line_number {
138            let file = meta.file();
139            let line = meta.line();
140            match (self.display_file, self.display_line_number, file, line) {
141                (true, true, Some(f), Some(l)) => {
142                    let loc = format!(" {f}:{l}");
143                    if ansi {
144                        write!(writer, "{}", loc.style(dim_style()))?;
145                    } else {
146                        write!(writer, "{loc}")?;
147                    }
148                }
149                (true, _, Some(f), _) => {
150                    let loc = format!(" {f}");
151                    if ansi {
152                        write!(writer, "{}", loc.style(dim_style()))?;
153                    } else {
154                        write!(writer, "{loc}")?;
155                    }
156                }
157                (_, true, _, Some(l)) => {
158                    let loc = format!(" :{l}");
159                    if ansi {
160                        write!(writer, "{}", loc.style(dim_style()))?;
161                    } else {
162                        write!(writer, "{loc}")?;
163                    }
164                }
165                _ => {}
166            }
167        }
168
169        writeln!(writer)
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Style helpers
175// ---------------------------------------------------------------------------
176
177fn level_style(level: Level) -> Style {
178    match level {
179        Level::ERROR => Style::new().red().bold(),
180        Level::WARN => Style::new().yellow(),
181        Level::INFO => Style::new().green(),
182        Level::DEBUG => Style::new().blue(),
183        Level::TRACE => Style::new().magenta().dimmed(),
184    }
185}
186
187fn dim_style() -> Style {
188    Style::new().dimmed()
189}
190
191fn target_style() -> Style {
192    Style::new().cyan().dimmed()
193}
194
195fn span_style() -> Style {
196    Style::new().bold()
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_level_style_returns_distinct_styles() {
205        // Verify each level produces a style (no panics)
206        let _ = level_style(Level::ERROR);
207        let _ = level_style(Level::WARN);
208        let _ = level_style(Level::INFO);
209        let _ = level_style(Level::DEBUG);
210        let _ = level_style(Level::TRACE);
211    }
212
213    #[test]
214    fn test_coloured_formatter_builder() {
215        let fmt = ColouredFormatter::new(true)
216            .with_file(false)
217            .with_line_number(false);
218
219        assert!(fmt.enable_ansi);
220        assert!(fmt.display_target);
221        assert!(!fmt.display_file);
222        assert!(!fmt.display_line_number);
223    }
224}