use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const ROLL_AT: u64 = 8 * 1024 * 1024;
pub fn init(app_name: &str) -> Option<PathBuf> {
let path = log_path(app_name);
let file = path.as_deref().and_then(open);
let mut builder =
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"));
let writing = match file {
Some(file) => {
builder.target(env_logger::Target::Pipe(Box::new(Tee {
file,
terminal: std::io::stderr(),
})));
true
}
None => false,
};
builder.init();
let path = path.filter(|_| writing)?;
log::info!("logging to {}", path.display());
Some(path)
}
pub fn log_path(app_name: &str) -> Option<PathBuf> {
let base = match std::env::var("XDG_STATE_HOME") {
Ok(state) if !state.is_empty() => PathBuf::from(state),
_ => std::env::home_dir()?.join(".local").join("state"),
};
Some(base.join(app_name).join(format!("{app_name}.log")))
}
fn open(path: &Path) -> Option<File> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok()?;
}
if should_roll(std::fs::metadata(path).ok().map(|meta| meta.len())) {
let _ = std::fs::rename(path, path.with_extension("log.1"));
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.ok()?;
let _ = writeln!(
file,
"\n=== run started at {} (unix seconds) ===",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or_default(),
);
Some(file)
}
fn should_roll(size: Option<u64>) -> bool {
size.is_some_and(|size| size >= ROLL_AT)
}
struct Tee {
file: File,
terminal: std::io::Stderr,
}
impl Write for Tee {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let written = self.terminal.write(buf)?;
let _ = self.file.write_all(buf);
Ok(written)
}
fn flush(&mut self) -> std::io::Result<()> {
let _ = self.file.flush();
self.terminal.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_log_goes_under_the_state_directory() {
let path = log_path("chessrs").expect("there is a home to put it in");
assert!(path.ends_with("chessrs/chessrs.log"), "{}", path.display());
}
#[test]
fn a_log_is_set_aside_only_once_it_is_big() {
assert!(!should_roll(None), "there is nothing to set aside yet");
assert!(!should_roll(Some(0)));
assert!(!should_roll(Some(ROLL_AT - 1)));
assert!(should_roll(Some(ROLL_AT)));
}
#[test]
fn a_run_is_marked_and_appended_to_rather_than_replacing_the_last_one() {
let dir = std::env::temp_dir().join("renderer-logging-append");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("chessrs.log");
std::fs::write(&path, "from the run before\n").unwrap();
let mut file = open(&path).expect("the log opens");
writeln!(file, "and this run").unwrap();
drop(file);
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("from the run before"), "{written}");
assert!(written.contains("run started at"), "{written}");
assert!(written.contains("and this run"), "{written}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn everything_written_lands_in_both_places() {
let dir = std::env::temp_dir().join("renderer-logging-tee");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("tee.log");
let mut tee = Tee {
file: File::create(&path).unwrap(),
terminal: std::io::stderr(),
};
tee.write_all(b"a line for the record\n").unwrap();
tee.flush().unwrap();
drop(tee);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"a line for the record\n",
);
let _ = std::fs::remove_dir_all(&dir);
}
}