1use 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#[derive(Debug, Clone)]
19pub struct ScanQuery {
20 pub window: TimeWindow,
22 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
52pub struct ScanStats {
53 pub partitions_read: usize,
55 pub lines_read: u64,
57 pub lines_skipped: u64,
59 pub events_filtered: u64,
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct Scan {
66 pub events: Vec<Event>,
67 pub stats: ScanStats,
68}
69
70#[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 pub fn partitions_for(&self, window: TimeWindow) -> io::Result<Vec<(Partition, PathBuf)>> {
88 StorePaths::partitions_in(&self.paths.events_dir(), window)
89 }
90
91 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 Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
103 Err(err) => return Err(err),
104 };
105 stats.partitions_read += 1;
106 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 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 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 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}