elph-core 0.0.3

Shared core utilities for Elph applications
Documentation
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Instant;

use crate::logger::LogRotation;
use time::{Duration, OffsetDateTime, Time};

pub struct RollingWriter {
    inner: Mutex<RollingWriterInner>,
}

const ROTATION_CHECK_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);

struct RollingWriterInner {
    directory: PathBuf,
    app: String,
    rotation: LogRotation,
    max_files: Option<usize>,
    period: String,
    file: File,
    last_period_check: Instant,
}

impl RollingWriter {
    pub fn new(logs_dir: &Path, app: &str, rotation: LogRotation, max_files: Option<usize>) -> io::Result<Self> {
        fs::create_dir_all(logs_dir)?;
        let now = OffsetDateTime::now_utc();
        let period = period_key(rotation, now);
        let file = open_log_file(logs_dir, app, &period)?;
        if let Some(max) = max_files {
            prune_old_logs(logs_dir, app, max)?;
        }

        Ok(Self {
            inner: Mutex::new(RollingWriterInner {
                directory: logs_dir.to_path_buf(),
                app: app.to_string(),
                rotation,
                max_files,
                period,
                file,
                last_period_check: Instant::now(),
            }),
        })
    }
}

impl Write for RollingWriter {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        let mut inner = self.inner.lock().expect("rolling log mutex poisoned");
        if inner.last_period_check.elapsed() >= ROTATION_CHECK_INTERVAL {
            let now = OffsetDateTime::now_utc();
            let period = period_key(inner.rotation, now);
            inner.last_period_check = Instant::now();
            if period != inner.period {
                inner.file = open_log_file(&inner.directory, &inner.app, &period)?;
                inner.period = period;
                if let Some(max) = inner.max_files {
                    prune_old_logs(&inner.directory, &inner.app, max)?;
                }
            }
        }
        inner.file.write(buf)
    }

    fn flush(&mut self) -> io::Result<()> {
        self.inner.lock().expect("rolling log mutex poisoned").file.flush()
    }
}

pub fn build_writer(
    logs_dir: &Path,
    app: &str,
    rotation: LogRotation,
    max_files: Option<usize>,
) -> io::Result<RollingWriter> {
    RollingWriter::new(logs_dir, app, rotation, max_files)
}

fn period_key(rotation: LogRotation, now: OffsetDateTime) -> String {
    let rounded = round_timestamp(rotation, now);
    format!(
        "{:04}{:02}{:02}_{:02}",
        rounded.year(),
        u8::from(rounded.month()),
        rounded.day(),
        rounded.hour()
    )
}

fn round_timestamp(rotation: LogRotation, now: OffsetDateTime) -> OffsetDateTime {
    match rotation {
        LogRotation::Hourly => {
            let time = Time::from_hms(now.hour(), 0, 0).expect("valid hourly time");
            now.replace_time(time)
        }
        LogRotation::Daily => now.replace_time(Time::MIDNIGHT),
        LogRotation::Weekly => {
            let days_since_sunday = now.weekday().number_days_from_sunday();
            let date = now.date() - Duration::days(days_since_sunday.into());
            date.with_time(Time::MIDNIGHT).assume_utc()
        }
    }
}

fn log_filename(app: &str, period: &str) -> String {
    format!("{app}-{period}.jsonl")
}

fn open_log_file(logs_dir: &Path, app: &str, period: &str) -> io::Result<File> {
    let path = logs_dir.join(log_filename(app, period));
    OpenOptions::new().create(true).append(true).open(path)
}

fn prune_old_logs(logs_dir: &Path, app: &str, max_files: usize) -> io::Result<()> {
    if max_files == 0 {
        return Ok(());
    }

    let prefix = format!("{app}-");
    let suffix = ".jsonl";
    let mut files = Vec::new();

    for entry in fs::read_dir(logs_dir)? {
        let entry = entry?;
        let name = entry.file_name();
        let Some(name) = name.to_str() else {
            continue;
        };
        if !name.starts_with(&prefix) || !name.ends_with(suffix) {
            continue;
        }
        if entry.file_type()?.is_file() {
            files.push(name.to_string());
        }
    }

    if files.len() < max_files {
        return Ok(());
    }

    files.sort_unstable();
    for name in files.iter().take(files.len().saturating_sub(max_files - 1)) {
        fs::remove_file(logs_dir.join(name))?;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn period_key_uses_compact_timestamp() {
        use time::{Date, Month};
        let timestamp = Date::from_calendar_date(2024, Month::July, 15)
            .expect("valid date")
            .with_hms(13, 45, 0)
            .expect("valid time")
            .assume_utc();
        assert_eq!(period_key(LogRotation::Hourly, timestamp), "20240715_13");
    }

    #[test]
    fn log_filename_matches_expected_pattern() {
        assert_eq!(log_filename("elph", "20240715_13"), "elph-20240715_13.jsonl");
        assert_eq!(log_filename("eclaw", "20240715_00"), "eclaw-20240715_00.jsonl");
    }
}