use std::fs::{self, File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const TIMESTAMP_LEN: usize = 15;
pub struct RunLog {
pub path: PathBuf,
pub file: File,
pub pruned: Vec<PathBuf>,
}
pub fn resolve_log_dir() -> PathBuf {
if let Ok(p) = std::env::var("AUTOCORE_LOG_DIR") {
return PathBuf::from(p);
}
let preferred = if cfg!(target_os = "windows") {
match std::env::var("LOCALAPPDATA") {
Ok(d) => PathBuf::from(d).join("ADC").join("Logs"),
Err(_) => PathBuf::from("./target/autocore/logs"),
}
} else {
PathBuf::from("/srv/autocore/logs")
};
if dir_is_writable(&preferred) {
return preferred;
}
eprintln!(
"WARN: cannot write to {:?}; falling back to ./target/autocore/logs",
preferred,
);
PathBuf::from("./target/autocore/logs")
}
fn dir_is_writable(dir: &Path) -> bool {
if fs::create_dir_all(dir).is_err() {
return false;
}
let probe = dir.join(".log_writable_probe");
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&probe)
{
Ok(_) => {
let _ = fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
pub fn open_default_run_log(base_name: &str, keep: usize) -> io::Result<RunLog> {
let log_dir = resolve_log_dir();
fs::create_dir_all(&log_dir)?;
create_run_log(&log_dir, base_name, keep)
}
pub fn create_run_log(log_dir: &Path, base_name: &str, keep: usize) -> io::Result<RunLog> {
let timestamp = utc_timestamp(SystemTime::now());
create_run_log_at(log_dir, base_name, keep, ×tamp)
}
fn create_run_log_at(
log_dir: &Path,
base_name: &str,
keep: usize,
timestamp: &str,
) -> io::Result<RunLog> {
let path = log_dir.join(format!("{base_name}.{timestamp}.log"));
let file = OpenOptions::new().create(true).append(true).open(&path)?;
let pruned = prune_old_runs(log_dir, base_name, keep.max(1));
Ok(RunLog { path, file, pruned })
}
fn prune_old_runs(log_dir: &Path, base_name: &str, keep: usize) -> Vec<PathBuf> {
let mut runs: Vec<PathBuf> = match fs::read_dir(log_dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| is_run_log_name(n, base_name))
.unwrap_or(false)
})
.collect(),
Err(_) => return Vec::new(),
};
runs.sort_unstable_by(|a, b| b.file_name().cmp(&a.file_name()));
let mut pruned = Vec::new();
for stale in runs.into_iter().skip(keep) {
match fs::remove_file(&stale) {
Ok(()) => pruned.push(stale),
Err(e) => eprintln!("WARN: could not prune old log {:?}: {}", stale, e),
}
}
pruned
}
fn is_run_log_name(name: &str, base_name: &str) -> bool {
let prefix = format!("{base_name}.");
let stem = match name
.strip_prefix(&prefix)
.and_then(|rest| rest.strip_suffix(".log"))
{
Some(s) => s,
None => return false,
};
is_timestamp(stem)
}
fn is_timestamp(s: &str) -> bool {
if s.len() != TIMESTAMP_LEN {
return false;
}
s.char_indices().all(|(i, c)| {
if i == 8 {
c == 'T'
} else {
c.is_ascii_digit()
}
})
}
pub fn utc_timestamp(t: SystemTime) -> String {
let secs = t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let days = (secs / 86_400) as i64;
let secs_of_day = secs % 86_400;
let (year, month, day) = civil_from_days(days);
let hour = secs_of_day / 3_600;
let minute = (secs_of_day % 3_600) / 60;
let second = secs_of_day % 60;
format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as i64; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (y + if m <= 2 { 1 } else { 0 }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn timestamp_formats_known_epochs() {
let t = UNIX_EPOCH + Duration::from_secs(1_779_868_232);
assert_eq!(utc_timestamp(t), "20260527T075032");
assert_eq!(utc_timestamp(UNIX_EPOCH), "19700101T000000");
}
#[test]
fn timestamp_validation() {
assert!(is_timestamp("20260527T075032"));
assert!(!is_timestamp("20260527075032")); assert!(!is_timestamp("2026-05-27T0750")); assert!(!is_timestamp("")); }
#[test]
fn run_log_name_matching() {
assert!(is_run_log_name("autocore-ni.20260527T075032.log", "autocore-ni"));
assert!(!is_run_log_name("autocore-ni.log", "autocore-ni")); assert!(!is_run_log_name("autocore-server.20260527T075032.log", "autocore-ni")); assert!(!is_run_log_name("autocore-ni.20260527T075032.txt", "autocore-ni")); assert!(!is_run_log_name("autocore-nimble.20260527T075032.log", "autocore-ni"));
}
#[test]
fn creates_file_and_keeps_only_newest_n() {
let dir = std::env::temp_dir().join(format!("mechutil_runlog_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
for i in 0..9 {
let ts = format!("202605{:02}T000000", i + 1);
fs::write(dir.join(format!("autocore-ni.{ts}.log")), b"x").unwrap();
}
fs::write(dir.join("autocore-ni.log"), b"legacy").unwrap();
fs::write(dir.join("notes.txt"), b"keep me").unwrap();
let run = create_run_log_at(&dir, "autocore-ni", 7, "20260601T120000").unwrap();
assert!(run.path.exists());
let remaining: Vec<String> = fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| is_run_log_name(n, "autocore-ni"))
.collect();
assert_eq!(remaining.len(), 7, "should keep exactly 7 run logs");
assert!(remaining.iter().any(|n| n == "autocore-ni.20260601T120000.log"));
assert!(!remaining.iter().any(|n| n == "autocore-ni.20260501T000000.log"));
assert!(dir.join("autocore-ni.log").exists());
assert!(dir.join("notes.txt").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn keep_zero_retains_current_run() {
let dir = std::env::temp_dir().join(format!("mechutil_runlog0_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let run = create_run_log_at(&dir, "m", 0, "20260601T120000").unwrap();
assert!(run.path.exists(), "current run file must never be pruned");
let _ = fs::remove_dir_all(&dir);
}
}