saddle-observability 0.3.5

Saddle structured logging and trace correlation
Documentation
use std::{
    fs::{self, File, OpenOptions},
    io::{self, Write},
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

use rustix::fs::{CWD, FlockOperation, RenameFlags, flock, renameat_with};
use time::{OffsetDateTime, UtcOffset};

const ACTIVE_NAME: &str = "saddle.log";
const LOCK_NAME: &str = ".saddle.log.lock";

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Rotation {
    #[default]
    Daily,
    Hourly,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FileLoggingConfig {
    directory: PathBuf,
    rotation: Rotation,
}

impl FileLoggingConfig {
    pub fn new(directory: impl Into<PathBuf>, rotation: Rotation) -> Self {
        Self {
            directory: directory.into(),
            rotation,
        }
    }

    pub fn directory(&self) -> &Path {
        &self.directory
    }

    pub const fn rotation(&self) -> Rotation {
        self.rotation
    }
}

impl Default for FileLoggingConfig {
    fn default() -> Self {
        Self::new(PathBuf::from("./logs"), Rotation::Daily)
    }
}

#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct Period {
    year: i32,
    month: u8,
    day: u8,
    hour: u8,
}

impl Period {
    fn archive_name(self, rotation: Rotation) -> String {
        match rotation {
            Rotation::Daily => format!(
                "saddle.log.{:04}-{:02}-{:02}",
                self.year, self.month, self.day
            ),
            Rotation::Hourly => format!(
                "saddle.log.{:04}-{:02}-{:02}-{:02}",
                self.year, self.month, self.day, self.hour
            ),
        }
    }
}

pub(super) struct CalendarFileWriter {
    directory: PathBuf,
    active_path: PathBuf,
    rotation: Rotation,
    period: Period,
    file: File,
    directory_handle: File,
    _lock: File,
    clock: Box<dyn Fn() -> io::Result<Period> + Send>,
}

impl CalendarFileWriter {
    pub(super) fn open(config: FileLoggingConfig) -> io::Result<Self> {
        let rotation = config.rotation;
        Self::open_with_clock(config, Box::new(move || system_period(rotation)))
    }

    fn open_with_clock(
        config: FileLoggingConfig,
        clock: Box<dyn Fn() -> io::Result<Period> + Send>,
    ) -> io::Result<Self> {
        fs::create_dir_all(&config.directory)?;
        let directory_handle = File::open(&config.directory)?;
        reject_non_regular_if_present(&config.directory.join(LOCK_NAME))?;
        let lock = OpenOptions::new()
            .read(true)
            .write(true)
            .create(true)
            .truncate(false)
            .open(config.directory.join(LOCK_NAME))?;
        flock(&lock, FlockOperation::NonBlockingLockExclusive)
            .map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()))?;

        let active_path = config.directory.join(ACTIVE_NAME);
        reject_non_regular_if_present(&active_path)?;
        let current = clock()?;
        if active_path.exists() {
            let active_period =
                period_at(fs::metadata(&active_path)?.modified()?, config.rotation)?;
            if active_period > current {
                return Err(io::Error::other(
                    "saddle.log belongs to a future calendar period",
                ));
            }
            if active_period < current {
                archive_no_replace(
                    &active_path,
                    &config
                        .directory
                        .join(active_period.archive_name(config.rotation)),
                )?;
                directory_handle.sync_all()?;
            }
        }
        let file = open_active(&active_path)?;
        directory_handle.sync_all()?;
        Ok(Self {
            directory: config.directory,
            active_path,
            rotation: config.rotation,
            period: current,
            file,
            directory_handle,
            _lock: lock,
            clock,
        })
    }

    fn rotate_if_needed(&mut self) -> io::Result<()> {
        let current = (self.clock)()?;
        if current < self.period {
            return Err(io::Error::other("system calendar moved backwards"));
        }
        if current == self.period {
            return Ok(());
        }

        self.file.flush()?;
        self.file.sync_data()?;
        let archive = self.directory.join(self.period.archive_name(self.rotation));
        archive_no_replace(&self.active_path, &archive)?;
        self.directory_handle.sync_all()?;
        self.file = open_active(&self.active_path)?;
        self.directory_handle.sync_all()?;
        self.period = current;
        Ok(())
    }
}

impl Write for CalendarFileWriter {
    fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
        self.rotate_if_needed()?;
        self.file.write(buffer)
    }

    fn flush(&mut self) -> io::Result<()> {
        self.file.flush()
    }
}

fn open_active(path: &Path) -> io::Result<File> {
    OpenOptions::new().create(true).append(true).open(path)
}

fn reject_non_regular_if_present(path: &Path) -> io::Result<()> {
    match fs::symlink_metadata(path) {
        Ok(metadata) if !metadata.file_type().is_file() => {
            Err(io::Error::other("log path is not a regular file"))
        }
        Ok(_) => Ok(()),
        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(error),
    }
}

fn archive_no_replace(active: &Path, archive: &Path) -> io::Result<()> {
    renameat_with(CWD, active, CWD, archive, RenameFlags::NOREPLACE)
        .map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()))
}

fn system_period(rotation: Rotation) -> io::Result<Period> {
    period_at(SystemTime::now(), rotation)
}

