use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub ts: String,
pub seq: u64,
pub tool_name: String,
#[serde(default)]
pub tool_input: serde_json::Value,
#[serde(default)]
pub tool_response: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
pub fn append_event(span_path: &Path, event: &Event) -> Result<()> {
let line = serde_json::to_string(event)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(span_path)?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")?;
Ok(())
}
pub fn fsync(span_path: &Path) -> Result<()> {
OpenOptions::new().read(true).open(span_path)?.sync_all()?;
Ok(())
}
pub fn count_events(span_path: &Path) -> u64 {
match std::fs::read_to_string(span_path) {
Ok(contents) => contents.lines().filter(|l| !l.trim().is_empty()).count() as u64,
Err(_) => 0,
}
}
pub fn read_span(span_path: &Path) -> Result<Vec<Event>> {
let contents = std::fs::read_to_string(span_path)?;
let mut events = Vec::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(event) = serde_json::from_str::<Event>(line) {
events.push(event);
}
}
Ok(events)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(seq: u64) -> Event {
Event {
ts: "2026-06-19T00:00:00Z".into(),
seq,
tool_name: "Bash".into(),
tool_input: serde_json::json!({ "command": "echo hi" }),
tool_response: serde_json::json!({ "exit_code": 0 }),
cwd: Some("/tmp".into()),
session_id: Some("s1".into()),
}
}
#[test]
fn append_then_read_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("span.jsonl");
assert_eq!(count_events(&path), 0, "missing file counts as zero events");
append_event(&path, &sample(0)).unwrap();
append_event(&path, &sample(1)).unwrap();
assert_eq!(count_events(&path), 2);
let events = read_span(&path).unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].seq, 0);
assert_eq!(events[1].seq, 1);
assert_eq!(events[0].tool_name, "Bash");
}
#[test]
fn read_skips_corrupt_lines() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("span.jsonl");
append_event(&path, &sample(0)).unwrap();
let mut file = OpenOptions::new().append(true).open(&path).unwrap();
writeln!(file, "{{ not valid json").unwrap();
assert_eq!(count_events(&path), 2);
assert_eq!(read_span(&path).unwrap().len(), 1);
}
}