The one logger every Whiskers binary installs: lines of
2026-10-04T18:25:51Z LEVEL target: message on stderr (or the Android log), filtered by the
WHISKERS_LOG environment variable (error, warn, info, debug, trace, off;
default info). It has no dependencies beyond the log facade.
The convention for what may be logged is in crates/CLAUDE.md: ids, lengths and counts,
never a child's words, keys, PIN hashes or URLs with secrets.
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}
Installs the logger once; later calls change nothing. WHISKERS_LOG picks the level, and
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}
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}
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}
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}