use std::fs::{create_dir_all, rename, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
const MAX_BYTES: u64 = 5 * 1024 * 1024; const KEEP_ROTATIONS: usize = 3;
static LOG_STATE: OnceLock<Mutex<Option<LogState>>> = OnceLock::new();
struct LogState {
path: PathBuf,
file: std::fs::File,
}
fn log_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
let mut dir = PathBuf::from(home);
dir.push(".powerliners");
create_dir_all(&dir).ok()?;
dir.push("powerliners.log");
Some(dir)
}
fn open(path: &Path) -> Option<std::fs::File> {
OpenOptions::new().create(true).append(true).open(path).ok()
}
fn rotate(path: &Path) {
for i in (1..KEEP_ROTATIONS).rev() {
let from = path.with_extension(format!("log.{}", i));
let to = path.with_extension(format!("log.{}", i + 1));
let _ = rename(&from, &to);
}
let dot1 = path.with_extension("log.1");
let _ = rename(path, &dot1);
}
fn init_state() -> Option<LogState> {
let path = log_path()?;
let file = open(&path)?;
Some(LogState { path, file })
}
pub fn log(msg: &str) {
let cell = LOG_STATE.get_or_init(|| Mutex::new(init_state()));
if let Ok(mut guard) = cell.lock() {
if let Some(state) = guard.as_mut() {
if let Ok(meta) = state.file.metadata() {
if meta.len() >= MAX_BYTES {
rotate(&state.path);
if let Some(f) = open(&state.path) {
state.file = f;
}
}
}
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let _ = writeln!(state.file, "[{:.3} pid={}] {}", ts, std::process::id(), msg);
let _ = state.file.flush();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn seed_log_chain(base: &Path, n: usize) {
fs::write(base, b"head\n").unwrap();
for i in 1..=n {
let p = base.with_extension(format!("log.{}", i));
fs::write(&p, format!("body-{}\n", i).as_bytes()).unwrap();
}
}
fn tmp_log_base(tag: &str) -> PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let mut p = std::env::temp_dir();
p.push(format!("powerliners-diag-test-{tag}-{pid}-{nanos}"));
p.push("powerliners.log");
fs::create_dir_all(p.parent().unwrap()).unwrap();
p
}
#[test]
fn rotate_moves_active_log_to_dot1() {
let base = tmp_log_base("dot1");
fs::write(&base, b"active\n").unwrap();
rotate(&base);
assert!(!base.exists(), "active log should be renamed away");
let dot1 = base.with_extension("log.1");
assert!(dot1.exists(), ".log.1 should now exist");
assert_eq!(fs::read(&dot1).unwrap(), b"active\n");
fs::remove_dir_all(base.parent().unwrap()).ok();
}
#[test]
fn rotate_cascades_through_keep_rotations() {
let base = tmp_log_base("cascade");
seed_log_chain(&base, KEEP_ROTATIONS - 1);
rotate(&base);
let read = |i: usize| -> Vec<u8> {
fs::read(base.with_extension(format!("log.{}", i))).unwrap_or_default()
};
assert_eq!(read(1), b"head\n", ".log.1 must hold ex-active content");
assert_eq!(read(2), b"body-1\n", ".log.2 must hold ex-.log.1 content");
assert_eq!(read(3), b"body-2\n", ".log.3 must hold ex-.log.2 content");
fs::remove_dir_all(base.parent().unwrap()).ok();
}
#[test]
fn rotate_drops_oldest_when_chain_is_full() {
let base = tmp_log_base("drop_oldest");
seed_log_chain(&base, KEEP_ROTATIONS);
let eldest_path = base.with_extension(format!("log.{}", KEEP_ROTATIONS));
let pre = fs::read(&eldest_path).unwrap();
rotate(&base);
let post = fs::read(&eldest_path).unwrap();
assert_ne!(
pre,
post,
"eldest .log.{} should have been overwritten by .log.{}",
KEEP_ROTATIONS,
KEEP_ROTATIONS - 1
);
let beyond = base.with_extension(format!("log.{}", KEEP_ROTATIONS + 1));
assert!(
!beyond.exists(),
"rotation must not create slot beyond KEEP_ROTATIONS"
);
fs::remove_dir_all(base.parent().unwrap()).ok();
}
#[test]
fn rotate_on_missing_active_is_a_noop() {
let base = tmp_log_base("missing");
rotate(&base);
assert!(!base.exists());
assert!(!base.with_extension("log.1").exists());
fs::remove_dir_all(base.parent().unwrap()).ok();
}
#[test]
fn open_creates_file_and_appends_on_reopen() {
let base = tmp_log_base("append");
{
let mut f = open(&base).expect("first open");
writeln!(f, "first").unwrap();
}
{
let mut f = open(&base).expect("second open");
writeln!(f, "second").unwrap();
}
let text = fs::read_to_string(&base).unwrap();
assert!(text.contains("first"), "append lost first write: {text:?}");
assert!(
text.contains("second"),
"append lost second write: {text:?}"
);
fs::remove_dir_all(base.parent().unwrap()).ok();
}
}