Skip to main content

xil_core/
log.rs

1//! Two sinks, two formats — port of `log_config.py`.
2//!
3//! Console (stdout) is human-readable: `INFO` and `RUN` bare, `[!] ` for
4//! warnings, `[ERROR] `, `[CRITICAL] `, `[debug] `.
5//!
6//! The file `logs/xil_v2_<date>_<host>.log` under the workspace root is one
7//! record per line: `<iso ts>|<LEVEL>|<host>|<stage>|<message>`. Multi-line
8//! messages get the prefix on every physical line; whitespace-only records
9//! are dropped from the file. The message may itself contain `|`.
10//!
11//! Python opens the file handler at configure time, which creates an empty
12//! log file even when nothing is logged. [`init`] does the same, so the set
13//! of files a command leaves behind matches.
14
15use std::fs::{self, File, OpenOptions};
16use std::io::{self, Write};
17use std::sync::{Mutex, OnceLock};
18
19use chrono::Local;
20
21use crate::workspace::workspace_root;
22
23/// Severity, ordered like Python's numeric levels (`RUN` = 25 sits between
24/// INFO and WARNING).
25#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
26pub enum Level {
27    Debug = 10,
28    Info = 20,
29    Run = 25,
30    Warning = 30,
31    Error = 40,
32    Critical = 50,
33}
34
35impl Level {
36    fn name(self) -> &'static str {
37        match self {
38            Level::Debug => "DEBUG",
39            Level::Info => "INFO",
40            Level::Run => "RUN",
41            Level::Warning => "WARNING",
42            Level::Error => "ERROR",
43            Level::Critical => "CRITICAL",
44        }
45    }
46
47    fn console_prefix(self) -> &'static str {
48        match self {
49            Level::Debug => "[debug] ",
50            Level::Info | Level::Run => "",
51            Level::Warning => "[!] ",
52            Level::Error => "[ERROR] ",
53            Level::Critical => "[CRITICAL] ",
54        }
55    }
56}
57
58/// Which sinks a record goes to. Mirrors `extra={"console": False}` /
59/// `extra={"file": False}` in the Python.
60#[derive(Clone, Copy, PartialEq, Eq, Debug)]
61pub enum Sink {
62    Both,
63    ConsoleOnly,
64    FileOnly,
65}
66
67struct Logger {
68    stage: String,
69    host: String,
70    threshold: Level,
71    file: Option<File>,
72}
73
74static LOGGER: OnceLock<Mutex<Logger>> = OnceLock::new();
75
76/// Short, filename-safe hostname: first label, non `[A-Za-z0-9._-]` → `-`.
77pub fn host() -> String {
78    let raw = hostname::get()
79        .ok()
80        .and_then(|h| h.into_string().ok())
81        .filter(|h| !h.is_empty())
82        .unwrap_or_else(|| "unknown".to_string());
83    let first = raw.split('.').next().unwrap_or("unknown");
84    let safe: String = first
85        .chars()
86        .map(|c| {
87            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
88                c
89            } else {
90                '-'
91            }
92        })
93        .collect();
94    if safe.is_empty() {
95        "unknown".to_string()
96    } else {
97        safe
98    }
99}
100
101/// Path of today's log file for this host under `<workspace>/logs/`.
102pub fn log_path() -> std::path::PathBuf {
103    workspace_root().join("logs").join(format!(
104        "xil_v2_{}_{}.log",
105        Local::now().format("%Y-%m-%d"),
106        host()
107    ))
108}
109
110/// Install the logger for a command. `stage` is what Python derives from
111/// `argv[0]` (`"use"`, `"parse"`, …). Safe to call more than once; only the
112/// first call opens the file. Creates `logs/` and the (possibly empty) file.
113pub fn init(stage: &str) {
114    LOGGER.get_or_init(|| {
115        let path = log_path();
116        let file = path
117            .parent()
118            .and_then(|d| fs::create_dir_all(d).ok())
119            .and_then(|_| {
120                OpenOptions::new()
121                    .append(true)
122                    .create(true)
123                    .open(&path)
124                    .ok()
125            });
126        Mutex::new(Logger {
127            stage: stage.to_string(),
128            host: host(),
129            threshold: Level::Info,
130            file,
131        })
132    });
133}
134
135/// Raise or lower the console/file threshold (Python `configure_logging(level)`).
136pub fn set_threshold(level: Level) {
137    if let Some(l) = LOGGER.get() {
138        if let Ok(mut g) = l.lock() {
139            g.threshold = level;
140        }
141    }
142}
143
144/// Emit one record. Callers normally use the level helpers below.
145pub fn log(level: Level, sink: Sink, msg: &str) {
146    let Some(l) = LOGGER.get() else {
147        // Not initialised: behave like a bare print so nothing is lost.
148        if level >= Level::Info {
149            println!("{}{msg}", level.console_prefix());
150        }
151        return;
152    };
153    let Ok(mut g) = l.lock() else { return };
154    if level < g.threshold {
155        return;
156    }
157    if sink != Sink::FileOnly {
158        let mut out = io::stdout().lock();
159        let _ = writeln!(out, "{}{msg}", level.console_prefix());
160    }
161    if sink != Sink::ConsoleOnly && !msg.trim().is_empty() {
162        let ts = Local::now().format("%Y-%m-%dT%H:%M:%S%z");
163        let prefix = format!("{ts}|{}|{}|{}|", level.name(), g.host, g.stage);
164        let record: String = msg
165            .split('\n')
166            .map(|line| format!("{prefix}{line}\n"))
167            .collect();
168        if let Some(f) = g.file.as_mut() {
169            let _ = f.write_all(record.as_bytes());
170        }
171    }
172}
173
174pub fn debug(msg: &str) {
175    log(Level::Debug, Sink::Both, msg);
176}
177pub fn info(msg: &str) {
178    log(Level::Info, Sink::Both, msg);
179}
180pub fn run(msg: &str) {
181    log(Level::Run, Sink::Both, msg);
182}
183pub fn warning(msg: &str) {
184    log(Level::Warning, Sink::Both, msg);
185}
186pub fn error(msg: &str) {
187    log(Level::Error, Sink::Both, msg);
188}
189pub fn critical(msg: &str) {
190    log(Level::Critical, Sink::Both, msg);
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn levels_order_like_python() {
199        assert!(Level::Debug < Level::Info);
200        assert!(Level::Info < Level::Run);
201        assert!(Level::Run < Level::Warning);
202        assert!(Level::Warning < Level::Error);
203        assert!(Level::Error < Level::Critical);
204    }
205
206    #[test]
207    fn console_prefixes_match_python() {
208        assert_eq!(Level::Debug.console_prefix(), "[debug] ");
209        assert_eq!(Level::Info.console_prefix(), "");
210        assert_eq!(Level::Run.console_prefix(), "");
211        assert_eq!(Level::Warning.console_prefix(), "[!] ");
212        assert_eq!(Level::Error.console_prefix(), "[ERROR] ");
213        assert_eq!(Level::Critical.console_prefix(), "[CRITICAL] ");
214    }
215
216    #[test]
217    fn host_is_first_label_and_filename_safe() {
218        let h = host();
219        assert!(!h.is_empty());
220        assert!(!h.contains('.'));
221        assert!(h
222            .chars()
223            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')));
224    }
225}