fn period_at(time: SystemTime, rotation: Rotation) -> io::Result<Period> {
    let seconds = time
        .duration_since(UNIX_EPOCH)
        .map_err(|_| io::Error::other("calendar time predates the Unix epoch"))?
        .as_secs();
    let seconds = i64::try_from(seconds).map_err(|_| io::Error::other("calendar time overflow"))?;
    let utc = OffsetDateTime::from_unix_timestamp(seconds)
        .map_err(|_| io::Error::other("calendar time is out of range"))?;
    let offset = UtcOffset::local_offset_at(utc)
        .map_err(|_| io::Error::other("operating-system timezone is unavailable"))?;
    let local = utc.to_offset(offset);
    Ok(Period {
        year: local.year(),
        month: u8::from(local.month()),
        day: local.day(),
        hour: match rotation {
            Rotation::Daily => 0,
            Rotation::Hourly => local.hour(),
        },
    })
}

#[cfg(test)]
mod tests {
    use std::{
        fs::FileTimes,
        sync::{Arc, Mutex},
        time::Duration,
    };

    use super::*;

    fn directory(name: &str) -> PathBuf {
        let path = std::env::temp_dir().join(format!(
            "saddle-calendar-writer-{name}-{}-{}",
            std::process::id(),
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        fs::create_dir_all(&path).unwrap();
        path
    }

    fn period(day: u8, hour: u8) -> Period {
        Period {
            year: 2026,
            month: 8,
            day,
            hour,
        }
    }

    #[test]
    fn rotates_daily_without_replacing_archive() {
        let path = directory("daily");
        let now = Arc::new(Mutex::new(period(28, 23)));
        let clock_now = Arc::clone(&now);
        let mut writer = CalendarFileWriter::open_with_clock(
            FileLoggingConfig::new(&path, Rotation::Daily),
            Box::new(move || Ok(*clock_now.lock().unwrap())),
        )
        .unwrap();
        writer.write_all(b"before\n").unwrap();
        *now.lock().unwrap() = period(29, 0);
        writer.write_all(b"after\n").unwrap();
        writer.flush().unwrap();

        assert_eq!(
            fs::read(path.join("saddle.log.2026-08-28")).unwrap(),
            b"before\n"
        );
        assert_eq!(fs::read(path.join(ACTIVE_NAME)).unwrap(), b"after\n");
        fs::remove_dir_all(path).unwrap();
    }

    #[test]
    fn startup_appends_same_period_and_rotates_stale_active() {
        let path = directory("restart");
        let active = path.join(ACTIVE_NAME);
        fs::write(&active, b"old\n").unwrap();
        let old = UNIX_EPOCH + Duration::from_secs(1_777_000_000);
        File::options()
            .write(true)
            .open(&active)
            .unwrap()
            .set_times(FileTimes::new().set_modified(old))
            .unwrap();
        let old_period = period_at(old, Rotation::Daily).unwrap();
        let current = Period {
            day: old_period.day.saturating_add(1),
            ..old_period
        };
        let mut writer = CalendarFileWriter::open_with_clock(
            FileLoggingConfig::new(&path, Rotation::Daily),
            Box::new(move || Ok(current)),
        )
        .unwrap();
        writer.write_all(b"new\n").unwrap();
        writer.flush().unwrap();
        assert_eq!(
            fs::read(path.join(old_period.archive_name(Rotation::Daily))).unwrap(),
            b"old\n"
        );
        assert_eq!(fs::read(&active).unwrap(), b"new\n");
        fs::remove_dir_all(path).unwrap();
    }

    #[test]
    fn lock_and_archive_collision_fail_closed() {
        let path = directory("failure");
        let now = Arc::new(Mutex::new(period(28, 15)));
        let first_now = Arc::clone(&now);
        let mut first = CalendarFileWriter::open_with_clock(
            FileLoggingConfig::new(&path, Rotation::Hourly),
            Box::new(move || Ok(*first_now.lock().unwrap())),
        )
        .unwrap();
        assert!(
            CalendarFileWriter::open_with_clock(
                FileLoggingConfig::new(&path, Rotation::Hourly),
                Box::new(|| Ok(period(28, 15))),
            )
            .is_err()
        );

        first.write_all(b"record\n").unwrap();
        fs::write(path.join("saddle.log.2026-08-28-15"), b"collision\n").unwrap();
        *now.lock().unwrap() = period(28, 16);
        assert!(first.write_all(b"must-fail\n").is_err());
        assert_eq!(
            fs::read(path.join("saddle.log.2026-08-28-15")).unwrap(),
            b"collision\n"
        );
        drop(first);
        fs::remove_dir_all(path).unwrap();
    }

    #[test]
    fn clock_rollback_fails_closed() {
        let path = directory("rollback");
        let now = Arc::new(Mutex::new(period(28, 16)));
        let clock_now = Arc::clone(&now);
        let mut writer = CalendarFileWriter::open_with_clock(
            FileLoggingConfig::new(&path, Rotation::Hourly),
            Box::new(move || Ok(*clock_now.lock().unwrap())),
        )
        .unwrap();
        *now.lock().unwrap() = period(28, 15);
        assert!(writer.write_all(b"must-fail\n").is_err());
        assert_eq!(fs::read(path.join(ACTIVE_NAME)).unwrap(), b"");
        drop(writer);
        fs::remove_dir_all(path).unwrap();
    }
}