mechutil 0.8.4

Utility structures and functions for mechatronics applications.
Documentation
//! Per-run log files with bounded retention.
//!
//! Every autocore process (the server and each external module) calls
//! [`create_run_log`] once at startup. Instead of appending to a single
//! growing file and rotating it by size, each run gets its own file named
//!
//! ```text
//! <base_name>.<YYYYMMDDThhmmss>.log      // e.g. autocore-ni.20260527T075032.log
//! ```
//!
//! After the new file is created, older run logs for the same `base_name`
//! are pruned so that at most `keep` of them remain (newest kept, oldest
//! deleted). This keeps a fixed, predictable history — "the last N runs" —
//! rather than an unbounded or size-truncated single file.
//!
//! The timestamp is UTC, matching the UTC timestamps simplelog writes inside
//! the log lines, and is fixed-width so lexicographic filename order equals
//! chronological order. This module is pure `std` (no `chrono`/`time`) to
//! keep `mechutil`'s dependency surface minimal.
//!
//! # Example
//!
//! ```no_run
//! use std::path::Path;
//! use mechutil::run_log::create_run_log;
//!
//! // Open this run's log file, keeping the 7 most recent runs.
//! let run = create_run_log(Path::new("/srv/autocore/logs"), "autocore-ni", 7)
//!     .expect("open run log");
//! // Hand `run.file` to simplelog's WriteLogger, etc.
//! println!("logging to {:?}; pruned {} old run(s)", run.path, run.pruned.len());
//! ```

use std::fs::{self, File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

/// Width of the `YYYYMMDDThhmmss` timestamp embedded in a run-log filename.
const TIMESTAMP_LEN: usize = 15;

/// Result of opening a new run log via [`create_run_log`].
pub struct RunLog {
    /// Full path of the freshly created log file for this run.
    pub path: PathBuf,
    /// The opened (create + append) file handle for this run.
    pub file: File,
    /// Paths of older run logs that were pruned to honor the retention limit.
    pub pruned: Vec<PathBuf>,
}

/// Resolve the autocore log directory, shared by the server and every
/// module so all `autocore-*` binaries on a target deposit logs in one
/// place. Resolution order:
///
///   1. `AUTOCORE_LOG_DIR` env var (always wins; handy for tests / dev).
///   2. Platform default: Linux `/srv/autocore/logs`,
///      Windows `%LOCALAPPDATA%\ADC\Logs`.
///   3. If the default isn't writable (e.g. an unprivileged dev run where
///      `/srv` is root-owned), fall back to `./target/autocore/logs`.
///
/// The writable probe means dev machines transparently land in
/// `./target/autocore/logs` without each binary special-casing debug builds.
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")
}

/// True if `dir` exists (or can be created) and a probe file can be written
/// in it. Used by [`resolve_log_dir`] to pick a writable location.
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,
    }
}

/// One-call logger file setup for the common case: resolve the shared log
/// directory ([`resolve_log_dir`]), create it, and open this run's log file
/// keeping the newest `keep` runs. This is what the server and every module
/// should call so the directory policy and retention live in exactly one place.
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)
}

/// Create this run's log file `<base_name>.<YYYYMMDDThhmmss>.log` in
/// `log_dir`, then prune older run logs for the same base name so at most
/// `keep` remain (the newest `keep`, including the one just created).
///
/// `log_dir` must already exist. A `keep` of 0 is treated as 1 (the current
/// run's file is never pruned).
///
/// Pruning failures (e.g. a permission error on one stale file) are not fatal:
/// the offending file is simply omitted from `pruned` and a warning is printed
/// to stderr, so a single un-deletable file can't stop logging from starting.
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, &timestamp)
}

/// Testable core of [`create_run_log`] with an injected timestamp.
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 })
}

/// Delete run logs for `base_name` beyond the newest `keep`. Returns the
/// paths actually removed. Never errors out: see [`create_run_log`].
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(),
    };

    // Fixed-width UTC timestamps make lexicographic order chronological.
    // Sort descending so the newest runs are first; everything past `keep`
    // is stale and gets removed.
    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
}

/// True if `name` is a run log produced by this module for `base_name`:
/// `<base_name>.<15-char timestamp>.log`, where the timestamp is
/// `YYYYMMDDThhmmss` (digits with a `T` at index 8). This deliberately
/// excludes a legacy `<base_name>.log` and unrelated files in the directory.
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)
}

/// Validate the `YYYYMMDDThhmmss` shape: 15 chars, `T` at index 8, digits elsewhere.
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()
        }
    })
}

/// Format a [`SystemTime`] as a UTC `YYYYMMDDThhmmss` string.
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}")
}

/// Convert a count of days since the Unix epoch (1970-01-01) into a
/// `(year, month, day)` civil date. Howard Hinnant's `civil_from_days`
/// algorithm (public domain), valid for the full range we care about.
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; // [0, 146096]
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
    let mp = (5 * doy + 2) / 153; // [0, 11]
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
    (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() {
        // 2026-05-27T07:50:32 UTC = 1779868232
        let t = UNIX_EPOCH + Duration::from_secs(1_779_868_232);
        assert_eq!(utc_timestamp(t), "20260527T075032");
        // The Unix epoch itself.
        assert_eq!(utc_timestamp(UNIX_EPOCH), "19700101T000000");
    }

    #[test]
    fn timestamp_validation() {
        assert!(is_timestamp("20260527T075032"));
        assert!(!is_timestamp("20260527075032")); // missing T
        assert!(!is_timestamp("2026-05-27T0750")); // wrong shape/len
        assert!(!is_timestamp("")); // empty (legacy <base>.log stem)
    }

    #[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")); // legacy single file
        assert!(!is_run_log_name("autocore-server.20260527T075032.log", "autocore-ni")); // other base
        assert!(!is_run_log_name("autocore-ni.20260527T075032.txt", "autocore-ni")); // not .log
        // A base name that is a prefix of another must not match across the boundary.
        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();

        // Seed 9 older runs with increasing timestamps.
        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();
        }
        // An unrelated file and a legacy file must survive pruning.
        fs::write(dir.join("autocore-ni.log"), b"legacy").unwrap();
        fs::write(dir.join("notes.txt"), b"keep me").unwrap();

        // New run with a timestamp newer than all seeds; keep 7.
        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");
        // Newest (the one we just created) is retained.
        assert!(remaining.iter().any(|n| n == "autocore-ni.20260601T120000.log"));
        // Oldest seed was pruned.
        assert!(!remaining.iter().any(|n| n == "autocore-ni.20260501T000000.log"));
        // Legacy + unrelated files untouched.
        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);
    }
}