Skip to main content

warden/store/
scanner.rs

1//! The shared read path.
2//!
3//! Every report consumes this scanner and nothing else opens event files
4//! directly — that is what keeps a derived cache addable later without touching
5//! each report. The scanner opens only partitions overlapping the requested
6//! window and skips a torn or unparseable line rather than failing the run.
7
8use std::fs::File;
9use std::io::{self, BufRead, BufReader};
10use std::path::PathBuf;
11
12use crate::cli::TimeWindow;
13
14use super::paths::{Partition, StorePaths};
15use super::record::Event;
16
17/// A filtered scan over the event store.
18#[derive(Debug, Clone)]
19pub struct ScanQuery {
20    /// Only events whose `ts` falls in this window are yielded.
21    pub window: TimeWindow,
22    /// Optional exact-match project filter.
23    pub project: Option<String>,
24}
25
26impl ScanQuery {
27    pub fn new(window: TimeWindow) -> Self {
28        Self {
29            window,
30            project: None,
31        }
32    }
33
34    pub fn with_project(mut self, project: Option<String>) -> Self {
35        self.project = project;
36        self
37    }
38
39    fn matches(&self, event: &Event) -> bool {
40        if !self.window.contains(event.ts) {
41            return false;
42        }
43        match &self.project {
44            Some(project) => event.project.as_deref() == Some(project.as_str()),
45            None => true,
46        }
47    }
48}
49
50/// What a scan skipped, so callers can report it honestly.
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
52pub struct ScanStats {
53    /// Partitions actually opened.
54    pub partitions_read: usize,
55    /// Lines read from those partitions.
56    pub lines_read: u64,
57    /// Lines that failed to parse (torn final line, or schema drift).
58    pub lines_skipped: u64,
59    /// Parsed events excluded by the window or project filter.
60    pub events_filtered: u64,
61}
62
63/// Result of a scan: the matching events plus what was skipped.
64#[derive(Debug, Clone, Default)]
65pub struct Scan {
66    pub events: Vec<Event>,
67    pub stats: ScanStats,
68}
69
70/// Reads events from the store.
71#[derive(Debug, Clone)]
72pub struct Scanner {
73    paths: StorePaths,
74}
75
76impl Scanner {
77    pub fn new(paths: StorePaths) -> Self {
78        Self { paths }
79    }
80
81    pub fn paths(&self) -> &StorePaths {
82        &self.paths
83    }
84
85    /// Partitions present on disk that overlap the query window, in
86    /// chronological order.
87    pub fn partitions_for(&self, window: TimeWindow) -> io::Result<Vec<(Partition, PathBuf)>> {
88        StorePaths::partitions_in(&self.paths.events_dir(), window)
89    }
90
91    /// Scan the store, calling `visit` for each matching event in partition
92    /// order. Unparseable lines are counted and skipped.
93    pub fn scan_with<F>(&self, query: &ScanQuery, mut visit: F) -> io::Result<ScanStats>
94    where
95        F: FnMut(Event),
96    {
97        let mut stats = ScanStats::default();
98        for (_, path) in self.partitions_for(query.window)? {
99            let file = match File::open(&path) {
100                Ok(file) => file,
101                // A partition can vanish between listing and opening.
102                Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
103                Err(err) => return Err(err),
104            };
105            stats.partitions_read += 1;
106            // One reused buffer rather than `lines()`, which allocates a fresh
107            // String per line: a full-store scan is hundreds of thousands of
108            // lines and every one of them is dropped before the next is read.
109            let mut reader = BufReader::new(file);
110            let mut line = String::new();
111            loop {
112                line.clear();
113                if reader.read_line(&mut line)? == 0 {
114                    break;
115                }
116                if line.trim().is_empty() {
117                    continue;
118                }
119                stats.lines_read += 1;
120                match serde_json::from_str::<Event>(&line) {
121                    Ok(event) if query.matches(&event) => visit(event),
122                    Ok(_) => stats.events_filtered += 1,
123                    Err(_) => stats.lines_skipped += 1,
124                }
125            }
126        }
127        Ok(stats)
128    }
129
130    /// Collect matching events into memory.
131    pub fn scan(&self, query: &ScanQuery) -> io::Result<Scan> {
132        let mut events = Vec::new();
133        let stats = self.scan_with(query, |event| events.push(event))?;
134        Ok(Scan { events, stats })
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::store::record::Event;
142    use crate::store::writer::StoreWriter;
143    use chrono::{TimeZone, Utc};
144
145    fn ms(y: i32, mo: u32, d: u32) -> i64 {
146        Utc.with_ymd_and_hms(y, mo, d, 12, 0, 0)
147            .unwrap()
148            .timestamp_millis()
149    }
150
151    fn event(id: &str, ts: i64, project: &str) -> Event {
152        let mut event = Event::new(id, ts, "claude-code", "anthropic", "assistant");
153        event.project = Some(project.to_string());
154        event
155    }
156
157    fn store_with_three_months() -> (tempfile::TempDir, StorePaths) {
158        let dir = tempfile::tempdir().unwrap();
159        let paths = StorePaths::new(dir.path());
160        let mut writer = StoreWriter::open(paths.clone()).unwrap();
161        writer
162            .append_event(&event("jun", ms(2026, 6, 15), "acme"))
163            .unwrap();
164        writer
165            .append_event(&event("jul", ms(2026, 7, 15), "acme"))
166            .unwrap();
167        writer
168            .append_event(&event("aug", ms(2026, 8, 15), "warden"))
169            .unwrap();
170        (dir, paths)
171    }
172
173    #[test]
174    fn opens_only_overlapping_partitions() {
175        let (_dir, paths) = store_with_three_months();
176        let scanner = Scanner::new(paths);
177        let window = TimeWindow::new(ms(2026, 7, 10), ms(2026, 8, 20));
178
179        let partitions: Vec<_> = scanner
180            .partitions_for(window)
181            .unwrap()
182            .into_iter()
183            .map(|(p, _)| p)
184            .collect();
185        assert_eq!(
186            partitions,
187            vec![Partition::new(2026, 7), Partition::new(2026, 8)]
188        );
189
190        let scan = scanner.scan(&ScanQuery::new(window)).unwrap();
191        assert_eq!(scan.stats.partitions_read, 2);
192        let ids: Vec<_> = scan.events.iter().map(|e| e.id.as_str()).collect();
193        assert_eq!(ids, vec!["jul", "aug"]);
194    }
195
196    #[test]
197    fn filters_by_window_within_a_partition() {
198        let (_dir, paths) = store_with_three_months();
199        let scanner = Scanner::new(paths);
200        // Covers all of July but starts after the 15th, so nothing matches.
201        let window = TimeWindow::new(ms(2026, 7, 20), ms(2026, 7, 25));
202        let scan = scanner.scan(&ScanQuery::new(window)).unwrap();
203        assert!(scan.events.is_empty());
204        assert_eq!(scan.stats.partitions_read, 1);
205        assert_eq!(scan.stats.events_filtered, 1);
206    }
207
208    #[test]
209    fn filters_by_project() {
210        let (_dir, paths) = store_with_three_months();
211        let scanner = Scanner::new(paths);
212        let query = ScanQuery::new(TimeWindow::all()).with_project(Some("acme".into()));
213        let scan = scanner.scan(&query).unwrap();
214        let ids: Vec<_> = scan.events.iter().map(|e| e.id.as_str()).collect();
215        assert_eq!(ids, vec!["jun", "jul"]);
216        assert_eq!(scan.stats.events_filtered, 1);
217    }
218
219    #[test]
220    fn skips_torn_final_line_and_counts_it() {
221        let (_dir, paths) = store_with_three_months();
222        let partition = paths.event_partition(Partition::new(2026, 8));
223        let mut text = std::fs::read_to_string(&partition).unwrap();
224        // A reader that hits a half-written record must skip it.
225        text.push_str("{\"v\":1,\"id\":\"torn\",\"ts\":17543000");
226        std::fs::write(&partition, text).unwrap();
227
228        let scan = Scanner::new(paths)
229            .scan(&ScanQuery::new(TimeWindow::all()))
230            .unwrap();
231        let ids: Vec<_> = scan.events.iter().map(|e| e.id.as_str()).collect();
232        assert_eq!(ids, vec!["jun", "jul", "aug"]);
233        assert_eq!(scan.stats.lines_skipped, 1);
234        assert_eq!(scan.stats.lines_read, 4);
235    }
236
237    #[test]
238    fn missing_store_scans_empty() {
239        let dir = tempfile::tempdir().unwrap();
240        let scanner = Scanner::new(StorePaths::new(dir.path().join("absent")));
241        let scan = scanner.scan(&ScanQuery::new(TimeWindow::all())).unwrap();
242        assert!(scan.events.is_empty());
243        assert_eq!(scan.stats, ScanStats::default());
244    }
245
246    #[test]
247    fn ignores_non_partition_files() {
248        let (_dir, paths) = store_with_three_months();
249        std::fs::write(paths.events_dir().join("notes.txt"), "junk").unwrap();
250        std::fs::write(paths.events_dir().join("scratch.jsonl"), "junk\n").unwrap();
251        let scan = Scanner::new(paths)
252            .scan(&ScanQuery::new(TimeWindow::all()))
253            .unwrap();
254        assert_eq!(scan.events.len(), 3);
255        assert_eq!(scan.stats.partitions_read, 3);
256    }
257}