Rust · 8569 bytes Raw Blame History
1 //! Mtime-keyed summary cache.
2 //!
3 //! A full sweep of `~/.claude/projects/` re-summarizing every session
4 //! is a few seconds on a fast disk — cheap enough, but we can make
5 //! cold-start O(changed files) by memoizing summaries keyed on
6 //! `(path, mtime_ns, size)`. The cache lives in the Tauri app data
7 //! directory and is loaded / saved as a bincode blob.
8 //!
9 //! Semantics:
10 //! - `get_or_compute(path, project_id)` returns a cached summary if
11 //! `(mtime_ns, size)` still match, else re-runs `summarize` and
12 //! updates the cache entry.
13 //! - `load(path)` reads the on-disk blob; a missing or corrupt file
14 //! produces an empty cache (we never fail startup because of cache
15 //! issues).
16 //! - `save(path)` writes the blob atomically via a temp file rename.
17 //!
18 //! The cache is intentionally unsharded and holds only summaries
19 //! (small). On-disk size is bounded by sessionCount × ~1 KB, which
20 //! puts even a very active user well under a MB.
21
22 use std::collections::HashMap;
23 use std::fs::File;
24 use std::io::{Read, Write};
25 use std::path::{Path, PathBuf};
26 use std::sync::RwLock;
27
28 use serde::{Deserialize, Serialize};
29
30 use crate::core::error::CoreResult;
31 use crate::core::metadata::summarize;
32 use crate::core::schema::SessionSummary;
33
34 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35 struct Key {
36 mtime_ns: u128,
37 size: u64,
38 }
39
40 #[derive(Debug, Clone, Serialize, Deserialize)]
41 struct Entry {
42 key: Key,
43 summary: SessionSummary,
44 }
45
46 #[derive(Debug, Default, Serialize, Deserialize)]
47 struct CacheBlob {
48 /// Keyed on the absolute session file path.
49 entries: HashMap<PathBuf, Entry>,
50 }
51
52 pub struct SummaryCache {
53 inner: RwLock<CacheBlob>,
54 }
55
56 impl SummaryCache {
57 pub fn empty() -> Self {
58 Self {
59 inner: RwLock::new(CacheBlob::default()),
60 }
61 }
62
63 /// Load from disk. A missing or unreadable blob yields an empty
64 /// cache — never fails startup.
65 pub fn load(blob_path: &Path) -> Self {
66 let Ok(mut f) = File::open(blob_path) else {
67 return Self::empty();
68 };
69 let mut bytes = Vec::new();
70 if f.read_to_end(&mut bytes).is_err() {
71 return Self::empty();
72 }
73 let (blob, _): (CacheBlob, _) =
74 match bincode::serde::decode_from_slice(&bytes, bincode::config::standard()) {
75 Ok(b) => b,
76 Err(_) => return Self::empty(),
77 };
78 Self {
79 inner: RwLock::new(blob),
80 }
81 }
82
83 /// Persist to disk atomically. Errors are returned but not
84 /// typically treated as fatal by callers.
85 pub fn save(&self, blob_path: &Path) -> CoreResult<()> {
86 if let Some(parent) = blob_path.parent() {
87 std::fs::create_dir_all(parent)?;
88 }
89 let bytes = {
90 let inner = self.inner.read().expect("poisoned cache lock");
91 bincode::serde::encode_to_vec(&*inner, bincode::config::standard())
92 .expect("bincode cannot fail on CacheBlob")
93 };
94 let tmp = blob_path.with_extension("bin.tmp");
95 {
96 let mut f = File::create(&tmp)?;
97 f.write_all(&bytes)?;
98 f.sync_all()?;
99 }
100 std::fs::rename(&tmp, blob_path)?;
101 Ok(())
102 }
103
104 /// Return a summary for `session_path`, using the cache when valid
105 /// and falling through to `metadata::summarize` on miss.
106 pub fn get_or_compute(
107 &self,
108 session_path: &Path,
109 project_id: &str,
110 ) -> CoreResult<SessionSummary> {
111 let key = match current_key(session_path) {
112 Ok(k) => k,
113 Err(_) => {
114 return summarize(session_path, project_id);
115 }
116 };
117
118 // Read path.
119 if let Some(entry) = self
120 .inner
121 .read()
122 .expect("poisoned cache lock")
123 .entries
124 .get(session_path)
125 {
126 if entry.key == key {
127 return Ok(entry.summary.clone());
128 }
129 }
130
131 // Miss — compute and store.
132 let summary = summarize(session_path, project_id)?;
133 self.inner
134 .write()
135 .expect("poisoned cache lock")
136 .entries
137 .insert(
138 session_path.to_path_buf(),
139 Entry {
140 key,
141 summary: summary.clone(),
142 },
143 );
144 Ok(summary)
145 }
146
147 /// Remove an entry. Used by the watcher on file deletion.
148 pub fn remove(&self, session_path: &Path) {
149 self.inner
150 .write()
151 .expect("poisoned cache lock")
152 .entries
153 .remove(session_path);
154 }
155
156 /// Number of live entries — for tests/diagnostics.
157 pub fn len(&self) -> usize {
158 self.inner
159 .read()
160 .expect("poisoned cache lock")
161 .entries
162 .len()
163 }
164
165 pub fn is_empty(&self) -> bool {
166 self.len() == 0
167 }
168 }
169
170 fn current_key(path: &Path) -> std::io::Result<Key> {
171 let meta = std::fs::metadata(path)?;
172 let mtime = meta
173 .modified()?
174 .duration_since(std::time::UNIX_EPOCH)
175 .map(|d| d.as_nanos())
176 .unwrap_or(0);
177 Ok(Key {
178 mtime_ns: mtime,
179 size: meta.len(),
180 })
181 }
182
183 #[cfg(test)]
184 mod tests {
185 use super::*;
186 use std::io::Write;
187 use tempfile::tempdir;
188
189 fn write_fixture(path: &Path) {
190 let mut f = File::create(path).unwrap();
191 writeln!(
192 f,
193 r#"{{"type":"user","uuid":"u1","timestamp":"2026-04-11T00:55:35.000Z","cwd":"/x","sessionId":"abc","message":{{"role":"user","content":"hi"}}}}"#
194 )
195 .unwrap();
196 }
197
198 #[test]
199 fn caches_summary_and_reuses_on_unchanged_mtime() {
200 let tmp = tempdir().unwrap();
201 let session = tmp.path().join("s.jsonl");
202 write_fixture(&session);
203
204 let cache = SummaryCache::empty();
205 assert_eq!(cache.len(), 0);
206
207 let s1 = cache.get_or_compute(&session, "pid").unwrap();
208 assert_eq!(cache.len(), 1);
209
210 let s2 = cache.get_or_compute(&session, "pid").unwrap();
211 assert_eq!(s1.title, s2.title);
212 assert_eq!(cache.len(), 1);
213 }
214
215 #[test]
216 fn recomputes_when_mtime_changes() {
217 let tmp = tempdir().unwrap();
218 let session = tmp.path().join("s.jsonl");
219 write_fixture(&session);
220
221 let cache = SummaryCache::empty();
222 let s1 = cache.get_or_compute(&session, "pid").unwrap();
223 let count_1 = s1.message_count;
224
225 // Append a line and ensure mtime advances.
226 std::thread::sleep(std::time::Duration::from_millis(10));
227 let mut f = std::fs::OpenOptions::new()
228 .append(true)
229 .open(&session)
230 .unwrap();
231 writeln!(
232 f,
233 r#"{{"type":"assistant","uuid":"u2","timestamp":"2026-04-11T00:56:00.000Z","sessionId":"abc","message":{{"model":"c","content":[{{"type":"text","text":"ok"}}]}}}}"#
234 )
235 .unwrap();
236 drop(f);
237
238 let s2 = cache.get_or_compute(&session, "pid").unwrap();
239 assert!(s2.message_count > count_1);
240 }
241
242 #[test]
243 fn survives_round_trip_save_load() {
244 let tmp = tempdir().unwrap();
245 let session = tmp.path().join("s.jsonl");
246 write_fixture(&session);
247
248 let blob_path = tmp.path().join("summaries.bin");
249
250 let cache = SummaryCache::empty();
251 cache.get_or_compute(&session, "pid").unwrap();
252 cache.save(&blob_path).unwrap();
253
254 let loaded = SummaryCache::load(&blob_path);
255 assert_eq!(loaded.len(), 1);
256 }
257
258 #[test]
259 fn corrupt_blob_loads_empty() {
260 let tmp = tempdir().unwrap();
261 let blob_path = tmp.path().join("summaries.bin");
262 std::fs::write(&blob_path, b"garbage").unwrap();
263 let loaded = SummaryCache::load(&blob_path);
264 assert!(loaded.is_empty());
265 }
266
267 #[test]
268 fn missing_blob_loads_empty() {
269 let tmp = tempdir().unwrap();
270 let blob_path = tmp.path().join("nonexistent.bin");
271 let loaded = SummaryCache::load(&blob_path);
272 assert!(loaded.is_empty());
273 }
274
275 #[test]
276 fn remove_drops_entry() {
277 let tmp = tempdir().unwrap();
278 let session = tmp.path().join("s.jsonl");
279 write_fixture(&session);
280
281 let cache = SummaryCache::empty();
282 cache.get_or_compute(&session, "pid").unwrap();
283 assert_eq!(cache.len(), 1);
284 cache.remove(&session);
285 assert_eq!(cache.len(), 0);
286 }
287 }
288