1//! The one logger every Whiskers binary installs: lines of
2//! `2026-10-04T18:25:51Z LEVEL target: message` on stderr (or the Android log), filtered by the
3//! `WHISKERS_LOG` environment variable (`error`, `warn`, `info`, `debug`, `trace`, `off`;
4//! default `info`). It has no dependencies beyond the `log` facade.
5//!
6//! The convention for what may be logged is in `crates/CLAUDE.md`: ids, lengths and counts,
7//! never a child's words, keys, PIN hashes or URLs with secrets.
8
9use std::io::Write;
10use std::sync::OnceLock;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use log::{LevelFilter, Log, Metadata, Record};
14
15struct StderrLogger {
16    max: LevelFilter,
17    #[cfg_attr(not(target_os = "android"), allow(dead_code))]
18    tag: &'static str,
19}
20
21static LOGGER: OnceLock<StderrLogger> = OnceLock::new();
22
23/// Reads a level name as `WHISKERS_LOG` spells it. Unknown names are `None`.
24pub fn parse_level(s: &str) -> Option<LevelFilter> {
25    match s.trim().to_ascii_lowercase().as_str() {
26        "off" | "none" => Some(LevelFilter::Off),
27        "error" => Some(LevelFilter::Error),
28        "warn" | "warning" => Some(LevelFilter::Warn),
29        "info" => Some(LevelFilter::Info),
30        "debug" => Some(LevelFilter::Debug),
31        "trace" => Some(LevelFilter::Trace),
32        _ => None,
33    }
34}
35
36/// Installs the logger once; later calls change nothing. `WHISKERS_LOG` picks the level, and
37/// `tag` names the program on platforms whose log has tags. Returns the level in force.
38pub fn init(tag: &'static str) -> LevelFilter {
39    let (max, bad) = match std::env::var("WHISKERS_LOG") {
40        Ok(v) => match parse_level(&v) {
41            Some(l) => (l, None),
42            None => (LevelFilter::Info, Some(v)),
43        },
44        Err(_) => (LevelFilter::Info, None),
45    };
46    let logger = LOGGER.get_or_init(|| StderrLogger { max, tag });
47    if log::set_logger(logger).is_ok() {
48        log::set_max_level(logger.max);
49        // A panic is a bug that must show in the log, not only on a stderr nobody reads (Android
50        // discards it). Only the place is logged: the message can carry a child's words.
51        let previous = std::panic::take_hook();
52        std::panic::set_hook(Box::new(move |info| {
53            match info.location() {
54                Some(l) => log::error!(target: "panic", "panic at {}:{}:{}", l.file(), l.line(), l.column()),
55                None => log::error!(target: "panic", "panic at an unknown place"),
56            }
57            previous(info);
58        }));
59        log::debug!("logging installed at level {} for {}", logger.max, tag);
60        if let Some(v) = bad {
61            log::warn!("WHISKERS_LOG={v:?} is not a level (error, warn, info, debug, trace, off); using info");
62        }
63    }
64    logger.max
65}
66
67impl Log for StderrLogger {
68    fn enabled(&self, metadata: &Metadata) -> bool {
69        metadata.level() <= self.max
70    }
71
72    fn log(&self, record: &Record) {
73        if !self.enabled(record.metadata()) {
74            return;
75        }
76        #[cfg(target_os = "android")]
77        android::write(self.tag, record);
78        #[cfg(not(target_os = "android"))]
79        {
80            let line = format!("{} {:<5} {}: {}\n", timestamp(now_secs()), record.level(), record.target(), record.args());
81            // One write call per line keeps lines from interleaving between threads.
82            let _ = std::io::stderr().lock().write_all(line.as_bytes());
83        }
84    }
85
86    fn flush(&self) {
87        let _ = std::io::stderr().flush();
88    }
89}
90
91fn now_secs() -> u64 {
92    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
93}
94
95/// `2026-10-04T18:25:51Z` for a Unix time in seconds.
96pub fn timestamp(secs: u64) -> String {
97    let days = (secs / 86_400) as i64;
98    let rem = secs % 86_400;
99    // Civil-from-days (Howard Hinnant's algorithm), proleptic Gregorian, days since 1970-01-01.
100    let z = days + 719_468;
101    let era = z.div_euclid(146_097);
102    let doe = z.rem_euclid(146_097);
103    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
104    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
105    let mp = (5 * doy + 2) / 153;
106    let d = doy - (153 * mp + 2) / 5 + 1;
107    let m = if mp < 10 { mp + 3 } else { mp - 9 };
108    let y = yoe + era * 400 + i64::from(m <= 2);
109    format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
110}
111
112#[cfg(target_os = "android")]
113mod android {
114    use super::*;
115    use log::Level;
116    use std::ffi::{CString, c_char, c_int};
117
118    #[link(name = "log")]
119    unsafe extern "C" {
120        fn __android_log_write(prio: c_int, tag: *const c_char, text: *const c_char) -> c_int;
121    }
122
123    pub fn write(tag: &str, record: &Record) {
124        let prio = match record.level() {
125            Level::Error => 6,
126            Level::Warn => 5,
127            Level::Info => 4,
128            Level::Debug => 3,
129            Level::Trace => 2,
130        };
131        let text = format!("{}: {}", record.target(), record.args()).replace('\0', " ");
132        if let (Ok(tag), Ok(text)) = (CString::new(tag), CString::new(text)) {
133            // SAFETY: both pointers are valid NUL-terminated strings for the duration of the call.
134            unsafe { __android_log_write(prio, tag.as_ptr(), text.as_ptr()) };
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn timestamps_are_utc_civil_time() {
145        assert_eq!(timestamp(0), "1970-01-01T00:00:00Z");
146        assert_eq!(timestamp(1_791_138_351), "2026-10-04T18:25:51Z");
147        assert_eq!(timestamp(951_782_400), "2000-02-29T00:00:00Z");
148    }
149
150    #[test]
151    fn levels_parse() {
152        assert_eq!(parse_level(" DEBUG "), Some(LevelFilter::Debug));
153        assert_eq!(parse_level("loud"), None);
154    }
155}