Skip to main content

oxicode_hashline/
snapshots.rs

1//! Per-session snapshot store used by recovery and the patcher to bind hashline
2//! section tags to the exact file content that minted them.
3//!
4//! A section tag is a content-derived hash of the *whole file* (see
5//! [`compute_file_hash`]). Any read of byte-identical
6//! content mints the same tag, so reads of one file state fuse onto one anchor,
7//! and a follow-up edit anchored at any line validates whenever the live file
8//! still hashes to it.
9//!
10//! Producers (`read` / `search` / `write` tools) call
11//! [`SnapshotStore::record`] with the full normalized text they observed. The
12//! store hashes it, dedups against the per-path history, and returns the tag.
13//! Consumers (the patcher) resolve a stale tag back to the recorded full text
14//! via [`SnapshotStore::by_hash`] and 3-way-merge the would-be edit onto the
15//! live content.
16//!
17//! [`InMemorySnapshotStore`] ships as a sensible default backed by [`lru`]:
18//! a bounded set of paths, each with a short history of full-file versions so
19//! in-session edit chains can still recover against the version a stale tag
20//! names.
21//!
22//! Ported from omp `packages/hashline/src/snapshots.ts`.
23
24use 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
34// ── Limits ───────────────────────────────────────────────────────────────
35
36/// Default maximum number of distinct paths tracked at once (LRU eviction).
37pub const DEFAULT_MAX_PATHS: usize = 30;
38/// Default maximum full-file versions retained per path (oldest dropped first).
39pub const DEFAULT_MAX_VERSIONS_PER_PATH: usize = 4;
40/// Default global ceiling on retained snapshot text, summed across every path's
41/// version history, measured in bytes (UTF-8).
42pub const DEFAULT_MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024;
43
44// ── Snapshot ─────────────────────────────────────────────────────────────
45
46/// One full-file version observed at a point in time. The tag the model sees is
47/// [`Snapshot::hash`]; recovery replays edits against [`Snapshot::text`].
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Snapshot {
50    /// Canonical path this version belongs to.
51    pub path: String,
52    /// Full normalized (LF, no BOM) file text as observed.
53    pub text: String,
54    /// Content-derived tag for [`Snapshot::text`] (see [`compute_file_hash`]).
55    pub hash: String,
56    /// Wall-clock time the version was recorded.
57    pub recorded_at: SystemTime,
58    /// 1-indexed file lines a producer actually *displayed* under this tag. A
59    /// partial read leaves this sparse; a whole-file read fills every line.
60    /// Multiple reads of the same content union into one set. `None` means "no
61    /// provenance recorded" — the patcher then skips the seen-line check.
62    pub seen_lines: Option<HashSet<u32>>,
63}
64
65// ── Trait ────────────────────────────────────────────────────────────────
66
67/// Storage seam for full-file version snapshots. The patcher calls [`head`]
68/// for the latest version of a path and [`by_hash`] when it needs the specific
69/// historical version a section's stale tag names.
70///
71/// [`head`]: SnapshotStore::head
72/// [`by_hash`]: SnapshotStore::by_hash
73pub trait SnapshotStore: Send + Sync + std::fmt::Debug {
74    /// Most-recently recorded version for `path`, or `None` if none.
75    fn head(&self, _path: &str) -> Option<Snapshot> {
76        None
77    }
78    /// Recorded version for `path` whose tag equals `hash`, or `None`.
79    fn by_hash(&self, _path: &str, _hash: &str) -> Option<Snapshot> {
80        None
81    }
82    /// Record the full normalized text of `path` and return its content tag.
83    /// `seen_lines` (optional) are the 1-indexed lines the producer displayed;
84    /// they merge into [`Snapshot::seen_lines`] across reads of identical text.
85    fn record(&self, _path: &str, _full_text: &str, _seen_lines: Option<&[u32]>) -> String {
86        String::new()
87    }
88    /// Merge `lines` into the [`Snapshot::seen_lines`] of the version whose tag
89    /// equals `hash`. No-op when no such version is retained.
90    fn record_seen_lines(&self, _path: &str, _hash: &str, _lines: &[u32]) {}
91    /// Drop the version history for a single path.
92    fn invalidate(&self, _path: &str) {}
93    /// Drop every version history.
94    fn clear(&self) {}
95}
96
97// ── InMemorySnapshotStore ────────────────────────────────────────────────
98
99/// Knobs for [`InMemorySnapshotStore`].
100#[derive(Debug, Clone, Copy)]
101pub struct InMemorySnapshotStoreOptions {
102    /// Maximum number of distinct paths tracked at once. LRU eviction.
103    pub max_paths: usize,
104    /// Maximum full-file versions retained per path. Oldest dropped first.
105    pub max_versions_per_path: usize,
106    /// Global ceiling on retained snapshot text summed across every path's
107    /// version history, measured in bytes. Least-recently-used path histories
108    /// are evicted to stay under it.
109    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
122/// Mutable inner state guarded by the [`RwLock`]. `LruCache` is `!Sync`, hence
123/// the lock; the per-path value is a short ring of full-file versions.
124struct Inner {
125    cache: LruCache<String, Vec<Snapshot>>,
126    max_versions_per_path: usize,
127    max_total_bytes: usize,
128}
129
130/// In-memory [`SnapshotStore`] backed by [`lru`]. Per-path history is a short
131/// ring of full-file versions (oldest dropped first); per-session path tracking
132/// is LRU-bounded so cold paths age out automatically.
133///
134/// Recording byte-identical content again refreshes recency and reuses the
135/// existing tag (read fusion); recording new content unshifts a fresh version
136/// onto the front of the path history.
137pub struct InMemorySnapshotStore {
138    inner: RwLock<Inner>,
139}
140
141impl InMemorySnapshotStore {
142    /// Build a store with the default limits (30 paths × 4 versions × 64 MiB).
143    pub fn new() -> Self {
144        Self::with_options(InMemorySnapshotStoreOptions::default())
145    }
146
147    /// Build a store with custom limits.
148    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        // `get` refreshes LRU recency for `path`, matching omp's `.get`.
177        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        // Normalize once: LF + no BOM. `compute_file_hash` trims trailing
191        // whitespace for hashing internally, so the tag is stable regardless.
192        let text = normalize_to_lf(strip_bom(full_text).text);
193        let hash = compute_file_hash(&text);
194        // `get` refreshes LRU recency for `path`.
195        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            // Same content observed again: refresh timestamp, promote to head,
199            // and union any newly-displayed lines. Reuse the tag.
200            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            // Oldest versions drop off the back of the ring.
221            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
253/// Evict least-recently-used path histories until retained text fits the global
254/// byte ceiling. Always keeps the most-recently-recorded path (even a single
255/// file larger than the ceiling is retained rather than dropped outright).
256fn 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        // CRLF + BOM must collapse to canonical LF; the tag is the same as the
318        // already-canonical text.
319        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        // Still exactly one version for this hash.
335        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        // Re-record a: it should become head again even though b was newer.
344        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        // Re-read the same content but display a different line range: union.
358        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        // Same tag reused.
363        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        // Must not panic / must be a silent no-op.
382        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        // [c, b] retained; a aged out.
395        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        // p1 is least-recently-used and must have aged out.
411        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        // Keep path count high so the byte ceiling is the binding constraint.
419        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); // 4 bytes
425        let _ = store.record("p2", "bbbb", None); // 4 bytes
426        let _ = store.record("p3", "cccc", None); // 4 bytes -> total 12 > 10
427        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        // Compile-time assertion: the store must be usable across threads.
458        fn assert_send_sync<T: Send + Sync>() {}
459        assert_send_sync::<InMemorySnapshotStore>();
460    }
461}