1use 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#[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#[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
76pub 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
101pub 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
110pub 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
135pub 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
144pub fn log(level: Level, sink: Sink, msg: &str) {
146 let Some(l) = LOGGER.get() else {
147 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}