Skip to main content

pylon_runtime/
log.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::io::Write;
4use std::sync::OnceLock;
5use std::time::SystemTime;
6
7// ---------------------------------------------------------------------------
8// LogLevel
9// ---------------------------------------------------------------------------
10
11/// Severity levels ordered from most verbose to most severe.
12///
13/// The discriminant values are intentionally spaced so that ordering
14/// comparisons (`>=`, `<=`) work correctly without a manual `Ord` impl.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16#[repr(u8)]
17pub enum LogLevel {
18    Trace = 0,
19    Debug = 1,
20    Info = 2,
21    Warn = 3,
22    Error = 4,
23}
24
25impl LogLevel {
26    /// Numeric priority — higher means more severe.
27    #[inline]
28    fn priority(self) -> u8 {
29        self as u8
30    }
31}
32
33impl PartialOrd for LogLevel {
34    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
35        Some(self.cmp(other))
36    }
37}
38
39impl Ord for LogLevel {
40    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
41        self.priority().cmp(&other.priority())
42    }
43}
44
45impl fmt::Display for LogLevel {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        let tag = match self {
48            LogLevel::Trace => "TRACE",
49            LogLevel::Debug => "DEBUG",
50            LogLevel::Info => "INFO ",
51            LogLevel::Warn => "WARN ",
52            LogLevel::Error => "ERROR",
53        };
54        f.write_str(tag)
55    }
56}
57
58// ---------------------------------------------------------------------------
59// LogEntry
60// ---------------------------------------------------------------------------
61
62/// A single structured log record.
63#[derive(Debug, Clone)]
64pub struct LogEntry {
65    pub level: LogLevel,
66    pub message: String,
67    pub target: String,
68    pub timestamp: String,
69    pub fields: HashMap<String, String>,
70}
71
72impl LogEntry {
73    /// Build a new entry, capturing the current UTC timestamp.
74    pub fn new(
75        level: LogLevel,
76        target: impl Into<String>,
77        message: impl Into<String>,
78        fields: HashMap<String, String>,
79    ) -> Self {
80        Self {
81            level,
82            message: message.into(),
83            target: target.into(),
84            timestamp: iso8601_now(),
85            fields,
86        }
87    }
88
89    /// Format this entry into the canonical one-line representation.
90    pub fn format(&self) -> String {
91        let mut buf = format!(
92            "[{}] {} [{}] {}",
93            self.timestamp, self.level, self.target, self.message,
94        );
95
96        // Append key=value pairs in deterministic (sorted) order so that
97        // tests and human readers get predictable output.
98        if !self.fields.is_empty() {
99            let mut keys: Vec<&String> = self.fields.keys().collect();
100            keys.sort();
101            for key in keys {
102                buf.push(' ');
103                buf.push_str(key);
104                buf.push('=');
105                buf.push_str(&self.fields[key]);
106            }
107        }
108
109        buf
110    }
111}
112
113impl fmt::Display for LogEntry {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.write_str(&self.format())
116    }
117}
118
119// ---------------------------------------------------------------------------
120// Logger
121// ---------------------------------------------------------------------------
122
123/// A thread-safe structured logger that writes to stderr.
124///
125/// Thread safety is achieved without a `Mutex` — each call formats the full
126/// line into a `String` first, then writes it in a single `write_all` call
127/// to stderr. On Unix-like systems writes of reasonable size to stderr are
128/// atomic at the fd level.
129pub struct Logger {
130    min_level: LogLevel,
131}
132
133impl Logger {
134    pub fn new(min_level: LogLevel) -> Self {
135        Self { min_level }
136    }
137
138    /// Returns `true` if a message at `level` would be emitted.
139    #[inline]
140    pub fn enabled(&self, level: LogLevel) -> bool {
141        level >= self.min_level
142    }
143
144    /// Core logging method. Messages below `min_level` are silently dropped.
145    pub fn log(
146        &self,
147        level: LogLevel,
148        target: &str,
149        message: &str,
150        fields: HashMap<String, String>,
151    ) {
152        if !self.enabled(level) {
153            return;
154        }
155
156        let entry = LogEntry::new(level, target, message, fields);
157        let line = format!("{}\n", entry.format());
158
159        // Single write — no lock needed.
160        let _ = std::io::stderr().write_all(line.as_bytes());
161    }
162
163    // -- convenience helpers ------------------------------------------------
164
165    pub fn info(&self, target: &str, message: &str, fields: HashMap<String, String>) {
166        self.log(LogLevel::Info, target, message, fields);
167    }
168
169    pub fn warn(&self, target: &str, message: &str, fields: HashMap<String, String>) {
170        self.log(LogLevel::Warn, target, message, fields);
171    }
172
173    pub fn error(&self, target: &str, message: &str, fields: HashMap<String, String>) {
174        self.log(LogLevel::Error, target, message, fields);
175    }
176
177    pub fn debug(&self, target: &str, message: &str, fields: HashMap<String, String>) {
178        self.log(LogLevel::Debug, target, message, fields);
179    }
180
181    pub fn trace(&self, target: &str, message: &str, fields: HashMap<String, String>) {
182        self.log(LogLevel::Trace, target, message, fields);
183    }
184}
185
186// ---------------------------------------------------------------------------
187// Global logger
188// ---------------------------------------------------------------------------
189
190static LOGGER: OnceLock<Logger> = OnceLock::new();
191
192/// Initialise the global logger. Subsequent calls are no-ops — the first
193/// writer wins.
194pub fn init_logger(level: LogLevel) {
195    let _ = LOGGER.set(Logger::new(level));
196}
197
198/// Obtain a reference to the global logger.
199///
200/// # Panics
201///
202/// Panics if `init_logger` has not been called yet. Prefer calling
203/// `init_logger` early in `main`.
204pub fn logger() -> &'static Logger {
205    LOGGER
206        .get()
207        .expect("pylon: logger not initialised — call init_logger() first")
208}
209
210// ---------------------------------------------------------------------------
211// Macros
212// ---------------------------------------------------------------------------
213
214/// Emit a structured log at INFO level via the global logger.
215///
216/// ```ignore
217/// log_info!("server", "listening", "port" => "8080", "host" => "0.0.0.0");
218/// log_info!("server", "started");
219/// ```
220#[macro_export]
221macro_rules! log_info {
222    ($target:expr, $msg:expr $(, $key:expr => $val:expr)*) => {{
223        let fields = $crate::log::__build_fields(&[$(($key, $val)),*]);
224        $crate::log::logger().info($target, $msg, fields);
225    }};
226}
227
228/// Emit a structured log at WARN level via the global logger.
229#[macro_export]
230macro_rules! log_warn {
231    ($target:expr, $msg:expr $(, $key:expr => $val:expr)*) => {{
232        let fields = $crate::log::__build_fields(&[$(($key, $val)),*]);
233        $crate::log::logger().warn($target, $msg, fields);
234    }};
235}
236
237/// Emit a structured log at ERROR level via the global logger.
238#[macro_export]
239macro_rules! log_error {
240    ($target:expr, $msg:expr $(, $key:expr => $val:expr)*) => {{
241        let fields = $crate::log::__build_fields(&[$(($key, $val)),*]);
242        $crate::log::logger().error($target, $msg, fields);
243    }};
244}
245
246/// Emit a structured log at DEBUG level via the global logger.
247#[macro_export]
248macro_rules! log_debug {
249    ($target:expr, $msg:expr $(, $key:expr => $val:expr)*) => {{
250        let fields = $crate::log::__build_fields(&[$(($key, $val)),*]);
251        $crate::log::logger().debug($target, $msg, fields);
252    }};
253}
254
255// ---------------------------------------------------------------------------
256// Helpers (public for macro hygiene, not part of the public API)
257// ---------------------------------------------------------------------------
258
259/// Convert a slice of key-value pairs into a `HashMap`. Exposed only so that
260/// the macros can call it; not intended for direct use.
261#[doc(hidden)]
262pub fn __build_fields(pairs: &[(&str, &str)]) -> HashMap<String, String> {
263    pairs
264        .iter()
265        .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
266        .collect()
267}
268
269/// Produce an ISO-8601 timestamp string in UTC (e.g. `2024-01-15T12:00:00Z`).
270///
271/// Uses only `std` — no chrono dependency.
272fn iso8601_now() -> String {
273    let duration = SystemTime::now()
274        .duration_since(SystemTime::UNIX_EPOCH)
275        .unwrap_or_default();
276
277    let secs = duration.as_secs();
278
279    // Manual decomposition — avoids pulling in `chrono` or `time`.
280    const SECS_PER_MINUTE: u64 = 60;
281    const SECS_PER_HOUR: u64 = 3_600;
282    const SECS_PER_DAY: u64 = 86_400;
283
284    let days = secs / SECS_PER_DAY;
285    let day_secs = secs % SECS_PER_DAY;
286    let hour = day_secs / SECS_PER_HOUR;
287    let minute = (day_secs % SECS_PER_HOUR) / SECS_PER_MINUTE;
288    let second = day_secs % SECS_PER_MINUTE;
289
290    // Civil date from day count (algorithm from Howard Hinnant).
291    let (year, month, day) = civil_from_days(days as i64);
292
293    format!(
294        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
295        year, month, day, hour, minute, second,
296    )
297}
298
299/// Convert a Unix day count to (year, month, day). Algorithm by Howard
300/// Hinnant — see <http://howardhinnant.github.io/date_algorithms.html>.
301fn civil_from_days(days: i64) -> (i64, u32, u32) {
302    let z = days + 719_468;
303    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
304    let doe = (z - era * 146_097) as u64; // [0, 146096]
305    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
306    let y = yoe as i64 + era * 400;
307    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
308    let mp = (5 * doy + 2) / 153; // [0, 11]
309    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
310    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
311    let y = if m <= 2 { y + 1 } else { y };
312    (y, m as u32, d as u32)
313}
314
315// ===========================================================================
316// Tests
317// ===========================================================================
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use std::cmp::Ordering;
323
324    // -- LogLevel ordering --------------------------------------------------
325
326    #[test]
327    fn level_ordering_error_is_most_severe() {
328        assert!(LogLevel::Error > LogLevel::Warn);
329        assert!(LogLevel::Error > LogLevel::Info);
330        assert!(LogLevel::Error > LogLevel::Debug);
331        assert!(LogLevel::Error > LogLevel::Trace);
332    }
333
334    #[test]
335    fn level_ordering_warn_gt_info() {
336        assert!(LogLevel::Warn > LogLevel::Info);
337        assert!(LogLevel::Warn > LogLevel::Debug);
338        assert!(LogLevel::Warn > LogLevel::Trace);
339    }
340
341    #[test]
342    fn level_ordering_info_gt_debug() {
343        assert!(LogLevel::Info > LogLevel::Debug);
344        assert!(LogLevel::Info > LogLevel::Trace);
345    }
346
347    #[test]
348    fn level_ordering_debug_gt_trace() {
349        assert!(LogLevel::Debug > LogLevel::Trace);
350    }
351
352    #[test]
353    fn level_ordering_equal() {
354        assert_eq!(LogLevel::Info.cmp(&LogLevel::Info), Ordering::Equal);
355    }
356
357    #[test]
358    fn level_ordering_full_sequence() {
359        let mut levels = vec![
360            LogLevel::Warn,
361            LogLevel::Trace,
362            LogLevel::Error,
363            LogLevel::Debug,
364            LogLevel::Info,
365        ];
366        levels.sort();
367        assert_eq!(
368            levels,
369            vec![
370                LogLevel::Trace,
371                LogLevel::Debug,
372                LogLevel::Info,
373                LogLevel::Warn,
374                LogLevel::Error,
375            ]
376        );
377    }
378
379    // -- LogEntry formatting ------------------------------------------------
380
381    #[test]
382    fn entry_format_without_fields() {
383        let entry = LogEntry {
384            level: LogLevel::Info,
385            message: "server started".into(),
386            target: "server".into(),
387            timestamp: "2024-01-15T12:00:00Z".into(),
388            fields: HashMap::new(),
389        };
390
391        assert_eq!(
392            entry.format(),
393            "[2024-01-15T12:00:00Z] INFO  [server] server started"
394        );
395    }
396
397    #[test]
398    fn entry_format_with_fields() {
399        let mut fields = HashMap::new();
400        fields.insert("port".into(), "8080".into());
401        fields.insert("host".into(), "0.0.0.0".into());
402
403        let entry = LogEntry {
404            level: LogLevel::Warn,
405            message: "binding".into(),
406            target: "net".into(),
407            timestamp: "2024-01-15T12:00:00Z".into(),
408            fields,
409        };
410
411        // Fields are sorted alphabetically.
412        assert_eq!(
413            entry.format(),
414            "[2024-01-15T12:00:00Z] WARN  [net] binding host=0.0.0.0 port=8080"
415        );
416    }
417
418    #[test]
419    fn entry_format_error_level() {
420        let entry = LogEntry {
421            level: LogLevel::Error,
422            message: "disk full".into(),
423            target: "storage".into(),
424            timestamp: "2024-01-15T12:00:00Z".into(),
425            fields: HashMap::new(),
426        };
427
428        assert_eq!(
429            entry.format(),
430            "[2024-01-15T12:00:00Z] ERROR [storage] disk full"
431        );
432    }
433
434    #[test]
435    fn entry_display_matches_format() {
436        let entry = LogEntry {
437            level: LogLevel::Debug,
438            message: "cache miss".into(),
439            target: "cache".into(),
440            timestamp: "2024-01-15T12:00:00Z".into(),
441            fields: HashMap::new(),
442        };
443
444        assert_eq!(entry.to_string(), entry.format());
445    }
446
447    // -- Logger filtering ---------------------------------------------------
448
449    #[test]
450    fn logger_enabled_at_min_level() {
451        let logger = Logger::new(LogLevel::Info);
452        assert!(logger.enabled(LogLevel::Info));
453        assert!(logger.enabled(LogLevel::Warn));
454        assert!(logger.enabled(LogLevel::Error));
455    }
456
457    #[test]
458    fn logger_filters_below_min_level() {
459        let logger = Logger::new(LogLevel::Warn);
460        assert!(!logger.enabled(LogLevel::Trace));
461        assert!(!logger.enabled(LogLevel::Debug));
462        assert!(!logger.enabled(LogLevel::Info));
463        assert!(logger.enabled(LogLevel::Warn));
464        assert!(logger.enabled(LogLevel::Error));
465    }
466
467    #[test]
468    fn logger_trace_enables_everything() {
469        let logger = Logger::new(LogLevel::Trace);
470        assert!(logger.enabled(LogLevel::Trace));
471        assert!(logger.enabled(LogLevel::Debug));
472        assert!(logger.enabled(LogLevel::Info));
473        assert!(logger.enabled(LogLevel::Warn));
474        assert!(logger.enabled(LogLevel::Error));
475    }
476
477    #[test]
478    fn logger_error_only() {
479        let logger = Logger::new(LogLevel::Error);
480        assert!(!logger.enabled(LogLevel::Trace));
481        assert!(!logger.enabled(LogLevel::Debug));
482        assert!(!logger.enabled(LogLevel::Info));
483        assert!(!logger.enabled(LogLevel::Warn));
484        assert!(logger.enabled(LogLevel::Error));
485    }
486
487    // -- Convenience methods compile and respect filtering -------------------
488
489    #[test]
490    fn convenience_methods_do_not_panic() {
491        // We cannot easily capture stderr in a unit test without extra
492        // machinery, so we simply verify these do not panic.
493        let logger = Logger::new(LogLevel::Error);
494        logger.info("t", "m", HashMap::new());
495        logger.warn("t", "m", HashMap::new());
496        logger.debug("t", "m", HashMap::new());
497        logger.trace("t", "m", HashMap::new());
498        logger.error("t", "m", HashMap::new());
499    }
500
501    // -- Timestamp helper ---------------------------------------------------
502
503    #[test]
504    fn iso8601_now_looks_valid() {
505        let ts = iso8601_now();
506        // Basic shape: YYYY-MM-DDTHH:MM:SSZ
507        assert_eq!(ts.len(), 20, "timestamp length: {ts}");
508        assert!(ts.ends_with('Z'));
509        assert_eq!(&ts[4..5], "-");
510        assert_eq!(&ts[7..8], "-");
511        assert_eq!(&ts[10..11], "T");
512        assert_eq!(&ts[13..14], ":");
513        assert_eq!(&ts[16..17], ":");
514    }
515
516    #[test]
517    fn civil_from_days_epoch() {
518        // Day 0 = 1970-01-01
519        assert_eq!(civil_from_days(0), (1970, 1, 1));
520    }
521
522    #[test]
523    fn civil_from_days_known_date() {
524        // 2024-01-15 is day 19_737
525        assert_eq!(civil_from_days(19_737), (2024, 1, 15));
526    }
527
528    // -- __build_fields helper ----------------------------------------------
529
530    #[test]
531    fn build_fields_empty() {
532        let fields = __build_fields(&[]);
533        assert!(fields.is_empty());
534    }
535
536    #[test]
537    fn build_fields_with_pairs() {
538        let fields = __build_fields(&[("a", "1"), ("b", "2")]);
539        assert_eq!(fields.len(), 2);
540        assert_eq!(fields.get("a").unwrap(), "1");
541        assert_eq!(fields.get("b").unwrap(), "2");
542    }
543}