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