use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
pub struct BeeLogWriter {
base_path: PathBuf,
rotate_size_bytes: u64,
keep_files: u32,
current: File,
current_size: u64,
}
impl BeeLogWriter {
pub fn open(base_path: PathBuf, rotate_size_mb: u64, keep_files: u32) -> io::Result<Self> {
let current = OpenOptions::new()
.create(true)
.append(true)
.open(&base_path)?;
let current_size = current.metadata()?.len();
Ok(Self {
base_path,
rotate_size_bytes: rotate_size_mb.saturating_mul(1024 * 1024).max(1),
keep_files: keep_files.max(1),
current,
current_size,
})
}
pub fn path(&self) -> &Path {
&self.base_path
}
pub fn write_line(&mut self, line: &[u8]) -> io::Result<()> {
let needed = line.len() as u64 + 1;
if self.current_size > 0 && self.current_size + needed > self.rotate_size_bytes {
self.rotate()?;
}
self.current.write_all(line)?;
self.current.write_all(b"\n")?;
self.current_size += needed;
Ok(())
}
pub fn rotate(&mut self) -> io::Result<()> {
self.current.flush()?;
let oldest = path_with_suffix(&self.base_path, self.keep_files);
let _ = std::fs::remove_file(&oldest);
for i in (1..self.keep_files).rev() {
let from = path_with_suffix(&self.base_path, i);
let to = path_with_suffix(&self.base_path, i + 1);
if from.exists() {
std::fs::rename(&from, &to)?;
}
}
if self.base_path.exists() {
let first = path_with_suffix(&self.base_path, 1);
std::fs::rename(&self.base_path, &first)?;
}
self.current = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&self.base_path)?;
self.current_size = 0;
Ok(())
}
}
fn path_with_suffix(base: &Path, n: u32) -> PathBuf {
let mut s = base.as_os_str().to_owned();
s.push(format!(".{n}"));
PathBuf::from(s)
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_path() -> PathBuf {
std::env::temp_dir().join(format!(
"bee-log-writer-test-{}.log",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
fn cleanup(base: &Path, keep_files: u32) {
let _ = std::fs::remove_file(base);
for i in 1..=keep_files + 1 {
let _ = std::fs::remove_file(path_with_suffix(base, i));
}
}
#[test]
fn writes_lines_with_newlines_appended() {
let path = unique_path();
let mut w = BeeLogWriter::open(path.clone(), 1, 2).unwrap();
w.write_line(b"hello").unwrap();
w.write_line(b"world").unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "hello\nworld\n");
cleanup(&path, 2);
}
#[test]
fn rotates_when_size_cap_exceeded() {
let path = unique_path();
let mut w = BeeLogWriter::open(path.clone(), 1, 3).unwrap();
let chunk: Vec<u8> = vec![b'x'; 100 * 1024]; for _ in 0..15 {
w.write_line(&chunk).unwrap();
}
let active_len = std::fs::metadata(&path).unwrap().len();
assert!(
active_len < 1024 * 1024,
"active file should be under cap, got {active_len}"
);
let rotated_1 = path_with_suffix(&path, 1);
assert!(rotated_1.exists(), ".1 rotation file must exist");
cleanup(&path, 3);
}
#[test]
fn rotation_drops_oldest_beyond_keep_count() {
let path = unique_path();
let mut w = BeeLogWriter::open(path.clone(), 1, 2).unwrap();
let chunk: Vec<u8> = vec![b'y'; 600 * 1024]; for _ in 0..10 {
w.write_line(&chunk).unwrap();
}
assert!(path_with_suffix(&path, 1).exists(), ".1 must exist");
assert!(path_with_suffix(&path, 2).exists(), ".2 must exist");
assert!(
!path_with_suffix(&path, 3).exists(),
".3 must NOT exist (keep_files=2)"
);
cleanup(&path, 3);
}
#[test]
fn reopen_picks_up_existing_size() {
let path = unique_path();
std::fs::write(&path, "x".repeat(900_000)).unwrap();
let mut w = BeeLogWriter::open(path.clone(), 1, 2).unwrap();
w.write_line(&vec![b'z'; 200 * 1024]).unwrap();
assert!(
path_with_suffix(&path, 1).exists(),
"rotation should fire on the first write past the cap"
);
cleanup(&path, 2);
}
#[test]
fn oversized_line_rotates_after_writing() {
let path = unique_path();
let mut w = BeeLogWriter::open(path.clone(), 1, 2).unwrap();
let huge: Vec<u8> = vec![b'h'; 2 * 1024 * 1024]; w.write_line(&huge).unwrap();
assert!(std::fs::metadata(&path).unwrap().len() > 1024 * 1024);
w.write_line(b"normal").unwrap();
assert!(path_with_suffix(&path, 1).exists());
cleanup(&path, 2);
}
}