Skip to main content

scud_weave/
log.rs

1//! Event log — read/write events.jsonl for coordinator history.
2
3use anyhow::Context;
4use serde::{Deserialize, Serialize};
5use std::io::{BufRead, Write};
6use std::path::Path;
7
8use crate::event::Event;
9
10/// An event with a timestamp, as stored in events.jsonl.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct TimestampedEvent {
13    pub timestamp: String,
14    pub event: Event,
15}
16
17/// Manages the events.jsonl file.
18pub 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    /// Load events from a jsonl file path.
30    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    /// Append an event to the log.
54    pub fn append(&mut self, event: TimestampedEvent) {
55        self.events.push(event);
56    }
57
58    /// Write all events to a jsonl file.
59    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    /// Rotate: keep only the last N events.
74    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        // Should keep the last 5 (indices 5-9 originally)
147        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}