Skip to main content

robonix_scribe/
format.rs

1//! Console formatter — logcat-style single-line human-readable output.
2//!
3//! Format: `MM-DD HH:MM:SS.sss  L tag       msg`
4//!
5//! Level is a single character (D/I/W/E).  Tag is padded to 24 characters
6//! left-aligned.  Timestamp is local time derived from the nanosecond
7//! UNIX-epoch `ts` field.
8
9use crate::{Level, LogRecord};
10use std::io::{self, IsTerminal, Write};
11
12/// Tag display width in console output (left-aligned, space-padded).
13const TAG_WIDTH: usize = 24;
14
15/// Format a [`LogRecord`] as a logcat-style console line.
16///
17/// Returns a String ending in `\n`.
18pub fn format_console(record: &LogRecord) -> String {
19    let (month, day, hour, min, sec, ms) = decompose_ts(record.ts);
20    let level_code = record.level.code();
21    // Truncate overly long tags; pad short ones.
22    let tag_display = if record.tag.len() > TAG_WIDTH {
23        format!("{}…", &record.tag[..TAG_WIDTH - 1])
24    } else {
25        format!("{: <width$}", record.tag, width = TAG_WIDTH)
26    };
27    format!(
28        "{:02}-{:02} {:02}:{:02}:{:02}.{:03}  {} {} {}\n",
29        month, day, hour, min, sec, ms, level_code, tag_display, record.msg
30    )
31}
32
33/// Write a [`LogRecord`] to stderr in console format.
34///
35/// Each call does a single `write_all`; the caller is responsible for
36/// not interleaving partial writes across threads (the global `log()`
37/// function synchronises via the file-sink mutex).
38pub fn write_console(record: &LogRecord) -> io::Result<()> {
39    let line = format_console(record);
40    let mut stderr = io::stderr().lock();
41    let use_color = stderr.is_terminal() && std::env::var_os("NO_COLOR").is_none();
42    let color = match record.level {
43        Level::Warn => Some("\x1b[33m"),
44        Level::Error => Some("\x1b[31m"),
45        _ => None,
46    };
47    if use_color && let Some(color) = color {
48        stderr.write_all(color.as_bytes())?;
49        stderr.write_all(line.as_bytes())?;
50        stderr.write_all(b"\x1b[0m")
51    } else {
52        stderr.write_all(line.as_bytes())
53    }
54}
55
56/// Decompose a nanosecond UNIX timestamp into local-time calendar fields
57/// and milliseconds.
58fn decompose_ts(ts_ns: u64) -> (u32, u32, u32, u32, u32, u32) {
59    // Convert ns → s + residual ns → ms
60    let secs = (ts_ns / 1_000_000_000) as i64;
61    let nanos_rem = (ts_ns % 1_000_000_000) as u32;
62    let ms = nanos_rem / 1_000_000;
63
64    // Use libc localtime_r for thread-safe local-time decomposition.
65    // Fall back to UTC if we can't determine local time.
66    #[cfg(unix)]
67    {
68        let local = localtime(secs);
69        let (year, month, day, hour, min, sec) = local;
70        // year is years since 1900; month is 0-11
71        let _ = year;
72        (month + 1, day, hour, min, sec, ms)
73    }
74    #[cfg(not(unix))]
75    {
76        // UTC fallback on non-Unix
77        let total_secs = secs;
78        let day_secs = total_secs % 86400;
79        let hour = (day_secs / 3600) as u32;
80        let min = ((day_secs % 3600) / 60) as u32;
81        let sec = (day_secs % 60) as u32;
82        let month = 1;
83        let day = 1;
84        (month, day, hour, min, sec, ms)
85    }
86}
87
88#[cfg(unix)]
89fn localtime(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
90    use std::mem::MaybeUninit;
91    let ts = libc::time_t::try_from(secs).unwrap_or(0);
92    let mut tm: MaybeUninit<libc::tm> = MaybeUninit::uninit();
93    // SAFETY: localtime_r writes to the caller-provided tm; no aliasing.
94    let tm_ptr = unsafe {
95        libc::localtime_r(&ts, tm.as_mut_ptr());
96        tm.assume_init()
97    };
98    (
99        tm_ptr.tm_year,
100        tm_ptr.tm_mon as u32,
101        tm_ptr.tm_mday as u32,
102        tm_ptr.tm_hour as u32,
103        tm_ptr.tm_min as u32,
104        tm_ptr.tm_sec as u32,
105    )
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::Level;
112
113    #[test]
114    fn format_includes_level_code_and_tag() {
115        let rec = LogRecord {
116            ts: 0,
117            level: Level::Warn,
118            tag: "atlas".into(),
119            msg: "test message".into(),
120        };
121        let line = format_console(&rec);
122        assert!(line.contains(" W "), "expected W level code, got: {line}");
123        assert!(line.contains("atlas"), "expected tag, got: {line}");
124        assert!(line.contains("test message"), "expected msg, got: {line}");
125        assert!(line.ends_with('\n'), "should end with newline");
126    }
127
128    #[test]
129    fn tag_padded_to_fixed_width() {
130        let rec_short = LogRecord {
131            ts: 0,
132            level: Level::Info,
133            tag: "a".into(),
134            msg: "msg".into(),
135        };
136        let rec_long = LogRecord {
137            ts: 0,
138            level: Level::Info,
139            tag: "this_tag_is_way_too_long_for_display".into(),
140            msg: "msg".into(),
141        };
142        let short = format_console(&rec_short);
143        let long = format_console(&rec_long);
144        // Short tag padded to TAG_WIDTH
145        // Short tag padded to TAG_WIDTH: expect N spaces after "a"
146        let after_level: Vec<&str> = short.splitn(2, " I ").collect();
147        assert_eq!(after_level.len(), 2);
148        let rest = after_level[1]; // "a                        msg"
149        assert!(
150            rest.starts_with(&format!("{: <width$} ", "a", width = TAG_WIDTH)),
151            "tag 'a' should be padded to {TAG_WIDTH} chars, got rest: {rest:?}"
152        );
153        // Long tag should be truncated with …
154        assert!(
155            long.contains('…'),
156            "long tag should be truncated with '…', got: {long}"
157        );
158    }
159
160    #[test]
161    fn level_codes_in_output() {
162        for level in [Level::Debug, Level::Info, Level::Warn, Level::Error] {
163            let rec = LogRecord {
164                ts: 0,
165                level,
166                tag: "test".into(),
167                msg: String::new(),
168            };
169            let line = format_console(&rec);
170            let expected_code = format!(" {} ", level.code());
171            assert!(
172                line.contains(&expected_code),
173                "level {level:?}: expected '{expected_code}' in '{line}'"
174            );
175        }
176    }
177
178    #[test]
179    fn write_console_does_not_panic() {
180        let rec = LogRecord {
181            ts: 1765432100123456789,
182            level: Level::Info,
183            tag: "test".into(),
184            msg: "hello".into(),
185        };
186        // Just verify it doesn't panic; stderr is always writable.
187        write_console(&rec).unwrap();
188    }
189}