1use std::collections::HashSet;
25use std::num::NonZeroUsize;
26use std::time::SystemTime;
27
28use lru::LruCache;
29use parking_lot::RwLock;
30
31use crate::format::compute_file_hash;
32use crate::normalize::{normalize_to_lf, strip_bom};
33
34pub const DEFAULT_MAX_PATHS: usize = 30;
38pub const DEFAULT_MAX_VERSIONS_PER_PATH: usize = 4;
40pub const DEFAULT_MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024;
43
44#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Snapshot {
50 pub path: String,
52 pub text: String,
54 pub hash: String,
56 pub recorded_at: SystemTime,
58 pub seen_lines: Option<HashSet<u32>>,
63}
64
65pub trait SnapshotStore: Send + Sync + std::fmt::Debug {
74 fn head(&self, _path: &str) -> Option<Snapshot> {
76 None
77 }
78 fn by_hash(&self, _path: &str, _hash: &str) -> Option<Snapshot> {
80 None
81 }
82 fn record(&self, _path: &str, _full_text: &str, _seen_lines: Option<&[u32]>) -> String {
86 String::new()
87 }
88 fn record_seen_lines(&self, _path: &str, _hash: &str, _lines: &[u32]) {}
91 fn invalidate(&self, _path: &str) {}
93 fn clear(&self) {}
95}
96
97#[derive(Debug, Clone, Copy)]
101pub struct InMemorySnapshotStoreOptions {
102 pub max_paths: usize,
104 pub max_versions_per_path: usize,
106 pub max_total_bytes: usize,
110}
111
112impl Default for InMemorySnapshotStoreOptions {
113 fn default() -> Self {
114 Self {
115 max_paths: DEFAULT_MAX_PATHS,
116 max_versions_per_path: DEFAULT_MAX_VERSIONS_PER_PATH,
117 max_total_bytes: DEFAULT_MAX_TOTAL_BYTES,
118 }
119 }
120}
121
122struct Inner {
125 cache: LruCache<String, Vec<Snapshot>>,
126 max_versions_per_path: usize,
127 max_total_bytes: usize,
128}
129
130pub struct InMemorySnapshotStore {
138 inner: RwLock<Inner>,
139}
140
141impl InMemorySnapshotStore {
142 pub fn new() -> Self {
144 Self::with_options(InMemorySnapshotStoreOptions::default())
145 }
146
147 pub fn with_options(opts: InMemorySnapshotStoreOptions) -> Self {
149 let cap = NonZeroUsize::new(opts.max_paths.max(1)).expect("clamped to >= 1");
150 Self {
151 inner: RwLock::new(Inner {
152 cache: LruCache::new(cap),
153 max_versions_per_path: opts.max_versions_per_path.max(1),
154 max_total_bytes: opts.max_total_bytes,
155 }),
156 }
157 }
158}
159
160impl Default for InMemorySnapshotStore {
161 fn default() -> Self {
162 Self::new()
163 }
164}
165
166impl std::fmt::Debug for InMemorySnapshotStore {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 f.debug_struct("InMemorySnapshotStore")
169 .finish_non_exhaustive()
170 }
171}
172
173impl SnapshotStore for InMemorySnapshotStore {
174 fn head(&self, path: &str) -> Option<Snapshot> {
175 let mut inner = self.inner.write();
176 inner.cache.get(path).and_then(|hist| hist.first().cloned())
178 }
179
180 fn by_hash(&self, path: &str, hash: &str) -> Option<Snapshot> {
181 let mut inner = self.inner.write();
182 inner
183 .cache
184 .get(path)
185 .and_then(|hist| hist.iter().find(|s| s.hash == hash).cloned())
186 }
187
188 fn record(&self, path: &str, full_text: &str, seen_lines: Option<&[u32]>) -> String {
189 let mut inner = self.inner.write();
190 let text = normalize_to_lf(strip_bom(full_text).text);
193 let hash = compute_file_hash(&text);
194 let mut history = inner.cache.get(path).cloned().unwrap_or_default();
196
197 if let Some(pos) = history.iter().position(|s| s.hash == hash) {
198 let mut snap = history.remove(pos);
201 snap.recorded_at = SystemTime::now();
202 if let Some(lines) = seen_lines {
203 snap.seen_lines
204 .get_or_insert_with(HashSet::new)
205 .extend(lines.iter().copied());
206 }
207 history.insert(0, snap);
208 } else {
209 let mut snap = Snapshot {
210 path: path.to_string(),
211 text,
212 hash: hash.clone(),
213 recorded_at: SystemTime::now(),
214 seen_lines: None,
215 };
216 if let Some(lines) = seen_lines {
217 snap.seen_lines = Some(lines.iter().copied().collect());
218 }
219 history.insert(0, snap);
220 while history.len() > inner.max_versions_per_path {
222 history.pop();
223 }
224 }
225
226 inner.cache.put(path.to_string(), history);
227 enforce_byte_limit(&mut inner);
228 hash
229 }
230
231 fn record_seen_lines(&self, path: &str, hash: &str, lines: &[u32]) {
232 let mut inner = self.inner.write();
233 if let Some(hist) = inner.cache.get_mut(path)
234 && let Some(snap) = hist.iter_mut().find(|s| s.hash == hash)
235 {
236 snap.seen_lines
237 .get_or_insert_with(HashSet::new)
238 .extend(lines.iter().copied());
239 }
240 }
241
242 fn invalidate(&self, path: &str) {
243 let mut inner = self.inner.write();
244 inner.cache.pop(path);
245 }
246
247 fn clear(&self) {
248 let mut inner = self.inner.write();
249 inner.cache.clear();
250 }
251}
252
253fn enforce_byte_limit(inner: &mut Inner) {
257 loop {
258 let total: usize = inner
259 .cache
260 .iter()
261 .flat_map(|(_, hist)| hist.iter())
262 .map(|s| s.text.len())
263 .sum();
264 if total <= inner.max_total_bytes || inner.cache.len() <= 1 {
265 break;
266 }
267 if inner.cache.pop_lru().is_none() {
268 break;
269 }
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 const PATH: &str = "src/foo.rs";
278
279 fn record(store: &impl SnapshotStore, path: &str, text: &str) -> String {
280 store.record(path, text, None)
281 }
282
283 #[test]
284 fn record_and_head_round_trip() {
285 let store = InMemorySnapshotStore::new();
286 let text = "fn main() {}\n";
287 let tag = record(&store, PATH, text);
288 assert_eq!(tag.len(), 4);
289 let head = store.head(PATH).expect("head after record");
290 assert_eq!(head.hash, tag);
291 assert_eq!(head.text, text);
292 assert_eq!(head.path, PATH);
293 assert!(head.seen_lines.is_none());
294 }
295
296 #[test]
297 fn head_missing_returns_none() {
298 let store = InMemorySnapshotStore::new();
299 assert!(store.head(PATH).is_none());
300 }
301
302 #[test]
303 fn by_hash_finds_recorded_version() {
304 let store = InMemorySnapshotStore::new();
305 let tag = record(&store, PATH, "alpha\n");
306 assert_eq!(
307 store.by_hash(PATH, &tag).map(|s| s.text),
308 Some("alpha\n".to_string())
309 );
310 assert!(store.by_hash(PATH, "DEAD").is_none());
311 assert!(store.by_hash("other.rs", &tag).is_none());
312 }
313
314 #[test]
315 fn record_normalizes_text_before_storing() {
316 let store = InMemorySnapshotStore::new();
317 let canonical = "line one\nline two\n";
320 let raw = "\u{feff}line one\r\nline two\r\n";
321 let tag_raw = store.record(PATH, raw, None);
322 let tag_canonical = store.record(PATH, canonical, None);
323 assert_eq!(tag_raw, tag_canonical, "hash must be normalization-stable");
324 let head = store.head(PATH).expect("head");
325 assert_eq!(head.text, canonical, "stored text must be normalized");
326 }
327
328 #[test]
329 fn record_dedups_identical_content() {
330 let store = InMemorySnapshotStore::new();
331 let tag1 = record(&store, PATH, "same\n");
332 let tag2 = record(&store, PATH, "same\n");
333 assert_eq!(tag1, tag2, "identical content reuses the tag");
334 assert!(store.by_hash(PATH, &tag1).is_some());
336 }
337
338 #[test]
339 fn record_promotes_existing_content_to_head() {
340 let store = InMemorySnapshotStore::new();
341 let _a = record(&store, PATH, "a\n");
342 let _b = record(&store, PATH, "b\n");
343 let tag_a = record(&store, PATH, "a\n");
345 let head = store.head(PATH).expect("head");
346 assert_eq!(head.hash, tag_a);
347 assert_eq!(head.text, "a\n");
348 }
349
350 #[test]
351 fn seen_lines_union_on_identical_content() {
352 let store = InMemorySnapshotStore::new();
353 let tag = store.record(PATH, "a\nb\nc\n", Some(&[1, 2]));
354 let head = store.head(PATH).expect("head");
355 assert_eq!(head.seen_lines.as_ref().map(|s| s.len()), Some(2));
356
357 let _ = store.record(PATH, "a\nb\nc\n", Some(&[2, 3]));
359 let head = store.head(PATH).expect("head");
360 let seen = head.seen_lines.expect("seen_lines");
361 assert_eq!(seen, [1, 2, 3].into_iter().collect::<HashSet<_>>());
362 assert_eq!(head.hash, tag);
364 }
365
366 #[test]
367 fn record_seen_lines_merges_into_existing_version() {
368 let store = InMemorySnapshotStore::new();
369 let tag = record(&store, PATH, "a\nb\nc\n");
370 store.record_seen_lines(PATH, &tag, &[1]);
371 store.record_seen_lines(PATH, &tag, &[3]);
372 let head = store.head(PATH).expect("head");
373 let seen = head.seen_lines.expect("seen_lines");
374 assert_eq!(seen, [1, 3].into_iter().collect::<HashSet<_>>());
375 }
376
377 #[test]
378 fn record_seen_lines_noop_for_unknown_hash() {
379 let store = InMemorySnapshotStore::new();
380 store.record(PATH, "a\n", None);
381 store.record_seen_lines(PATH, "NOPE", &[1]);
383 }
384
385 #[test]
386 fn version_cap_drops_oldest() {
387 let store = InMemorySnapshotStore::with_options(InMemorySnapshotStoreOptions {
388 max_versions_per_path: 2,
389 ..Default::default()
390 });
391 let a = record(&store, PATH, "a\n");
392 let b = record(&store, PATH, "b\n");
393 let c = record(&store, PATH, "c\n");
394 assert_eq!(store.head(PATH).map(|s| s.hash), Some(c.clone()));
396 assert!(store.by_hash(PATH, &a).is_none(), "oldest version evicted");
397 assert!(store.by_hash(PATH, &b).is_some());
398 assert!(store.by_hash(PATH, &c).is_some());
399 }
400
401 #[test]
402 fn lru_evicts_coldest_path() {
403 let store = InMemorySnapshotStore::with_options(InMemorySnapshotStoreOptions {
404 max_paths: 2,
405 ..Default::default()
406 });
407 let _ = record(&store, "p1", "one\n");
408 let _ = record(&store, "p2", "two\n");
409 let _ = record(&store, "p3", "three\n");
410 assert!(store.head("p1").is_none(), "coldest path evicted");
412 assert!(store.head("p2").is_some());
413 assert!(store.head("p3").is_some());
414 }
415
416 #[test]
417 fn byte_ceiling_evicts_coldest_path() {
418 let store = InMemorySnapshotStore::with_options(InMemorySnapshotStoreOptions {
420 max_paths: 30,
421 max_total_bytes: 10,
422 ..Default::default()
423 });
424 let _ = store.record("p1", "aaaa", None); let _ = store.record("p2", "bbbb", None); let _ = store.record("p3", "cccc", None); assert!(
428 store.head("p1").is_none(),
429 "oldest path evicted by byte ceiling"
430 );
431 assert!(store.head("p2").is_some());
432 assert!(store.head("p3").is_some());
433 }
434
435 #[test]
436 fn invalidate_drops_single_path() {
437 let store = InMemorySnapshotStore::new();
438 let _ = record(&store, "p1", "one\n");
439 let _ = record(&store, "p2", "two\n");
440 store.invalidate("p1");
441 assert!(store.head("p1").is_none());
442 assert!(store.head("p2").is_some());
443 }
444
445 #[test]
446 fn clear_drops_everything() {
447 let store = InMemorySnapshotStore::new();
448 let _ = record(&store, "p1", "one\n");
449 let _ = record(&store, "p2", "two\n");
450 store.clear();
451 assert!(store.head("p1").is_none());
452 assert!(store.head("p2").is_none());
453 }
454
455 #[test]
456 fn store_is_send_sync() {
457 fn assert_send_sync<T: Send + Sync>() {}
459 assert_send_sync::<InMemorySnapshotStore>();
460 }
461}