1use anyhow::Context;
4use serde::{Deserialize, Serialize};
5use std::io::{BufRead, Write};
6use std::path::Path;
7
8use crate::event::Event;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct TimestampedEvent {
13 pub timestamp: String,
14 pub event: Event,
15}
16
17pub struct EventLog {
19 pub events: Vec<TimestampedEvent>,
20}
21
22impl EventLog {
23 pub fn new() -> Self {
24 EventLog {
25 events: Vec::new(),
26 }
27 }
28
29 pub fn load(path: &Path) -> anyhow::Result<Self> {
31 if !path.exists() {
32 return Ok(Self::new());
33 }
34 let file =
35 std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
36 let reader = std::io::BufReader::new(file);
37 let mut events = Vec::new();
38
39 for line in reader.lines() {
40 let line = line?;
41 let trimmed = line.trim();
42 if trimmed.is_empty() || trimmed.starts_with('#') {
43 continue;
44 }
45 let te: TimestampedEvent = serde_json::from_str(trimmed)
46 .with_context(|| format!("parsing event line: {}", trimmed))?;
47 events.push(te);
48 }
49
50 Ok(EventLog { events })
51 }
52
53 pub fn append(&mut self, event: TimestampedEvent) {
55 self.events.push(event);
56 }
57
58 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
60 if let Some(parent) = path.parent() {
61 std::fs::create_dir_all(parent)
62 .with_context(|| format!("creating directory {}", parent.display()))?;
63 }
64 let mut file = std::fs::File::create(path)
65 .with_context(|| format!("creating {}", path.display()))?;
66 for te in &self.events {
67 let json = serde_json::to_string(te)?;
68 writeln!(file, "{}", json)?;
69 }
70 Ok(())
71 }
72
73 pub fn rotate(&mut self, keep: usize) {
75 if self.events.len() > keep {
76 let start = self.events.len() - keep;
77 self.events = self.events.split_off(start);
78 }
79 }
80}
81
82impl Default for EventLog {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91 use crate::event::EventKind;
92 use std::collections::HashMap;
93 use tempfile::TempDir;
94
95 fn make_te(kind: EventKind, agent: &str, ts: &str) -> TimestampedEvent {
96 TimestampedEvent {
97 timestamp: ts.to_string(),
98 event: Event {
99 kind,
100 agent: Some(agent.to_string()),
101 target: None,
102 task_id: None,
103 metadata: HashMap::new(),
104 },
105 }
106 }
107
108 #[test]
109 fn test_save_and_load_roundtrip() {
110 let dir = TempDir::new().unwrap();
111 let path = dir.path().join("events.jsonl");
112
113 let mut log = EventLog::new();
114 log.append(make_te(EventKind::FileWrite, "agent-1", "2026-01-01T00:00:00Z"));
115 log.append(make_te(EventKind::TestPass, "agent-1", "2026-01-01T00:01:00Z"));
116 log.save(&path).unwrap();
117
118 let loaded = EventLog::load(&path).unwrap();
119 assert_eq!(loaded.events.len(), 2);
120 assert_eq!(loaded.events[0].event.kind, EventKind::FileWrite);
121 assert_eq!(loaded.events[1].event.kind, EventKind::TestPass);
122 }
123
124 #[test]
125 fn test_load_nonexistent() {
126 let dir = TempDir::new().unwrap();
127 let path = dir.path().join("nope.jsonl");
128 let log = EventLog::load(&path).unwrap();
129 assert!(log.events.is_empty());
130 }
131
132 #[test]
133 fn test_rotate() {
134 let mut log = EventLog::new();
135 for i in 0..10 {
136 log.append(make_te(
137 EventKind::FileWrite,
138 "agent-1",
139 &format!("2026-01-01T00:{:02}:00Z", i),
140 ));
141 }
142 assert_eq!(log.events.len(), 10);
143
144 log.rotate(5);
145 assert_eq!(log.events.len(), 5);
146 assert!(log.events[0].timestamp.contains("05"));
148 }
149
150 #[test]
151 fn test_rotate_under_limit() {
152 let mut log = EventLog::new();
153 log.append(make_te(EventKind::FileWrite, "agent-1", "2026-01-01T00:00:00Z"));
154 log.rotate(100);
155 assert_eq!(log.events.len(), 1);
156 }
157}