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();
}
}