use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub(crate) trait Clock {
fn should_continue(&mut self) -> bool;
fn sleep(&mut self, dur: Duration);
}
pub(crate) struct Forever;
impl Clock for Forever {
fn should_continue(&mut self) -> bool {
true
}
fn sleep(&mut self, dur: Duration) {
std::thread::sleep(dur);
}
}
#[cfg(test)]
pub(crate) struct Bounded {
pub(crate) remaining: usize,
}
#[cfg(test)]
impl Clock for Bounded {
fn should_continue(&mut self) -> bool {
if self.remaining == 0 {
return false;
}
self.remaining -= 1;
true
}
fn sleep(&mut self, _dur: Duration) {}
}
type Offsets = HashMap<PathBuf, u64>;
fn log_files(run_dir: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = match std::fs::read_dir(run_dir) {
Ok(entries) => entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "log"))
.collect(),
Err(_) => Vec::new(),
};
files.sort();
files
}
pub(crate) fn emit_new_bytes(path: &Path, offsets: &mut Offsets, json: bool) -> u64 {
let content = match std::fs::read(path) {
Ok(c) => c,
Err(_) => return 0,
};
let seen = offsets.entry(path.to_path_buf()).or_insert(0);
let len = content.len() as u64;
if len < *seen {
*seen = 0;
}
if len == *seen {
return 0;
}
let fresh = String::from_utf8_lossy(&content[*seen as usize..]).to_string();
let new_bytes = len - *seen;
*seen = len;
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if json {
let obj = serde_json::json!({
"action": "follow",
"status": "append",
"file": name,
"bytes": new_bytes,
"content": fresh,
});
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
} else {
for line in fresh.lines() {
println!("[{name}] {line}");
}
}
new_bytes
}
pub(crate) fn tail_run_dir(
run_dir: &Path,
json: bool,
clock: &mut dyn Clock,
interval: Duration,
) -> u64 {
let mut offsets: Offsets = HashMap::new();
let mut total = 0u64;
while clock.should_continue() {
for path in log_files(run_dir) {
total += emit_new_bytes(&path, &mut offsets, json);
}
clock.sleep(interval);
}
total
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tail_streams_bytes_appended_between_polls() {
let tmp = tempfile::tempdir().expect("tempdir");
let log = tmp.path().join("a.create.log");
std::fs::write(&log, "line one\n").expect("write");
let streamed = tail_run_dir(
tmp.path(),
false,
&mut Bounded { remaining: 1 },
Duration::from_millis(0),
);
assert_eq!(streamed, 9, "first poll must stream the existing content");
}
#[test]
fn tail_does_not_re_emit_already_streamed_bytes() {
let tmp = tempfile::tempdir().expect("tempdir");
let log = tmp.path().join("a.create.log");
std::fs::write(&log, "hello\n").expect("write");
let streamed = tail_run_dir(
tmp.path(),
false,
&mut Bounded { remaining: 4 },
Duration::from_millis(0),
);
assert_eq!(streamed, 6, "content must be streamed exactly once");
}
#[test]
fn emit_new_bytes_resumes_after_truncation() {
let tmp = tempfile::tempdir().expect("tempdir");
let log = tmp.path().join("a.create.log");
std::fs::write(&log, "abcdef").expect("write");
let mut offsets = Offsets::new();
assert_eq!(emit_new_bytes(&log, &mut offsets, false), 6);
std::fs::write(&log, "xy").expect("rewrite");
assert_eq!(
emit_new_bytes(&log, &mut offsets, false),
2,
"a truncated log must restart rather than go silent"
);
}
#[test]
fn tail_ignores_non_log_files() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("meta.yaml"), "run_id: r-1\n").expect("write");
let streamed = tail_run_dir(
tmp.path(),
false,
&mut Bounded { remaining: 2 },
Duration::from_millis(0),
);
assert_eq!(streamed, 0);
}
}