use std::fs::{self, File};
use std::io::{self, Write as _};
use std::path::{Component, Path, PathBuf};
pub const LOG_FILES_KEPT: usize = 5;
pub const LOG_FILE_MAX_BYTES: u64 = 16 * 1024 * 1024;
pub const FILE_ENV_VAR: &str = "CRCBL_LOG_FILE";
const CAPPED_NOTE: &str = "[log file size cap reached; the rest of this run went to stderr only]\n";
#[derive(Debug)]
pub(super) struct FileSink {
file: File,
path: PathBuf,
written: u64,
max_bytes: u64,
capped: bool,
}
impl FileSink {
pub(super) fn open(dir: &Path, stem: &str, kept: usize, max_bytes: u64) -> io::Result<Self> {
check_stem(stem)?;
fs::create_dir_all(dir)?;
rotate(dir, stem, kept)?;
let path = dir.join(file_name(stem, 0));
let file = File::create(&path)?;
Ok(Self {
file,
path,
written: 0,
max_bytes,
capped: false,
})
}
pub(super) fn path(&self) -> &Path {
&self.path
}
pub(super) fn try_clone_file(&self) -> io::Result<File> {
self.file.try_clone()
}
pub(super) fn write_line(&mut self, line: &str) {
if self.capped {
return;
}
let len = line.len() as u64;
if self.written + len > self.max_bytes {
self.capped = true;
let _ = self.file.write_all(CAPPED_NOTE.as_bytes());
return;
}
if self.file.write_all(line.as_bytes()).is_ok() {
self.written += len;
}
}
}
fn check_stem(stem: &str) -> io::Result<()> {
let mut components = Path::new(stem).components();
match (components.next(), components.next()) {
(Some(Component::Normal(name)), None) if name == stem => Ok(()),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("`{stem}` is not a plain file name for a log"),
)),
}
}
fn file_name(stem: &str, generation: usize) -> String {
if generation == 0 {
format!("{stem}.log")
} else {
format!("{stem}.{generation}.log")
}
}
fn rotate(dir: &Path, stem: &str, kept: usize) -> io::Result<()> {
for generation in (0..kept).rev() {
match fs::rename(
dir.join(file_name(stem, generation)),
dir.join(file_name(stem, generation + 1)),
) {
Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
_ => {}
}
}
Ok(())
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
pub(in crate::log) struct TempDir(pub(in crate::log) PathBuf);
impl TempDir {
pub(in crate::log) fn new(name: &str) -> Self {
let path = std::env::temp_dir()
.join(format!("crcbl-core-log-file-{name}-{}", std::process::id()));
fs::create_dir(&path).expect("a fresh temp directory for this test");
Self(path)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn names(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.expect("the log directory exists")
.map(|entry| {
entry
.expect("a readable entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
names.sort();
names
}
#[test]
fn rotation_keeps_exactly_the_bounded_count() {
let temp = TempDir::new("rotation");
let runs = LOG_FILES_KEPT + 2;
for run in 0..runs {
let mut sink = FileSink::open(&temp.0, "game", LOG_FILES_KEPT, LOG_FILE_MAX_BYTES)
.expect("opening the log");
sink.write_line(&format!("run {run}\n"));
}
let mut want: Vec<String> = (1..=LOG_FILES_KEPT)
.map(|generation| file_name("game", generation))
.collect();
want.push("game.log".to_owned());
want.sort();
assert_eq!(names(&temp.0), want);
for generation in 0..=LOG_FILES_KEPT {
let text = fs::read_to_string(temp.0.join(file_name("game", generation)))
.expect("reading a kept log");
assert_eq!(
text,
format!("run {}\n", runs - 1 - generation),
"generation {generation}"
);
}
}
#[test]
fn an_emitted_line_reaches_the_file() {
let temp = TempDir::new("emit");
let logger = super::super::StderrLogger::new();
let path = logger
.attach(&temp.0, "game", LOG_FILES_KEPT, LOG_FILE_MAX_BYTES)
.expect("attaching the file");
assert_eq!(path, temp.0.join("game.log"));
let target = "crcbl_core::log::file::tests";
logger.emit(
super::super::Level::Error,
target,
format_args!("the reactor is {}", "on fire"),
);
let text = fs::read_to_string(&path).expect("reading the log");
let mut lines = text.lines();
let banner = lines.next().expect("a banner line");
assert!(banner.contains("run started "), "{text}");
let line = lines.next().expect("the emitted line");
assert!(
line.ends_with(&format!("ERROR {target}] the reactor is on fire")),
"{text}"
);
assert_eq!(lines.next(), None, "{text}");
let again = logger
.attach(&temp.0, "game", LOG_FILES_KEPT, LOG_FILE_MAX_BYTES)
.expect_err("a second file for the same run");
assert_eq!(again.kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn a_run_stops_writing_at_the_size_cap() {
let temp = TempDir::new("cap");
let line = "0123456789\n";
let max = 5 * line.len() as u64 + 3;
let mut sink = FileSink::open(&temp.0, "game", 0, max).expect("opening the log");
for _ in 0..20 {
sink.write_line(line);
}
let text = fs::read_to_string(sink.path()).expect("reading the log");
assert_eq!(text, format!("{}{CAPPED_NOTE}", line.repeat(5)));
assert!(text.len() as u64 <= max + CAPPED_NOTE.len() as u64);
}
#[test]
fn a_stem_that_names_a_path_is_refused() {
let temp = TempDir::new("stem");
for stem in ["", "..", "a/b", "../escape", "."] {
let error = FileSink::open(&temp.0, stem, 1, 1024).expect_err(stem);
assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{stem:?}");
}
assert!(!temp.0.join("..").join("escape.log").exists());
}
}