|
| 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 | +
|
| 21 | +use std::fmt; |
| 22 | + |
| 23 | +use owo_colors::{OwoColorize, Style}; |
| 24 | +use tracing::Level; |
| 25 | +use tracing_subscriber::fmt::format::Writer; |
| 26 | +use tracing_subscriber::fmt::time::{FormatTime, UtcTime}; |
| 27 | +use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields}; |
| 28 | +use 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)] |
| 35 | +pub struct ColouredFormatter { |
| 36 | + enable_ansi: bool, |
| 37 | + display_target: bool, |
| 38 | + display_file: bool, |
| 39 | + display_line_number: bool, |
| 40 | +} |
| 41 | + |
| 42 | +impl 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 | + |
| 69 | +impl<S, N> FormatEvent<S, N> for ColouredFormatter |
| 70 | +where |
| 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) = |
| 122 | + ext.get::<tracing_subscriber::fmt::FormattedFields<N>>() |
| 123 | + { |
| 124 | + if !fields.is_empty() { |
| 125 | + write!(writer, "{{{fields}}}")?; |
| 126 | + } |
| 127 | + } |
| 128 | + write!(writer, ":")?; |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + // Space before message |
| 133 | + write!(writer, " ")?; |
| 134 | + |
| 135 | + // Event fields (message + structured fields) |
| 136 | + ctx.format_fields(writer.by_ref(), event)?; |
| 137 | + |
| 138 | + // Source location (dim) |
| 139 | + if self.display_file || self.display_line_number { |
| 140 | + let file = meta.file(); |
| 141 | + let line = meta.line(); |
| 142 | + match (self.display_file, self.display_line_number, file, line) { |
| 143 | + (true, true, Some(f), Some(l)) => { |
| 144 | + let loc = format!(" {f}:{l}"); |
| 145 | + if ansi { |
| 146 | + write!(writer, "{}", loc.style(dim_style()))?; |
| 147 | + } else { |
| 148 | + write!(writer, "{loc}")?; |
| 149 | + } |
| 150 | + } |
| 151 | + (true, _, Some(f), _) => { |
| 152 | + let loc = format!(" {f}"); |
| 153 | + if ansi { |
| 154 | + write!(writer, "{}", loc.style(dim_style()))?; |
| 155 | + } else { |
| 156 | + write!(writer, "{loc}")?; |
| 157 | + } |
| 158 | + } |
| 159 | + (_, true, _, Some(l)) => { |
| 160 | + let loc = format!(" :{l}"); |
| 161 | + if ansi { |
| 162 | + write!(writer, "{}", loc.style(dim_style()))?; |
| 163 | + } else { |
| 164 | + write!(writer, "{loc}")?; |
| 165 | + } |
| 166 | + } |
| 167 | + _ => {} |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + writeln!(writer) |
| 172 | + } |
| 173 | +} |
| 174 | + |
| 175 | +// --------------------------------------------------------------------------- |
| 176 | +// Style helpers |
| 177 | +// --------------------------------------------------------------------------- |
| 178 | + |
| 179 | +fn level_style(level: Level) -> Style { |
| 180 | + match level { |
| 181 | + Level::ERROR => Style::new().red().bold(), |
| 182 | + Level::WARN => Style::new().yellow(), |
| 183 | + Level::INFO => Style::new().green(), |
| 184 | + Level::DEBUG => Style::new().blue(), |
| 185 | + Level::TRACE => Style::new().magenta().dimmed(), |
| 186 | + } |
| 187 | +} |
| 188 | + |
| 189 | +fn dim_style() -> Style { |
| 190 | + Style::new().dimmed() |
| 191 | +} |
| 192 | + |
| 193 | +fn target_style() -> Style { |
| 194 | + Style::new().cyan().dimmed() |
| 195 | +} |
| 196 | + |
| 197 | +fn span_style() -> Style { |
| 198 | + Style::new().bold() |
| 199 | +} |
| 200 | + |
| 201 | +#[cfg(test)] |
| 202 | +mod tests { |
| 203 | + use super::*; |
| 204 | + |
| 205 | + #[test] |
| 206 | + fn test_level_style_returns_distinct_styles() { |
| 207 | + // Verify each level produces a style (no panics) |
| 208 | + let _ = level_style(Level::ERROR); |
| 209 | + let _ = level_style(Level::WARN); |
| 210 | + let _ = level_style(Level::INFO); |
| 211 | + let _ = level_style(Level::DEBUG); |
| 212 | + let _ = level_style(Level::TRACE); |
| 213 | + } |
| 214 | + |
| 215 | + #[test] |
| 216 | + fn test_coloured_formatter_builder() { |
| 217 | + let fmt = ColouredFormatter::new(true) |
| 218 | + .with_file(false) |
| 219 | + .with_line_number(false); |
| 220 | + |
| 221 | + assert!(fmt.enable_ansi); |
| 222 | + assert!(fmt.display_target); |
| 223 | + assert!(!fmt.display_file); |
| 224 | + assert!(!fmt.display_line_number); |
| 225 | + } |
| 226 | +} |
0 commit comments