use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
pub struct SizeRollingWriter {
directory: PathBuf,
prefix: String,
max_bytes: u64,
max_files: usize,
compress: bool,
file: Option<File>,
written: u64,
}
impl SizeRollingWriter {
pub fn new(
directory: impl AsRef<Path>,
prefix: impl Into<String>,
max_bytes: u64,
max_files: usize,
compress: bool,
) -> io::Result<Self> {
let directory = directory.as_ref().to_path_buf();
fs::create_dir_all(&directory)?;
let prefix = prefix.into();
let path = directory.join(&prefix);
let file = OpenOptions::new().create(true).append(true).open(&path)?;
let written = file.metadata().map(|m| m.len()).unwrap_or(0);
Ok(Self {
directory,
prefix,
max_bytes,
max_files,
compress,
file: Some(file),
written,
})
}
fn active_path(&self) -> PathBuf {
self.directory.join(&self.prefix)
}
fn retired_path(&self, index: usize) -> PathBuf {
self.directory.join(format!("{}.{index}", self.prefix))
}
fn roll(&mut self) -> io::Result<()> {
self.file = None;
if self.max_files == 0 {
fs::remove_file(self.active_path()).ok();
} else {
for index in (1..=self.max_files).rev() {
let from = self.retired_path(index);
if !from.exists() {
continue;
}
if index == self.max_files {
fs::remove_file(&from).ok();
} else {
fs::rename(&from, self.retired_path(index + 1)).ok();
}
}
let retired = self.retired_path(1);
fs::rename(self.active_path(), &retired).ok();
if self.compress {
compress_in_place(&retired);
}
}
self.file = Some(
OpenOptions::new()
.create(true)
.append(true)
.open(self.active_path())?,
);
self.written = 0;
Ok(())
}
}
impl Write for SizeRollingWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.max_bytes > 0
&& self.written + buf.len() as u64 > self.max_bytes
&& self.written > 0
{
self.roll()?;
}
let file = self
.file
.as_mut()
.ok_or_else(|| io::Error::other("log file is not open"))?;
let count = file.write(buf)?;
self.written += count as u64;
Ok(count)
}
fn flush(&mut self) -> io::Result<()> {
match self.file.as_mut() {
Some(file) => file.flush(),
None => Ok(()),
}
}
}
#[cfg(feature = "compression")]
fn compress_in_place(path: &Path) {
use flate2::write::GzEncoder;
use flate2::Compression;
let Ok(contents) = fs::read(path) else {
return;
};
let target = PathBuf::from(format!("{}.gz", path.display()));
let Ok(file) = File::create(&target) else {
return;
};
let mut encoder = GzEncoder::new(file, Compression::default());
if encoder.write_all(&contents).is_ok() && encoder.finish().is_ok() {
fs::remove_file(path).ok();
} else {
fs::remove_file(&target).ok();
}
}
#[cfg(not(feature = "compression"))]
fn compress_in_place(_path: &Path) {}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(label: &str) -> PathBuf {
std::env::temp_dir().join(format!("lk-roll-{}-{label}", std::process::id()))
}
#[test]
fn rolls_once_the_size_is_exceeded() {
let dir = scratch("rolls");
let _ = fs::remove_dir_all(&dir);
let mut writer = SizeRollingWriter::new(&dir, "app.log", 32, 3, false).expect("opens");
for _ in 0..8 {
writer.write_all(b"0123456789\n").expect("writes");
}
writer.flush().expect("flushes");
assert!(dir.join("app.log").exists(), "active file must exist");
assert!(dir.join("app.log.1").exists(), "a retired file must exist");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn never_exceeds_the_retained_file_count() {
let dir = scratch("cap");
let _ = fs::remove_dir_all(&dir);
let mut writer = SizeRollingWriter::new(&dir, "app.log", 16, 2, false).expect("opens");
for _ in 0..40 {
writer.write_all(b"0123456789\n").expect("writes");
}
writer.flush().expect("flushes");
assert!(!dir.join("app.log.3").exists(), "retention cap exceeded");
let count = fs::read_dir(&dir).expect("readable").count();
assert!(count <= 3, "{count} files on disk, expected at most 3");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_line_is_never_split_across_two_files() {
let dir = scratch("whole");
let _ = fs::remove_dir_all(&dir);
let mut writer = SizeRollingWriter::new(&dir, "app.log", 20, 3, false).expect("opens");
writer.write_all(b"first-line-here\n").expect("writes");
writer.write_all(b"second-line-here\n").expect("writes");
writer.flush().expect("flushes");
for entry in fs::read_dir(&dir).expect("readable").flatten() {
let body = fs::read_to_string(entry.path()).expect("readable");
if !body.is_empty() {
assert!(body.ends_with('\n'), "split line in {body:?}");
}
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn an_existing_file_keeps_its_length_across_a_restart() {
let dir = scratch("resume");
let _ = fs::remove_dir_all(&dir);
{
let mut writer = SizeRollingWriter::new(&dir, "app.log", 64, 2, false).expect("opens");
writer.write_all(b"0123456789\n").expect("writes");
writer.flush().expect("flushes");
}
let writer = SizeRollingWriter::new(&dir, "app.log", 64, 2, false).expect("reopens");
assert_eq!(writer.written, 11);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn zero_retained_files_truncates_rather_than_retiring() {
let dir = scratch("zero");
let _ = fs::remove_dir_all(&dir);
let mut writer = SizeRollingWriter::new(&dir, "app.log", 16, 0, false).expect("opens");
for _ in 0..10 {
writer.write_all(b"0123456789\n").expect("writes");
}
writer.flush().expect("flushes");
assert!(!dir.join("app.log.1").exists(), "nothing should be retired");
assert!(dir.join("app.log").exists());
let _ = fs::remove_dir_all(&dir);
}
}