use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FlightEntry {
pub seq: u64,
pub ts: u64,
pub tool: String,
pub args: Value,
}
pub struct FlightRecorder {
path: PathBuf,
next_seq: AtomicU64,
}
impl FlightRecorder {
pub fn new(path: impl Into<PathBuf>) -> io::Result<Self> {
let path = path.into();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let next = Self::scan_last_seq(&path)?.map_or(0, |s| s + 1);
Ok(Self {
path,
next_seq: AtomicU64::new(next),
})
}
pub fn record(&self, tool: &str, args: &Value) -> io::Result<u64> {
let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
let entry = FlightEntry {
seq,
ts: wm_core::time::now_unix_secs(),
tool: tool.to_string(),
args: args.clone(),
};
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
writeln!(
file,
"{}",
serde_json::to_string(&entry).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("serialize: {e}"))
})?
)?;
file.flush()?;
Ok(seq)
}
fn scan_last_seq(path: &Path) -> io::Result<Option<u64>> {
if !path.exists() {
return Ok(None);
}
let last = BufReader::new(File::open(path)?)
.lines()
.map_while(Result::ok)
.filter_map(|l| serde_json::from_str::<FlightEntry>(&l).ok())
.map(|e| e.seq)
.max();
Ok(last)
}
pub fn read_entries(path: impl AsRef<Path>) -> io::Result<(Vec<FlightEntry>, usize)> {
let file = File::open(path.as_ref())?;
let mut entries = Vec::new();
let mut malformed = 0usize;
for line in BufReader::new(file).lines() {
let line = line?;
match serde_json::from_str::<FlightEntry>(&line) {
Ok(e) => entries.push(e),
Err(_) => malformed += 1,
}
}
Ok((entries, malformed))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tmp_path(tag: &str) -> PathBuf {
let dir = tempfile::tempdir().unwrap();
drop(dir);
std::env::temp_dir().join(format!("wm-flight-test-{tag}-{}", std::process::id()))
}
#[test]
fn record_then_read_roundtrip_and_append_seq() {
let path = tmp_path("roundtrip");
let rec = FlightRecorder::new(&path).unwrap();
let s0 = rec
.record("test.writer", &json!({"key": "a", "n": 1}))
.unwrap();
let s1 = rec.record("test.reader", &json!({"id": "x"})).unwrap();
assert_eq!((s0, s1), (0, 1));
let rec2 = FlightRecorder::new(&path).unwrap();
let s2 = rec2
.record("test.writer", &json!({"key": "b", "n": 2}))
.unwrap();
assert_eq!(s2, 2, "reopen continues the sequence");
let (entries, malformed) = FlightRecorder::read_entries(&path).unwrap();
assert_eq!(malformed, 0);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].tool, "test.writer");
assert_eq!(entries[1].args["id"], "x");
let _ = fs::remove_file(&path);
}
#[test]
fn malformed_tail_is_counted_not_silent() {
let path = tmp_path("torn");
let rec = FlightRecorder::new(&path).unwrap();
rec.record("test.writer", &json!({"key": "a"})).unwrap();
let mut f = OpenOptions::new().append(true).open(&path).unwrap();
f.write_all(b"{\"seq\":1,\"tool\":\"te").unwrap();
drop(f);
let (entries, malformed) = FlightRecorder::read_entries(&path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(malformed, 1, "torn line must be loud");
let _ = fs::remove_file(&path);
}
}