use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use tracing_subscriber::fmt::MakeWriter;
pub const RUN_LOG_BACKUPS: u32 = 9;
static ACTIVE_LOG: OnceLock<Arc<Mutex<Option<File>>>> = OnceLock::new();
fn slot() -> &'static Arc<Mutex<Option<File>>> {
ACTIVE_LOG.get_or_init(|| Arc::new(Mutex::new(None)))
}
#[cfg(test)]
pub fn is_run_log_active() -> bool {
slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_some()
}
pub fn deactivate_run_log() {
if let Some(mut file) = slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.take()
{
let _ = file.flush();
}
}
fn numbered_path(base: &Path, n: u32) -> PathBuf {
PathBuf::from(format!("{}.{}", base.display(), n))
}
pub fn rotate_run_log(base: &Path) -> io::Result<()> {
let mut j = RUN_LOG_BACKUPS + 1;
loop {
let overflow = numbered_path(base, j);
if overflow.exists() {
fs::remove_file(&overflow)?;
j += 1;
} else {
break;
}
}
for i in (1..=RUN_LOG_BACKUPS).rev() {
let from = if i == 1 {
base.to_path_buf()
} else {
numbered_path(base, i - 1)
};
if from.exists() {
fs::rename(&from, numbered_path(base, i))?;
}
}
Ok(())
}
pub fn activate_run_log(path: &Path, header: &str) -> io::Result<RunLogGuard> {
rotate_run_log(path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(path)?;
file.write_all(header.as_bytes())?;
let clone = file.try_clone()?;
*slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(file);
Ok(RunLogGuard { file: Some(clone) })
}
pub struct RunLogGuard {
file: Option<File>,
}
impl Write for RunLogGuard {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match &mut self.file {
Some(f) => f.write(buf),
None => Ok(0),
}
}
fn flush(&mut self) -> io::Result<()> {
match &mut self.file {
Some(f) => f.flush(),
None => Ok(()),
}
}
}
impl Drop for RunLogGuard {
fn drop(&mut self) {
if let Some(f) = &mut self.file {
let _ = f.flush();
}
deactivate_run_log();
}
}
pub struct TeeWriter;
impl<'a> MakeWriter<'a> for TeeWriter {
type Writer = Box<dyn Write + 'a>;
fn make_writer(&'a self) -> Self::Writer {
let file = slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.as_ref()
.and_then(|f| f.try_clone().ok());
match file {
Some(f) => Box::new(Tee {
stderr: io::stderr(),
file: Some(f),
}),
None => Box::new(io::stderr()),
}
}
}
static FILE_WRITE_ERROR_REPORTED: AtomicBool = AtomicBool::new(false);
struct Tee {
stderr: io::Stderr,
file: Option<File>,
}
fn write_plain(out: &mut File, buf: &[u8]) -> io::Result<()> {
let mut plain = Vec::with_capacity(buf.len());
let mut i = 0;
while i < buf.len() {
if buf[i] == 0x1b && i + 1 < buf.len() && buf[i + 1] == b'[' {
i += 2;
while i < buf.len() && !(0x40..=0x7e).contains(&buf[i]) {
i += 1;
}
if i < buf.len() {
i += 1; }
} else if buf[i] == 0x1b {
i += 1; } else {
plain.push(buf[i]);
i += 1;
}
}
out.write_all(&plain)
}
impl Write for Tee {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let _ = self.stderr.write(buf);
if let Some(f) = &mut self.file
&& let Err(e) = write_plain(f, buf)
{
if !FILE_WRITE_ERROR_REPORTED.swap(true, Ordering::Relaxed) {
let _ = writeln!(
self.stderr,
"warning: run log write failed ({e}); continuing without file logging"
);
}
self.file = None;
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
let _ = self.stderr.flush();
if let Some(f) = &mut self.file {
f.flush()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
static GLOBAL_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("oxo-runlog-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn rotate_shifts_numbered_backups_and_caps() {
let dir = scratch("rotate");
let base = dir.join("oxo-flow.log");
fs::write(&base, "latest").unwrap();
for i in 1..=(RUN_LOG_BACKUPS + 3) {
fs::write(dir.join(format!("oxo-flow.log.{i}")), format!("backup{i}")).unwrap();
}
rotate_run_log(&base).unwrap();
assert_eq!(
fs::read_to_string(dir.join("oxo-flow.log.1")).unwrap(),
"latest"
);
for i in 2..=RUN_LOG_BACKUPS {
assert_eq!(
fs::read_to_string(dir.join(format!("oxo-flow.log.{i}"))).unwrap(),
format!("backup{}", i - 1)
);
}
for i in (RUN_LOG_BACKUPS + 1)..=(RUN_LOG_BACKUPS + 3) {
assert!(!dir.join(format!("oxo-flow.log.{i}")).exists());
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn rotate_without_existing_logs_is_a_noop() {
let dir = scratch("rotate-noop");
let base = dir.join("oxo-flow.log");
rotate_run_log(&base).unwrap();
assert!(!base.exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn activate_creates_parent_dirs_and_writes_header() {
let _guard = GLOBAL_STATE_LOCK.lock().unwrap();
let dir = scratch("activate");
let log = dir.join("nested/.oxo-flow/logs/oxo-flow.log");
let mut guard = activate_run_log(&log, "run header\nsecond line\n").unwrap();
let content = fs::read_to_string(&log).unwrap();
assert!(content.starts_with("run header\n"));
assert!(content.contains("second line"));
guard.write_all(b"event: rule x started\n").unwrap();
guard.flush().unwrap();
let content = fs::read_to_string(&log).unwrap();
assert!(content.contains("event: rule x started"));
assert!(is_run_log_active());
drop(guard);
assert!(!is_run_log_active());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn tee_strips_ansi_codes_from_file_writes() {
let _guard = GLOBAL_STATE_LOCK.lock().unwrap();
let dir = scratch("ansi");
let log = dir.join("run.log");
let _guard = activate_run_log(&log, "").unwrap();
let file = std::fs::OpenOptions::new().append(true).open(&log).unwrap();
let mut tee = Tee {
stderr: io::stderr(),
file: Some(file),
};
tee.write_all(b"\x1b[2m2026-08-21T14:54:11Z\x1b[0m \x1b[32m INFO\x1b[0m rule started\n")
.unwrap();
tee.flush().unwrap();
let content = fs::read_to_string(&log).unwrap();
assert!(content.contains(" INFO rule started"));
assert!(
!content.contains("\x1b["),
"run-log files must be plain text"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn activate_replaces_previous_run_log_after_rotation() {
let _guard = GLOBAL_STATE_LOCK.lock().unwrap();
let dir = scratch("replace");
let base = dir.join("oxo-flow.log");
fs::write(&base, "old run").unwrap();
let _guard = activate_run_log(&base, "new header\n").unwrap();
let content = fs::read_to_string(&base).unwrap();
assert!(content.starts_with("new header\n"));
assert!(!content.contains("old run"));
assert_eq!(
fs::read_to_string(dir.join("oxo-flow.log.1")).unwrap(),
"old run"
);
let _ = fs::remove_dir_all(&dir);
}
}