use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use parking_lot::Mutex;
pub struct SizeRotatingWriter {
inner: Mutex<RotatingState>,
}
struct RotatingState {
path: PathBuf,
max_size: u64,
max_files: usize,
file: File,
current_size: u64,
}
impl SizeRotatingWriter {
pub fn new(path: PathBuf, max_size: u64, max_files: usize) -> Self {
assert!(max_size > 0, "max_size must be > 0");
assert!(max_files > 0, "max_files must be > 0");
let (file, current_size) = open_log_file(&path);
Self {
inner: Mutex::new(RotatingState {
path,
max_size,
max_files,
file,
current_size,
}),
}
}
}
impl Write for SizeRotatingWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut state = self.inner.lock();
write_and_maybe_rotate(&mut state, buf)
}
fn flush(&mut self) -> io::Result<()> {
let mut state = self.inner.lock();
state.file.flush()
}
}
impl Write for &SizeRotatingWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut state = self.inner.lock();
write_and_maybe_rotate(&mut state, buf)
}
fn flush(&mut self) -> io::Result<()> {
let mut state = self.inner.lock();
state.file.flush()
}
}
fn write_and_maybe_rotate(state: &mut RotatingState, buf: &[u8]) -> io::Result<usize> {
if state.current_size > 0 && state.current_size + buf.len() as u64 > state.max_size {
rotate(state);
}
let written = state.file.write(buf)?;
state.current_size += written as u64;
Ok(written)
}
fn rotate(state: &mut RotatingState) {
let _ = state.file.flush();
let oldest = rotated_path(&state.path, state.max_files);
if let Err(e) = fs::remove_file(&oldest) {
if e.kind() != io::ErrorKind::NotFound {
eprintln!("log rotate: failed to remove {}: {e}", oldest.display());
}
}
for i in (1..state.max_files).rev() {
let from = rotated_path(&state.path, i);
let to = rotated_path(&state.path, i + 1);
if let Err(e) = fs::rename(&from, &to) {
if e.kind() != io::ErrorKind::NotFound {
eprintln!(
"log rotate: failed to rename {} → {}: {e}",
from.display(),
to.display()
);
}
}
}
let rotated = rotated_path(&state.path, 1);
if let Err(e) = fs::rename(&state.path, &rotated) {
eprintln!(
"log rotate: failed to rename {} → {}: {e}",
state.path.display(),
rotated.display()
);
return;
}
let (file, size) = open_log_file(&state.path);
state.file = file;
state.current_size = size;
}
fn rotated_path(base: &Path, index: usize) -> PathBuf {
let mut p = base.as_os_str().to_os_string();
p.push(format!(".{index}"));
PathBuf::from(p)
}
fn open_log_file(path: &Path) -> (File, u64) {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.unwrap_or_else(|e| panic!("failed to open log file {}: {e}", path.display()));
let size = file.metadata().map_or(0, |m| m.len());
(file, size)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn rotation_creates_numbered_files() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("test.log");
let mut writer = SizeRotatingWriter::new(log_path.clone(), 100, 3);
let data = vec![b'A'; 60];
writer.write_all(&data).unwrap();
writer.write_all(&data).unwrap();
assert!(log_path.exists());
writer.write_all(&data).unwrap();
assert!(log_path.exists());
assert!(dir.path().join("test.log.1").exists());
}
#[test]
fn oldest_file_is_deleted() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("test.log");
let mut writer = SizeRotatingWriter::new(log_path.clone(), 50, 3);
let data = vec![b'X'; 60];
for _ in 0..6 {
writer.write_all(&data).unwrap();
}
assert!(log_path.exists());
assert!(dir.path().join("test.log.1").exists());
assert!(dir.path().join("test.log.2").exists());
assert!(dir.path().join("test.log.3").exists());
assert!(!dir.path().join("test.log.4").exists());
}
}