ref:main
//! Tiny size-based log rotation. No external deps. Used by `--detach`
//! when the runner redirects its stdout/stderr to a file.
//!
//! Rotation is rotate-on-start: when `runner start --detach` is invoked,
//! we check the log size and, if over the configured threshold, shuffle
//! `runner.log` → `runner.log.1`, the old `.1` → `.2`, etc., dropping
//! anything past `max_files`. Then we open `runner.log` fresh for the
//! new daemon.
//!
//! This is deliberately not periodic-during-run: a long-lived detached
//! runner that needs strict rotation should pair with `logrotate` (Linux)
//! or `newsyslog` (macOS). The rotate-on-start approach bounds disk
//! usage across restarts, which is the common case (binary upgrades via
//! `runner restart` reset the log naturally).
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy)]
pub struct RotationPolicy {
pub max_size_bytes: u64,
pub max_files: usize,
}
impl Default for RotationPolicy {
fn default() -> Self {
Self {
// 100 MB per file × 3 rotated copies = 400 MB total cap.
max_size_bytes: 100 * 1024 * 1024,
max_files: 3,
}
}
}
/// If `path` exists and exceeds `policy.max_size_bytes`, shift the
/// rotation chain (`runner.log.{n-1} → runner.log.n`, … `runner.log →
/// runner.log.1`) and drop anything past `policy.max_files`. The
/// caller then opens a fresh `path` for writing.
///
/// Returns `Ok(true)` if a rotation actually happened, `Ok(false)` if
/// no rotation was needed (file absent or under the threshold).
pub fn maybe_rotate(path: &Path, policy: RotationPolicy) -> std::io::Result<bool> {
let size = match fs::metadata(path) {
Ok(m) => m.len(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e),
};
if size <= policy.max_size_bytes {
return Ok(false);
}
rotate_chain(path, policy.max_files)?;
Ok(true)
}
fn rotated_path(base: &Path, n: usize) -> PathBuf {
let mut s = base.as_os_str().to_owned();
s.push(format!(".{n}"));
PathBuf::from(s)
}
fn rotate_chain(base: &Path, max_files: usize) -> std::io::Result<()> {
// Drop the oldest copy if it exists. `max_files = 3` means we keep
// .1, .2, .3 and delete anything that would have been .4.
let oldest = rotated_path(base, max_files);
if oldest.exists() {
fs::remove_file(&oldest)?;
}
// Shuffle: .{n-1} → .{n} for n in (max_files .. 2)
for n in (2..=max_files).rev() {
let from = rotated_path(base, n - 1);
let to = rotated_path(base, n);
if from.exists() {
fs::rename(&from, &to)?;
}
}
// base → .1
fs::rename(base, rotated_path(base, 1))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn temp_dir(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("anvil-runner-rotate-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_n_bytes(p: &Path, n: usize) {
let mut f = fs::File::create(p).unwrap();
f.write_all(&vec![b'x'; n]).unwrap();
}
#[test]
fn no_rotate_when_under_threshold() {
let dir = temp_dir("under");
let log = dir.join("runner.log");
write_n_bytes(&log, 100);
let rotated = maybe_rotate(
&log,
RotationPolicy {
max_size_bytes: 1000,
max_files: 3,
},
)
.unwrap();
assert!(!rotated);
assert_eq!(fs::metadata(&log).unwrap().len(), 100);
}
#[test]
fn rotates_when_over_threshold() {
let dir = temp_dir("over");
let log = dir.join("runner.log");
write_n_bytes(&log, 2000);
let rotated = maybe_rotate(
&log,
RotationPolicy {
max_size_bytes: 1000,
max_files: 3,
},
)
.unwrap();
assert!(rotated);
assert!(!log.exists(), "original should be moved aside");
assert!(
rotated_path(&log, 1).exists(),
".1 should now hold prior contents"
);
}
#[test]
fn rotation_chain_shifts_and_drops_oldest() {
let dir = temp_dir("chain");
let log = dir.join("runner.log");
// Pre-populate .1, .2, .3 with distinguishable contents.
fs::write(rotated_path(&log, 1), b"one").unwrap();
fs::write(rotated_path(&log, 2), b"two").unwrap();
fs::write(rotated_path(&log, 3), b"three").unwrap();
write_n_bytes(&log, 5000);
maybe_rotate(
&log,
RotationPolicy {
max_size_bytes: 1000,
max_files: 3,
},
)
.unwrap();
// .3 should now hold what was .2 (the oldest, "three", got dropped).
assert_eq!(fs::read_to_string(rotated_path(&log, 3)).unwrap(), "two");
assert_eq!(fs::read_to_string(rotated_path(&log, 2)).unwrap(), "one");
// .1 holds the prior live log (5000 bytes of 'x').
assert_eq!(fs::metadata(rotated_path(&log, 1)).unwrap().len(), 5000);
}
#[test]
fn no_rotate_when_log_absent() {
let dir = temp_dir("absent");
let log = dir.join("never-existed.log");
let rotated = maybe_rotate(&log, RotationPolicy::default()).unwrap();
assert!(!rotated);
}
}