1use std::fs::OpenOptions;
33use std::io::Write;
34use std::path::PathBuf;
35use std::sync::Mutex;
36use std::sync::OnceLock;
37use std::time::{SystemTime, UNIX_EPOCH};
38
39static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
40static LEVEL: OnceLock<Level> = OnceLock::new();
41
42const DEFAULT_MAX_BYTES: u64 = 5 * 1024 * 1024;
46const DEFAULT_MAX_FILES: u32 = 5;
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
51pub enum Level {
52 Trace = 0,
53 Debug = 1,
54 Info = 2,
55 Warn = 3,
56 Error = 4,
57}
58
59impl Level {
60 pub fn as_str(self) -> &'static str {
61 match self {
62 Level::Trace => "TRACE",
63 Level::Debug => "DEBUG",
64 Level::Info => "INFO",
65 Level::Warn => "WARN",
66 Level::Error => "ERROR",
67 }
68 }
69
70 pub fn parse(s: &str) -> Option<Level> {
73 match s.trim().to_ascii_lowercase().as_str() {
74 "trace" => Some(Level::Trace),
75 "debug" => Some(Level::Debug),
76 "info" => Some(Level::Info),
77 "warn" | "warning" => Some(Level::Warn),
78 "error" | "err" => Some(Level::Error),
79 _ => None,
80 }
81 }
82}
83
84pub fn log_path() -> PathBuf {
89 if let Ok(p) = std::env::var("STRYKE_LOG_FILE") {
90 if !p.is_empty() {
91 return PathBuf::from(p);
92 }
93 }
94 if let Ok(h) = std::env::var("STRYKE_HOME") {
95 if !h.is_empty() {
96 return PathBuf::from(h).join("stryke.log");
97 }
98 }
99 let home = std::env::var("HOME").unwrap_or_default();
100 PathBuf::from(home).join(".stryke").join("stryke.log")
101}
102
103pub fn current_level() -> Level {
107 *LEVEL.get_or_init(|| {
108 std::env::var("STRYKE_LOG_LEVEL")
109 .ok()
110 .and_then(|s| Level::parse(&s))
111 .unwrap_or(Level::Info)
112 })
113}
114
115pub fn max_bytes() -> u64 {
123 std::env::var("STRYKE_LOG_MAX_BYTES")
124 .ok()
125 .and_then(|s| s.parse::<u64>().ok())
126 .unwrap_or(DEFAULT_MAX_BYTES)
127}
128
129pub fn max_files() -> u32 {
133 std::env::var("STRYKE_LOG_MAX_FILES")
134 .ok()
135 .and_then(|s| s.parse::<u32>().ok())
136 .filter(|&n| n >= 1)
137 .unwrap_or(DEFAULT_MAX_FILES)
138}
139
140fn rotate_if_needed(path: &std::path::Path) {
149 let max = max_bytes();
150 if max == 0 {
151 return;
152 }
153 let size = match std::fs::metadata(path) {
154 Ok(md) => md.len(),
155 Err(_) => return,
156 };
157 if size < max {
158 return;
159 }
160 let n = max_files();
161 let base = path.as_os_str().to_string_lossy().into_owned();
162 for i in (1..n).rev() {
166 let from = format!("{base}.{i}");
167 let to = format!("{base}.{}", i + 1);
168 let _ = std::fs::rename(&from, &to);
169 }
170 let dot1 = format!("{base}.1");
171 let _ = std::fs::rename(path, &dot1);
172}
173
174#[inline]
178pub fn enabled(lvl: Level) -> bool {
179 lvl >= current_level()
180}
181
182pub fn log_at(lvl: Level, tag: &str, msg: &str) {
186 if !enabled(lvl) {
187 return;
188 }
189 let mu = LOCK.get_or_init(|| Mutex::new(()));
190 let _g = mu.lock().unwrap_or_else(|p| p.into_inner());
191 let path = log_path();
192 if let Some(dir) = path.parent() {
193 let _ = std::fs::create_dir_all(dir);
194 }
195 rotate_if_needed(&path);
198 let ts = SystemTime::now()
199 .duration_since(UNIX_EPOCH)
200 .map(|d| d.as_millis())
201 .unwrap_or(0);
202 let secs = (ts / 1000) as i64;
203 let millis = (ts % 1000) as u32;
204 if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
205 let _ = writeln!(
206 f,
207 "[{}.{:03}] [{:>5}] [{}] {}",
208 secs,
209 millis,
210 lvl.as_str(),
211 tag,
212 msg
213 );
214 }
215}
216
217pub fn log(tag: &str, msg: &str) {
220 log_at(Level::Info, tag, msg);
221}
222
223#[cfg(test)]
226pub fn force_level_for_test(_lvl: Level) {
227 }
231
232#[macro_export]
234macro_rules! slog_trace {
235 ($tag:expr, $($arg:tt)*) => {{
236 if $crate::stryke_log::enabled($crate::stryke_log::Level::Trace) {
237 $crate::stryke_log::log_at(
238 $crate::stryke_log::Level::Trace,
239 $tag,
240 &format!($($arg)*),
241 );
242 }
243 }};
244}
245
246#[macro_export]
247macro_rules! slog_debug {
248 ($tag:expr, $($arg:tt)*) => {{
249 if $crate::stryke_log::enabled($crate::stryke_log::Level::Debug) {
250 $crate::stryke_log::log_at(
251 $crate::stryke_log::Level::Debug,
252 $tag,
253 &format!($($arg)*),
254 );
255 }
256 }};
257}
258
259#[macro_export]
260macro_rules! slog_info {
261 ($tag:expr, $($arg:tt)*) => {{
262 if $crate::stryke_log::enabled($crate::stryke_log::Level::Info) {
263 $crate::stryke_log::log_at(
264 $crate::stryke_log::Level::Info,
265 $tag,
266 &format!($($arg)*),
267 );
268 }
269 }};
270}
271
272#[macro_export]
273macro_rules! slog_warn {
274 ($tag:expr, $($arg:tt)*) => {{
275 if $crate::stryke_log::enabled($crate::stryke_log::Level::Warn) {
276 $crate::stryke_log::log_at(
277 $crate::stryke_log::Level::Warn,
278 $tag,
279 &format!($($arg)*),
280 );
281 }
282 }};
283}
284
285#[macro_export]
286macro_rules! slog_error {
287 ($tag:expr, $($arg:tt)*) => {{
288 if $crate::stryke_log::enabled($crate::stryke_log::Level::Error) {
289 $crate::stryke_log::log_at(
290 $crate::stryke_log::Level::Error,
291 $tag,
292 &format!($($arg)*),
293 );
294 }
295 }};
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use std::sync::Mutex as StdMutex;
302
303 static ENV_GUARD: StdMutex<()> = StdMutex::new(());
310
311 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
312 ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner())
313 }
314
315 fn clear_log_env() {
316 std::env::remove_var("STRYKE_LOG_FILE");
317 std::env::remove_var("STRYKE_HOME");
318 std::env::remove_var("STRYKE_LOG_MAX_BYTES");
319 std::env::remove_var("STRYKE_LOG_MAX_FILES");
320 }
321
322 fn fresh_path() -> PathBuf {
323 std::env::temp_dir().join(format!(
324 "stryke-log-{}-{}.log",
325 std::process::id(),
326 SystemTime::now()
327 .duration_since(UNIX_EPOCH)
328 .map(|d| d.as_nanos())
329 .unwrap_or(0)
330 ))
331 }
332
333 #[test]
334 fn log_writes_to_env_override() {
335 let _g = env_lock();
336 clear_log_env();
337 let tmp = fresh_path();
338 std::env::set_var("STRYKE_LOG_FILE", &tmp);
339 log("test", "hello world");
340 let contents = std::fs::read_to_string(&tmp).expect("log file written");
341 assert!(
342 contents.contains("[ INFO] [test] hello world"),
343 "got: {contents:?}"
344 );
345 let _ = std::fs::remove_file(&tmp);
346 clear_log_env();
347 }
348
349 #[test]
350 fn log_path_honors_stryke_home() {
351 let _g = env_lock();
352 clear_log_env();
353 std::env::set_var("STRYKE_HOME", "/tmp/stryke-home-fixture");
354 let p = log_path();
355 assert_eq!(p, PathBuf::from("/tmp/stryke-home-fixture/stryke.log"));
356 clear_log_env();
357 }
358
359 #[test]
360 fn level_parsing_accepts_canonical_names() {
361 assert_eq!(Level::parse("trace"), Some(Level::Trace));
362 assert_eq!(Level::parse("DEBUG"), Some(Level::Debug));
363 assert_eq!(Level::parse("Info"), Some(Level::Info));
364 assert_eq!(Level::parse("warning"), Some(Level::Warn));
365 assert_eq!(Level::parse("err"), Some(Level::Error));
366 assert_eq!(Level::parse("zorp"), None);
367 }
368
369 #[test]
370 fn level_ordering_matches_severity() {
371 assert!(Level::Trace < Level::Debug);
372 assert!(Level::Debug < Level::Info);
373 assert!(Level::Info < Level::Warn);
374 assert!(Level::Warn < Level::Error);
375 }
376
377 #[test]
378 fn rotate_shifts_files_inward_when_oversize() {
379 let _g = env_lock();
380 clear_log_env();
381 std::env::set_var("STRYKE_LOG_MAX_BYTES", "10");
382 std::env::set_var("STRYKE_LOG_MAX_FILES", "3");
383 let p = fresh_path();
384 std::fs::write(&p, b"AAAAAAAAAAAAAAAA").unwrap(); std::fs::write(format!("{}.1", p.display()), b"prev").unwrap();
388 rotate_if_needed(&p);
389 assert!(!p.exists(), "active path must be rotated away");
391 let r1 = std::fs::read(format!("{}.1", p.display())).unwrap();
393 assert_eq!(r1, b"AAAAAAAAAAAAAAAA");
394 let r2 = std::fs::read(format!("{}.2", p.display())).unwrap();
396 assert_eq!(r2, b"prev");
397 let _ = std::fs::remove_file(&p);
398 let _ = std::fs::remove_file(format!("{}.1", p.display()));
399 let _ = std::fs::remove_file(format!("{}.2", p.display()));
400 clear_log_env();
401 }
402
403 #[test]
404 fn rotate_noop_when_disabled() {
405 let _g = env_lock();
406 clear_log_env();
407 std::env::set_var("STRYKE_LOG_MAX_BYTES", "0");
408 let p = fresh_path();
409 std::fs::write(&p, b"this is way more than zero bytes long").unwrap();
410 rotate_if_needed(&p);
411 assert!(p.exists(), "rotation disabled — file must persist");
412 let _ = std::fs::remove_file(&p);
413 clear_log_env();
414 }
415
416 #[test]
417 fn rotate_noop_when_under_threshold() {
418 let _g = env_lock();
419 clear_log_env();
420 std::env::set_var("STRYKE_LOG_MAX_BYTES", "1000");
421 let p = fresh_path();
422 std::fs::write(&p, b"short").unwrap();
423 rotate_if_needed(&p);
424 assert!(p.exists(), "small file must not rotate");
425 let _ = std::fs::remove_file(&p);
426 clear_log_env();
427 }
428
429 #[test]
430 fn log_at_writes_level_in_line() {
431 let _g = env_lock();
432 clear_log_env();
433 let tmp = fresh_path();
434 std::env::set_var("STRYKE_LOG_FILE", &tmp);
435 log_at(Level::Error, "boot", "fatal=42");
437 let contents = std::fs::read_to_string(&tmp).expect("written");
438 assert!(
439 contents.contains("[ERROR] [boot] fatal=42"),
440 "got: {contents:?}"
441 );
442 let _ = std::fs::remove_file(&tmp);
443 clear_log_env();
444 }
445
446 #[test]
447 fn rotation_kicks_in_during_real_writes() {
448 let _g = env_lock();
451 clear_log_env();
452 let tmp = fresh_path();
453 std::env::set_var("STRYKE_LOG_FILE", &tmp);
454 std::env::set_var("STRYKE_LOG_MAX_BYTES", "100");
455 std::env::set_var("STRYKE_LOG_MAX_FILES", "2");
456 for i in 0..50 {
457 log_at(
458 Level::Error,
459 "stress",
460 &format!("line-{:04}-filler-text-here", i),
461 );
462 }
463 let dot1 = std::path::PathBuf::from(format!("{}.1", tmp.display()));
464 assert!(dot1.exists(), "rotation must have produced a .1 archive");
465 let active_size = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0);
467 assert!(
468 active_size < 50 * 30,
469 "active log {active_size} should be smaller than total write volume"
470 );
471 let _ = std::fs::remove_file(&tmp);
472 let _ = std::fs::remove_file(&dot1);
473 let _ = std::fs::remove_file(format!("{}.2", tmp.display()));
474 clear_log_env();
475 }
476}