sqz_engine/cache_manager.rs
1use std::collections::HashMap;
2use std::path::Path;
3use std::time::Duration;
4
5use sha2::{Digest, Sha256};
6
7use crate::delta_encoder::DeltaEncoder;
8use crate::error::Result;
9use crate::pipeline::{CompressionPipeline, SessionContext};
10use crate::preset::Preset;
11use crate::session_store::SessionStore;
12use crate::types::CompressedContent;
13
14/// Outcome of a cache lookup in [`CacheManager`].
15///
16/// The cache has three possible outcomes:
17/// - **Dedup**: exact match, returns a tiny `§ref:HASH§` token (~13 tokens)
18/// - **Delta**: near-duplicate, returns a compact diff against the cached version
19/// - **Fresh**: cache miss, returns the full compressed output
20pub enum CacheResult {
21 /// Previously seen content — returns a short inline reference (~13 tokens).
22 Dedup {
23 /// Inline token of the form `§ref:<hash_prefix>§`.
24 inline_ref: String,
25 /// Approximate token cost of the reference (always 13).
26 token_cost: u32,
27 },
28 /// Near-duplicate of cached content — returns a compact delta.
29 Delta {
30 /// The delta text (header + changed lines).
31 delta_text: String,
32 /// Approximate token cost of the delta.
33 token_cost: u32,
34 /// Similarity to the cached version (0.0–1.0).
35 similarity: f64,
36 },
37 /// Content not seen before — full compression result.
38 Fresh { output: CompressedContent },
39}
40
41/// Result of resolving a dedup-ref prefix via
42/// [`CacheManager::expand_prefix`].
43///
44/// Two shapes because not every stored entry can be round-tripped to the
45/// raw bytes: cache entries written before the `original` column was
46/// introduced (or by a code path that didn't thread originals through)
47/// fall into `CompressedOnly`. The CLI uses this to pick a message for
48/// the user.
49#[derive(Debug, Clone)]
50pub enum ExpandResult {
51 /// Full pre-compression bytes available — this is what `sqz expand`
52 /// was invented to return.
53 Original {
54 /// Full 64-hex SHA-256 of the original content. Lets callers
55 /// print `expanded a1b2c3d4 (full hash a1b2c3d4…)`.
56 hash: String,
57 /// The raw bytes the agent asked to see.
58 bytes: Vec<u8>,
59 },
60 /// Only the compressed form is stored. Agent still gets legible
61 /// output; the CLI attaches a note so the user knows why re-running
62 /// uncompressed may be worthwhile.
63 CompressedOnly {
64 hash: String,
65 /// Compressed text as served to the LLM originally.
66 compressed: String,
67 },
68}
69
70/// Tracks when a dedup ref was last sent, so we can detect staleness.
71///
72/// Historically used for an in-memory per-process turn counter; now kept
73/// only for interface compatibility (clear on notify_compaction). Actual
74/// staleness is computed from SQLite `accessed_at` timestamps so it works
75/// across the shell-hook invocation model where each sqz process is short-
76/// lived. See the comment on `is_ref_fresh` for details.
77#[derive(Debug, Clone)]
78#[allow(dead_code)]
79struct RefEntry {
80 /// The turn number when this ref was last sent to the LLM.
81 last_sent_turn: u64,
82}
83
84/// SHA-256 content-hash deduplication cache backed by [`SessionStore`],
85/// with delta encoding for near-duplicate content and compaction awareness.
86///
87/// # Freshness model
88///
89/// A dedup ref is considered fresh (safe to serve instead of the full
90/// content) when the cache entry's `accessed_at` timestamp in SQLite is
91/// within `max_ref_age` of now. When sqz is invoked from shell hooks each
92/// invocation is a short-lived process, so the freshness check must be
93/// persistent — in-memory state is gone the moment the process exits.
94///
95/// The previous turn-counter heuristic was in-memory only and therefore
96/// never registered freshness across hook invocations, which silently
97/// disabled the dedup feature in production. Issue found April 18 2026.
98///
99/// Default TTL: 30 minutes. Empirically matches a typical active coding
100/// session before a context compaction. Use [`with_ref_age`] to tune.
101pub struct CacheManager {
102 store: SessionStore,
103 max_size_bytes: u64,
104 delta_encoder: DeltaEncoder,
105 /// Retained for notify_compaction's semantic ("forget all tracked refs"),
106 /// but no longer consulted for freshness checks.
107 #[allow(dead_code)]
108 turn_counter: std::cell::Cell<u64>,
109 /// Retained for notify_compaction; cleared on compaction events.
110 #[allow(dead_code)]
111 ref_tracker: std::cell::RefCell<HashMap<String, RefEntry>>,
112 /// Maximum wall-clock age before a dedup ref is considered stale.
113 /// After this duration we assume the LLM's context window has rolled
114 /// over enough to have dropped the original content, so we re-send the
115 /// full version instead of a dangling ref.
116 max_ref_age: Duration,
117 /// Records the instant at which the in-memory compaction flag was set.
118 /// Any cache entry whose `accessed_at` predates this instant is stale.
119 /// Reset by [`notify_compaction`].
120 compaction_marker: std::cell::Cell<Option<chrono::DateTime<chrono::Utc>>>,
121}
122
123impl CacheManager {
124 /// Create a new cache manager backed by the given session store.
125 ///
126 /// `max_size_bytes` controls when LRU eviction kicks in. A good default
127 /// is 512 MB (`512 * 1024 * 1024`). Dedup refs go stale after 30 minutes
128 /// of wall-clock time by default — use [`with_ref_age`] to tune.
129 pub fn new(store: SessionStore, max_size_bytes: u64) -> Self {
130 Self::with_ref_age_duration(store, max_size_bytes, Duration::from_secs(30 * 60))
131 }
132
133 /// Create a CacheManager with a custom ref staleness threshold measured
134 /// in turns. The turn count is converted to wall-clock time by assuming
135 /// ~1 minute per turn (a rough approximation; the real freshness check
136 /// uses SQLite timestamps). This constructor exists for backward
137 /// compatibility with tests that previously advanced a turn counter.
138 #[doc(hidden)]
139 pub fn with_ref_age(store: SessionStore, max_size_bytes: u64, max_ref_age_turns: u64) -> Self {
140 Self::with_ref_age_duration(
141 store,
142 max_size_bytes,
143 Duration::from_secs(max_ref_age_turns.saturating_mul(60)),
144 )
145 }
146
147 /// Create a CacheManager with an explicit wall-clock ref-age cap.
148 pub fn with_ref_age_duration(
149 store: SessionStore,
150 max_size_bytes: u64,
151 max_ref_age: Duration,
152 ) -> Self {
153 Self {
154 store,
155 max_size_bytes,
156 delta_encoder: DeltaEncoder::new(),
157 turn_counter: std::cell::Cell::new(0),
158 ref_tracker: std::cell::RefCell::new(HashMap::new()),
159 max_ref_age,
160 compaction_marker: std::cell::Cell::new(None),
161 }
162 }
163
164 /// Compute the SHA-256 hex digest of `bytes`.
165 fn sha256_hex(bytes: &[u8]) -> String {
166 let mut hasher = Sha256::new();
167 hasher.update(bytes);
168 format!("{:x}", hasher.finalize())
169 }
170
171 /// Advance the turn counter. Retained for API compatibility; not used
172 /// for freshness. The context_evictor still reads `current_turn` for
173 /// LRU scoring during `sqz compact`.
174 pub fn advance_turn(&self) {
175 self.turn_counter.set(self.turn_counter.get() + 1);
176 }
177
178 /// Get the current turn number. Used by the context_evictor for scoring.
179 pub fn current_turn(&self) -> u64 {
180 self.turn_counter.get()
181 }
182
183 /// Notify the cache that a context compaction has occurred.
184 ///
185 /// Persists a compaction timestamp into the session store so any cache
186 /// entry whose `accessed_at` predates the marker is considered stale
187 /// by **every subsequent sqz process**, not just this one. The shell-
188 /// hook invocation model means this method is typically called from a
189 /// short-lived `sqz hook precompact` process, and the check runs in a
190 /// different `sqz compress` process milliseconds later.
191 ///
192 /// Call this when:
193 /// - The harness signals a compaction event (PreCompact hook)
194 /// - A session is resumed after being idle
195 /// - The user runs `sqz compact`
196 pub fn notify_compaction(&self) {
197 let now = chrono::Utc::now();
198 self.compaction_marker.set(Some(now));
199 self.ref_tracker.borrow_mut().clear();
200 // Persist the marker so other sqz processes see the invalidation.
201 // Silently swallow a write error: losing the marker means some
202 // refs may survive the compaction and show as dedup hits in the
203 // next few calls — annoying, not wrong (the agent still receives
204 // valid content; it just sees a short-ref it has to resolve).
205 let _ = self
206 .store
207 .set_metadata("last_compaction_at", &now.to_rfc3339());
208 }
209
210 /// Check if a dedup ref for the given hash is still fresh (likely still
211 /// in the LLM's context window).
212 ///
213 /// Uses the SQLite `accessed_at` timestamp rather than the in-memory
214 /// turn counter. This works across sqz process invocations: shell hooks
215 /// spawn a new sqz process per intercepted command, so any in-memory
216 /// counter would reset every time. The database survives.
217 ///
218 /// The compaction marker is read from SQLite on every check so that
219 /// a `sqz hook precompact` call from another process immediately
220 /// invalidates refs in the current process. Without the persistent
221 /// read, the invalidation would only affect the process that called
222 /// notify_compaction — which is never the same process that serves
223 /// dedup hits.
224 fn is_ref_fresh(&self, hash: &str) -> bool {
225 let accessed = match self.store.get_cache_entry_accessed_at(hash) {
226 Ok(Some(ts)) => ts,
227 _ => return false,
228 };
229 // In-memory compaction marker (set in this process).
230 if let Some(marker) = self.compaction_marker.get() {
231 if accessed < marker {
232 return false;
233 }
234 }
235 // Persistent compaction marker — set by `sqz hook precompact` in
236 // a different process. Without this read the in-memory marker is
237 // never consulted because each hook invocation is a fresh process.
238 if let Ok(Some(raw)) = self.store.get_metadata("last_compaction_at") {
239 if let Ok(marker) = raw.parse::<chrono::DateTime<chrono::Utc>>() {
240 if accessed < marker {
241 return false;
242 }
243 }
244 }
245 let age = (chrono::Utc::now() - accessed)
246 .to_std()
247 .unwrap_or(Duration::from_secs(0));
248 age < self.max_ref_age
249 }
250
251 /// Record that a dedup ref was sent for the given hash. Updates the
252 /// persistent `accessed_at` timestamp so subsequent freshness checks
253 /// see this send. Silently swallows SQLite errors — losing a touch
254 /// means the next call may treat the ref as stale and re-send, which
255 /// is strictly worse on tokens but never wrong.
256 fn record_ref_sent(&self, hash: &str) {
257 let _ = self.store.touch_cache_entry(hash);
258 }
259
260 /// Look up `content` in the cache with compaction awareness.
261 ///
262 /// - On exact dedup with fresh ref: return `CacheResult::Dedup` (~13 tokens).
263 /// - On exact dedup with stale ref: re-compress and return `CacheResult::Fresh`
264 /// (the original content may have been compacted out of the LLM's context).
265 /// - On near-duplicate: return `CacheResult::Delta` with a compact diff.
266 /// - On cache miss: compress via `pipeline`, persist, return `CacheResult::Fresh`.
267 pub fn get_or_compress(
268 &self,
269 _path: &Path,
270 content: &[u8],
271 pipeline: &CompressionPipeline,
272 ) -> Result<CacheResult> {
273 let hash = Self::sha256_hex(content);
274
275 // Exact match — check if the ref is still fresh
276 // Exact match — probe without touching accessed_at, then check
277 // freshness. Touching on the probe would make every ref appear
278 // fresh immediately (the timestamp we just wrote is `now`).
279 let exists = self.store.cache_entry_exists(&hash)?;
280 if exists {
281 if self.is_ref_fresh(&hash) {
282 // Ref is fresh — the LLM likely still has the original in context
283 let hash_prefix = &hash[..16];
284 let inline_ref = format!("§ref:{hash_prefix}§");
285 // Update the sent timestamp
286 self.record_ref_sent(&hash);
287 return Ok(CacheResult::Dedup {
288 inline_ref,
289 token_cost: 13,
290 });
291 } else {
292 // Ref is stale — re-send the full compressed content.
293 // The original may have been compacted out of the LLM's context.
294 let text = String::from_utf8_lossy(content).into_owned();
295 let ctx = SessionContext {
296 session_id: "cache".to_string(),
297 };
298 let preset = Preset::default();
299 let compressed = pipeline.compress(&text, &ctx, &preset)?;
300 // Record that we re-sent this content
301 self.record_ref_sent(&hash);
302 return Ok(CacheResult::Fresh { output: compressed });
303 }
304 }
305
306 // Near-duplicate check: compare against recent cache entries
307 let text = String::from_utf8_lossy(content).into_owned();
308 if let Some(delta_result) = self.try_delta_encode(&text)? {
309 // Store the new content in cache for future exact matches
310 let ctx = SessionContext {
311 session_id: "cache".to_string(),
312 };
313 let preset = Preset::default();
314 let compressed = pipeline.compress(&text, &ctx, &preset)?;
315 // Persist the raw bytes so `sqz expand <prefix>` can round-trip.
316 self.store
317 .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
318 self.record_ref_sent(&hash);
319
320 let token_cost = (delta_result.delta_text.len() / 4) as u32;
321 return Ok(CacheResult::Delta {
322 delta_text: delta_result.delta_text,
323 token_cost: token_cost.max(5),
324 similarity: delta_result.similarity,
325 });
326 }
327
328 let ctx = SessionContext {
329 session_id: "cache".to_string(),
330 };
331 let preset = Preset::default();
332 let compressed = pipeline.compress(&text, &ctx, &preset)?;
333 self.store
334 .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
335 // Record that this content was sent at the current turn
336 self.record_ref_sent(&hash);
337
338 Ok(CacheResult::Fresh { output: compressed })
339 }
340
341 /// Try to delta-encode content against recent cache entries.
342 /// Returns Some(DeltaResult) if a near-duplicate was found.
343 fn try_delta_encode(
344 &self,
345 new_content: &str,
346 ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
347 let entries = self.store.list_cache_entries_lru()?;
348
349 // Check the most recent entries (up to 10) for near-duplicates
350 let check_count = entries.len().min(10);
351 for (hash, _) in entries.iter().rev().take(check_count) {
352 if let Some(cached) = self.store.get_cache_entry(hash)? {
353 let hash_prefix = &hash[..hash.len().min(16)];
354 if let Ok(Some(delta)) =
355 self.delta_encoder
356 .encode(&cached.data, new_content, hash_prefix)
357 {
358 // Only use delta if it's actually smaller than the full content
359 if delta.delta_text.len() < new_content.len() {
360 return Ok(Some(delta));
361 }
362 }
363 }
364 }
365
366 Ok(None)
367 }
368
369 /// Check if `content` is already in the persistent cache (dedup lookup only).
370 ///
371 /// Returns `Some(inline_ref)` if cached AND the ref is still fresh,
372 /// `None` if the content is not cached or the ref is stale.
373 ///
374 /// Unlike [`get_or_compress`], this method does not touch `accessed_at`
375 /// until after the freshness check — otherwise every read would make
376 /// itself "fresh."
377 pub fn check_dedup(&self, content: &[u8]) -> Result<Option<String>> {
378 let hash = Self::sha256_hex(content);
379 // Probe existence without touching accessed_at.
380 let fresh = self.is_ref_fresh(&hash);
381 if fresh {
382 let hash_prefix = &hash[..16];
383 self.record_ref_sent(&hash);
384 Ok(Some(format!("§ref:{hash_prefix}§")))
385 } else {
386 // If the entry exists but is stale, don't return a dangling ref.
387 // If it doesn't exist at all, same result: no dedup.
388 Ok(None)
389 }
390 }
391
392 /// Store a compressed result in the persistent cache, keyed by the
393 /// SHA-256 hash of the original content.
394 ///
395 /// Also records the ref as sent at the current turn for compaction tracking.
396 /// Persists `original_content` alongside `compressed` so that
397 /// `sqz expand <prefix>` can recover the raw bytes for agents that
398 /// cannot parse `§ref:…§` dedup tokens.
399 pub fn store_compressed(
400 &self,
401 original_content: &[u8],
402 compressed: &CompressedContent,
403 ) -> Result<()> {
404 let hash = Self::sha256_hex(original_content);
405 self.store
406 .save_cache_entry_with_original(&hash, compressed, Some(original_content))?;
407 self.record_ref_sent(&hash);
408 Ok(())
409 }
410
411 /// Resolve a hex prefix (the 16-char tail of a `§ref:<prefix>§` token,
412 /// or any longer hex string pasted by a user) to the cached content.
413 ///
414 /// Designed for the `sqz expand <prefix>` CLI: the agent sees a ref
415 /// token it can't parse, runs `sqz expand a1b2c3d4e5f6g7h8`, and gets
416 /// back the raw bytes that produced the ref. This is the
417 /// user-visible escape hatch SquireNed asked for.
418 ///
419 /// Three outcomes:
420 ///
421 /// * `Ok(Some(Original))` — the original bytes were captured when
422 /// the entry was stored (new cache entries from v0.10.0+ always
423 /// capture the original). Write `bytes` to stdout.
424 /// * `Ok(Some(CompressedOnly))` — the entry exists but its `original`
425 /// column is `NULL` (pre-migration data). We still return the
426 /// compressed form — always legible, always useful — and the CLI
427 /// surfaces a note that tells the user to re-run their original
428 /// command with `--no-cache` to capture a truly uncompressed copy.
429 /// * `Ok(None)` — no entry matches. Usually means the ref was
430 /// truncated from a different sqz database (a different machine,
431 /// a wiped `~/.sqz/sessions.db`, etc.).
432 /// * `Err(_)` — prefix was ambiguous or the DB is broken. The error
433 /// carries a user-readable message explaining what went wrong.
434 ///
435 /// Touches `accessed_at` on hit (consistent with `get_cache_entry`).
436 pub fn expand_prefix(&self, prefix: &str) -> Result<Option<ExpandResult>> {
437 let Some((hash, compressed_entry)) = self.store.get_cache_entry_by_prefix(prefix)? else {
438 return Ok(None);
439 };
440
441 // Prefer the original bytes when we have them — that's the
442 // whole point of this feature.
443 if let Some(bytes) = self.store.get_cache_entry_original(&hash)? {
444 return Ok(Some(ExpandResult::Original { hash, bytes }));
445 }
446 Ok(Some(ExpandResult::CompressedOnly {
447 hash,
448 compressed: compressed_entry.data,
449 }))
450 }
451
452 /// Invalidate the cache entry for `path` if its current content is known.
453 ///
454 /// Reads the file at `path`, computes its hash, and removes the matching
455 /// entry from the store. If the file does not exist the call is a no-op.
456 pub fn invalidate(&self, path: &Path) -> Result<()> {
457 if !path.exists() {
458 return Ok(());
459 }
460 let bytes = std::fs::read(path)?;
461 let hash = Self::sha256_hex(&bytes);
462 self.store.delete_cache_entry(&hash)?;
463 Ok(())
464 }
465
466 /// Evict least-recently-used entries until total cache size is at or below
467 /// `max_size_bytes`.
468 ///
469 /// Returns the number of bytes freed.
470 pub fn evict_lru(&self) -> Result<u64> {
471 let entries = self.store.list_cache_entries_lru()?;
472
473 // Compute current total size.
474 let total: u64 = entries.iter().map(|(_, sz)| sz).sum();
475 if total <= self.max_size_bytes {
476 return Ok(0);
477 }
478
479 let mut freed: u64 = 0;
480 let mut remaining = total;
481
482 for (hash, size) in &entries {
483 if remaining <= self.max_size_bytes {
484 break;
485 }
486 self.store.delete_cache_entry(hash)?;
487 freed += size;
488 remaining -= size;
489 }
490
491 Ok(freed)
492 }
493}
494
495// ── Tests ─────────────────────────────────────────────────────────────────────
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use crate::preset::{
501 BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
502 CustomTransformsConfig, ModelConfig, PresetMeta, StripNullsConfig, TerseModeConfig,
503 ToolSelectionConfig, TruncateStringsConfig,
504 };
505 use crate::session_store::SessionStore;
506
507 fn in_memory_store() -> (SessionStore, tempfile::TempDir) {
508 let dir = tempfile::tempdir().unwrap();
509 let path = dir.path().join("test.db");
510 let store = SessionStore::open_or_create(&path).unwrap();
511 (store, dir)
512 }
513
514 fn test_preset() -> Preset {
515 Preset {
516 preset: PresetMeta {
517 name: "test".into(),
518 version: "1.0".into(),
519 description: String::new(),
520 },
521 compression: CompressionConfig {
522 stages: vec![],
523 keep_fields: None,
524 strip_fields: None,
525 condense: Some(CondenseConfig {
526 enabled: true,
527 max_repeated_lines: 3,
528 }),
529 git_diff_fold: None,
530 strip_nulls: Some(StripNullsConfig { enabled: true }),
531 flatten: None,
532 truncate_strings: Some(TruncateStringsConfig {
533 enabled: true,
534 max_length: 500,
535 }),
536 collapse_arrays: Some(CollapseArraysConfig {
537 enabled: true,
538 max_items: 5,
539 summary_template: "... and {remaining} more items".into(),
540 }),
541 custom_transforms: Some(CustomTransformsConfig { enabled: true }),
542 },
543 tool_selection: ToolSelectionConfig {
544 max_tools: 5,
545 similarity_threshold: 0.7,
546 default_tools: vec![],
547 },
548 budget: BudgetConfig {
549 warning_threshold: 0.70,
550 ceiling_threshold: 0.85,
551 default_window_size: 200_000,
552 agents: Default::default(),
553 },
554 terse_mode: TerseModeConfig {
555 enabled: false,
556 level: crate::preset::TerseLevel::Moderate,
557 },
558 model: ModelConfig {
559 family: "anthropic".into(),
560 primary: "claude-sonnet-4-20250514".into(),
561 local: String::new(),
562 complexity_threshold: 0.4,
563 pricing: None,
564 },
565 }
566 }
567
568 fn make_pipeline() -> CompressionPipeline {
569 CompressionPipeline::new(&test_preset())
570 }
571
572 #[test]
573 fn first_read_is_miss() {
574 let (store, _dir) = in_memory_store();
575 let cm = CacheManager::new(store, u64::MAX);
576 let pipeline = make_pipeline();
577 let content = b"hello world";
578 let result = cm
579 .get_or_compress(Path::new("file.txt"), content, &pipeline)
580 .unwrap();
581 assert!(matches!(result, CacheResult::Fresh { .. }));
582 }
583
584 #[test]
585 fn second_read_is_hit() {
586 let (store, _dir) = in_memory_store();
587 let cm = CacheManager::new(store, u64::MAX);
588 let pipeline = make_pipeline();
589 let content = b"hello world";
590 let path = Path::new("file.txt");
591
592 // First read — miss
593 cm.get_or_compress(path, content, &pipeline).unwrap();
594
595 // Second read — hit
596 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
597 match result {
598 CacheResult::Dedup {
599 inline_ref,
600 token_cost,
601 } => {
602 assert!(inline_ref.starts_with("§ref:"));
603 assert!(inline_ref.ends_with('§'));
604 assert_eq!(token_cost, 13);
605 }
606 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => panic!("expected cache hit"),
607 }
608 }
609
610 #[test]
611 fn different_content_is_miss() {
612 let (store, _dir) = in_memory_store();
613 let cm = CacheManager::new(store, u64::MAX);
614 let pipeline = make_pipeline();
615 let path = Path::new("file.txt");
616
617 cm.get_or_compress(path, b"content v1", &pipeline).unwrap();
618 let result = cm
619 .get_or_compress(path, b"content v2", &pipeline)
620 .unwrap();
621 assert!(matches!(result, CacheResult::Fresh { .. } | CacheResult::Delta { .. }));
622 }
623
624 #[test]
625 fn evict_lru_frees_bytes_when_over_limit() {
626 let (store, _dir) = in_memory_store();
627 // Very small limit so eviction triggers immediately.
628 let cm = CacheManager::new(store, 1);
629 let pipeline = make_pipeline();
630 let path = Path::new("f.txt");
631
632 // Populate cache with a few entries.
633 cm.get_or_compress(path, b"entry one", &pipeline).unwrap();
634 cm.get_or_compress(path, b"entry two", &pipeline).unwrap();
635 cm.get_or_compress(path, b"entry three", &pipeline).unwrap();
636
637 let freed = cm.evict_lru().unwrap();
638 assert!(freed > 0, "expected bytes to be freed");
639 }
640
641 #[test]
642 fn evict_lru_no_op_when_under_limit() {
643 let (store, _dir) = in_memory_store();
644 let cm = CacheManager::new(store, u64::MAX);
645 let pipeline = make_pipeline();
646
647 cm.get_or_compress(Path::new("f.txt"), b"data", &pipeline)
648 .unwrap();
649
650 let freed = cm.evict_lru().unwrap();
651 assert_eq!(freed, 0);
652 }
653
654 #[test]
655 fn invalidate_removes_entry() {
656 let dir = tempfile::tempdir().unwrap();
657 let file_path = dir.path().join("test.txt");
658 std::fs::write(&file_path, b"some content").unwrap();
659
660 let store_path = dir.path().join("store.db");
661 let store = SessionStore::open_or_create(&store_path).unwrap();
662 let cm = CacheManager::new(store, u64::MAX);
663 let pipeline = make_pipeline();
664
665 // Populate cache.
666 let content = std::fs::read(&file_path).unwrap();
667 cm.get_or_compress(&file_path, &content, &pipeline).unwrap();
668
669 // Verify it's a hit.
670 let hit = cm
671 .get_or_compress(&file_path, &content, &pipeline)
672 .unwrap();
673 assert!(matches!(hit, CacheResult::Dedup { .. }));
674
675 cm.invalidate(&file_path).unwrap();
676
677 let miss = cm
678 .get_or_compress(&file_path, &content, &pipeline)
679 .unwrap();
680 assert!(matches!(miss, CacheResult::Fresh { .. }));
681 }
682
683 #[test]
684 fn invalidate_nonexistent_path_is_noop() {
685 let (store, _dir) = in_memory_store();
686 let cm = CacheManager::new(store, u64::MAX);
687 // Should not error.
688 cm.invalidate(Path::new("/nonexistent/path/file.txt"))
689 .unwrap();
690 }
691
692 // ── Compaction / freshness tests ──────────────────────────────────────
693 //
694 // These tests used to exercise an in-memory turn counter. Freshness is
695 // now computed from SQLite `accessed_at` timestamps so dedup works
696 // across the shell-hook model (each hook invocation is a fresh
697 // process). The tests below use wall-clock durations instead.
698
699 #[test]
700 fn stale_ref_returns_fresh_instead_of_dedup() {
701 let (store, _dir) = in_memory_store();
702 // Set max_ref_age to 0 — every ref goes stale immediately.
703 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::ZERO);
704 let pipeline = make_pipeline();
705 let content = b"hello world";
706 let path = Path::new("file.txt");
707
708 // First read — miss. accessed_at recorded.
709 cm.get_or_compress(path, content, &pipeline).unwrap();
710
711 // Second read — with TTL=0 the ref is already stale, should re-send.
712 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
713 assert!(
714 matches!(result, CacheResult::Fresh { .. }),
715 "stale ref (TTL=0) should return Fresh, not Dedup"
716 );
717 }
718
719 #[test]
720 fn fresh_ref_returns_dedup() {
721 let (store, _dir) = in_memory_store();
722 // Generous TTL: one day. Refs stay fresh for the life of the test.
723 let cm = CacheManager::with_ref_age_duration(
724 store,
725 u64::MAX,
726 Duration::from_secs(86_400),
727 );
728 let pipeline = make_pipeline();
729 let content = b"hello world";
730 let path = Path::new("file.txt");
731
732 cm.get_or_compress(path, content, &pipeline).unwrap();
733 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
734 assert!(
735 matches!(result, CacheResult::Dedup { .. }),
736 "fresh ref should dedup"
737 );
738 }
739
740 #[test]
741 fn notify_compaction_invalidates_all_refs() {
742 let (store, _dir) = in_memory_store();
743 let cm = CacheManager::with_ref_age_duration(
744 store,
745 u64::MAX,
746 Duration::from_secs(86_400),
747 );
748 let pipeline = make_pipeline();
749 let path = Path::new("file.txt");
750
751 // Populate cache — every subsequent read is a dedup hit.
752 cm.get_or_compress(path, b"content A", &pipeline).unwrap();
753 cm.get_or_compress(path, b"content B", &pipeline).unwrap();
754 assert!(matches!(
755 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
756 CacheResult::Dedup { .. }
757 ));
758 assert!(matches!(
759 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
760 CacheResult::Dedup { .. }
761 ));
762
763 // Simulate a context compaction. The compaction marker is set to
764 // `now`; any cache entry whose accessed_at predates this moment is
765 // treated as stale even though the TTL hasn't expired.
766 // Sleep 10ms to ensure `now` is strictly after the last touch.
767 std::thread::sleep(std::time::Duration::from_millis(10));
768 cm.notify_compaction();
769
770 // After compaction, refs predate the marker — re-send full content.
771 assert!(matches!(
772 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
773 CacheResult::Fresh { .. }
774 ));
775 assert!(matches!(
776 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
777 CacheResult::Fresh { .. }
778 ));
779 }
780
781 #[test]
782 fn ref_refreshed_after_resend() {
783 let (store, _dir) = in_memory_store();
784 // TTL of 10ms: a fresh send bumps accessed_at, so immediately after
785 // the re-send the ref is fresh again.
786 let cm = CacheManager::with_ref_age_duration(
787 store,
788 u64::MAX,
789 Duration::from_millis(10),
790 );
791 let pipeline = make_pipeline();
792 let content = b"hello world";
793 let path = Path::new("file.txt");
794
795 cm.get_or_compress(path, content, &pipeline).unwrap();
796 // Wait past the TTL so the entry is stale.
797 std::thread::sleep(std::time::Duration::from_millis(25));
798
799 // Stale — must re-send Fresh. The re-send bumps accessed_at.
800 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
801 assert!(matches!(result, CacheResult::Fresh { .. }));
802
803 // Immediately read again — the freshly-updated accessed_at is
804 // within the 10ms TTL, so the ref is fresh.
805 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
806 assert!(
807 matches!(result, CacheResult::Dedup { .. }),
808 "ref should be fresh after re-send"
809 );
810 }
811
812 #[test]
813 fn check_dedup_returns_none_for_stale_ref() {
814 let (store, _dir) = in_memory_store();
815 let cm = CacheManager::with_ref_age_duration(
816 store,
817 u64::MAX,
818 Duration::from_millis(10),
819 );
820 let pipeline = make_pipeline();
821 let content = b"test content";
822 let path = Path::new("file.txt");
823
824 cm.get_or_compress(path, content, &pipeline).unwrap();
825
826 // Immediately fresh.
827 assert!(cm.check_dedup(content).unwrap().is_some());
828
829 // Wait past TTL.
830 std::thread::sleep(std::time::Duration::from_millis(25));
831 assert!(
832 cm.check_dedup(content).unwrap().is_none(),
833 "stale ref should not be returned by check_dedup"
834 );
835 }
836
837 #[test]
838 fn advance_turn_increments_counter() {
839 // The counter is retained for context_evictor compatibility.
840 let (store, _dir) = in_memory_store();
841 let cm = CacheManager::new(store, u64::MAX);
842 assert_eq!(cm.current_turn(), 0);
843 cm.advance_turn();
844 assert_eq!(cm.current_turn(), 1);
845 cm.advance_turn();
846 assert_eq!(cm.current_turn(), 2);
847 }
848
849 // ── Expand feature tests ────────────────────────────────────────────
850
851 #[test]
852 fn expand_returns_original_bytes_for_new_entry() {
853 // New cache entries (written after the `original` column migration)
854 // must always round-trip back to the exact bytes the agent sent.
855 // This is the core guarantee `sqz expand` exists to provide.
856 let (store, _dir) = in_memory_store();
857 let cm = CacheManager::new(store, u64::MAX);
858 let pipeline = make_pipeline();
859 let path = Path::new("x.txt");
860 let content = b"hello\nworld\nthis is the original\n";
861
862 // Seed the cache by running the content through get_or_compress.
863 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
864 let CacheResult::Fresh { .. } = first else {
865 panic!("first read should be a Fresh miss, got something else");
866 };
867
868 // Find the ref prefix by doing a second read (which dedups).
869 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
870 let inline_ref = match second {
871 CacheResult::Dedup { inline_ref, .. } => inline_ref,
872 _ => panic!("second read should dedup"),
873 };
874
875 // Strip the `§ref:` / `§` wrappers to get the raw prefix.
876 let prefix = inline_ref
877 .strip_prefix("§ref:")
878 .and_then(|s| s.strip_suffix('§'))
879 .expect("unexpected ref format");
880
881 let result = cm.expand_prefix(prefix).unwrap().expect("expand hit expected");
882 match result {
883 ExpandResult::Original { bytes, hash } => {
884 assert_eq!(bytes, content, "expand must return exact original bytes");
885 assert!(
886 hash.starts_with(prefix),
887 "returned full hash {hash} must start with prefix {prefix}"
888 );
889 assert_eq!(hash.len(), 64, "full SHA-256 must be 64 hex chars");
890 }
891 ExpandResult::CompressedOnly { .. } => {
892 panic!("new cache entries must carry the original bytes");
893 }
894 }
895 }
896
897 #[test]
898 fn expand_prefix_rejects_nonhex_input() {
899 // Agents paste all sorts of things — ref tokens with the §'s still
900 // attached, mixed-case hashes, stray whitespace. The store layer
901 // normalises most of this but we also want to reject obvious
902 // garbage without touching SQLite.
903 let (store, _dir) = in_memory_store();
904 let cm = CacheManager::new(store, u64::MAX);
905 assert!(cm.expand_prefix("").unwrap().is_none(), "empty prefix is no-match");
906 assert!(cm.expand_prefix("not a hex").unwrap().is_none());
907 assert!(cm.expand_prefix("ABCDEF").unwrap().is_none(), "uppercase hex should not match (sqz emits lowercase)");
908 assert!(cm.expand_prefix("g0g0g0").unwrap().is_none());
909 }
910
911 #[test]
912 fn expand_prefix_returns_none_for_unknown_prefix() {
913 // Very common in practice: agent pastes a ref from a different
914 // machine, a different sqz install, or after a cache wipe.
915 let (store, _dir) = in_memory_store();
916 let cm = CacheManager::new(store, u64::MAX);
917 let pipeline = make_pipeline();
918 // Seed one entry.
919 let _ = cm
920 .get_or_compress(
921 Path::new("y.txt"),
922 b"some cached content that won't match the prefix below",
923 &pipeline,
924 )
925 .unwrap();
926 assert!(
927 cm.expand_prefix("0000000000000000").unwrap().is_none(),
928 "prefix that matches nothing must return None, not error"
929 );
930 }
931
932 #[test]
933 fn expand_prefix_errors_on_ambiguous_match() {
934 // Two entries whose hashes both start with the same hex prefix
935 // must be detected and reported. The caller is expected to
936 // surface "use a longer prefix" — we don't want to silently
937 // pick one.
938 let (store, _dir) = in_memory_store();
939 // Hand-craft two entries with prefixes that collide on "ab".
940 let compressed = crate::types::CompressedContent {
941 data: "compressed-a".to_string(),
942 tokens_compressed: 1,
943 tokens_original: 10,
944 stages_applied: vec![],
945 compression_ratio: 0.1,
946 provenance: Default::default(),
947 verify: None,
948 };
949 store
950 .save_cache_entry_with_original(
951 &format!("ab{}", "0".repeat(62)),
952 &compressed,
953 Some(b"content a"),
954 )
955 .unwrap();
956 store
957 .save_cache_entry_with_original(
958 &format!("ab{}", "1".repeat(62)),
959 &compressed,
960 Some(b"content b"),
961 )
962 .unwrap();
963
964 let cm = CacheManager::new(store, u64::MAX);
965 // Unambiguous 3-char prefixes resolve:
966 let ok_a = cm.expand_prefix(&format!("ab{}", "0")).unwrap().unwrap();
967 assert!(matches!(ok_a, ExpandResult::Original { .. }));
968 // Ambiguous 2-char prefix errors out.
969 let err = cm.expand_prefix("ab").unwrap_err();
970 let msg = err.to_string();
971 assert!(
972 msg.contains("multiple entries") || msg.contains("longer prefix"),
973 "ambiguity error should mention multiple matches / longer prefix, got: {msg}"
974 );
975 }
976
977 #[test]
978 fn expand_returns_compressed_only_for_pre_migration_entry() {
979 // Entries written via `save_cache_entry` (without the `_with_original`
980 // suffix) leave the `original` column NULL. Expand must still return
981 // something useful — the compressed blob — with a note (handled by
982 // the CLI). Here we assert the engine returns the right variant.
983 let (store, _dir) = in_memory_store();
984 let compressed = crate::types::CompressedContent {
985 data: "the compressed version".to_string(),
986 tokens_compressed: 4,
987 tokens_original: 100,
988 stages_applied: vec!["condense".to_string()],
989 compression_ratio: 0.04,
990 provenance: Default::default(),
991 verify: None,
992 };
993 // Deliberately use the legacy save_cache_entry (no original).
994 let hash = format!("deadbeef{}", "0".repeat(56));
995 store.save_cache_entry(&hash, &compressed).unwrap();
996
997 let cm = CacheManager::new(store, u64::MAX);
998 let result = cm
999 .expand_prefix("deadbeef")
1000 .unwrap()
1001 .expect("deadbeef prefix should match");
1002 match result {
1003 ExpandResult::CompressedOnly { compressed, hash: h } => {
1004 assert_eq!(compressed, "the compressed version");
1005 assert_eq!(h, hash);
1006 }
1007 ExpandResult::Original { .. } => {
1008 panic!("legacy entry without original bytes should return CompressedOnly");
1009 }
1010 }
1011 }
1012
1013 #[test]
1014 fn expand_preserves_non_utf8_original_bytes() {
1015 // Shell output routinely contains non-UTF-8 sequences (terminal
1016 // control codes, binary blobs from `cat` on a wrong file, etc.).
1017 // Expand must round-trip byte-for-byte — not through a UTF-8
1018 // lossy conversion — or agents auditing the cache will see
1019 // spurious `\uFFFD` REPLACEMENT CHARACTER bytes.
1020 let (store, _dir) = in_memory_store();
1021 let cm = CacheManager::new(store, u64::MAX);
1022 let pipeline = make_pipeline();
1023
1024 // Mix of valid UTF-8 and isolated 0xFF bytes.
1025 let content: Vec<u8> = vec![
1026 b'h', b'e', b'l', b'l', b'o', b'\n',
1027 0xFF, 0xFE, 0xFD,
1028 b'\n',
1029 ];
1030 let path = Path::new("bin.dat");
1031 let _ = cm.get_or_compress(path, &content, &pipeline).unwrap();
1032 // Round-trip via expand_prefix.
1033 let hash = CacheManager::sha256_hex(&content);
1034 let result = cm.expand_prefix(&hash[..16]).unwrap().unwrap();
1035 match result {
1036 ExpandResult::Original { bytes, .. } => {
1037 assert_eq!(bytes, content, "non-UTF-8 bytes must round-trip exactly");
1038 }
1039 ExpandResult::CompressedOnly { .. } => {
1040 panic!("fresh entry should have original bytes");
1041 }
1042 }
1043 }
1044
1045 #[test]
1046 fn dedup_survives_cache_manager_restart() {
1047 // Regression for the April 18 bug: the turn counter was in-memory
1048 // only, so every new sqz process saw an empty ref tracker and the
1049 // dedup feature silently produced Fresh results forever. With
1050 // accessed_at-based freshness, a fresh CacheManager reading the
1051 // same SQLite store picks up the dedup correctly.
1052 let dir = tempfile::tempdir().unwrap();
1053 let db_path = dir.path().join("cache.db");
1054 let pipeline = make_pipeline();
1055 let content = b"a substantial chunk of content to dedup";
1056 let path = Path::new("x.txt");
1057
1058 // First "process": populate cache.
1059 {
1060 let store = SessionStore::open_or_create(&db_path).unwrap();
1061 let cm = CacheManager::with_ref_age_duration(
1062 store,
1063 u64::MAX,
1064 Duration::from_secs(3600),
1065 );
1066 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1067 assert!(matches!(first, CacheResult::Fresh { .. }));
1068 }
1069
1070 // Second "process": new CacheManager, same DB. Dedup must fire.
1071 {
1072 let store = SessionStore::open_or_create(&db_path).unwrap();
1073 let cm = CacheManager::with_ref_age_duration(
1074 store,
1075 u64::MAX,
1076 Duration::from_secs(3600),
1077 );
1078 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1079 assert!(
1080 matches!(second, CacheResult::Dedup { .. }),
1081 "second-process read must dedup — this was broken before the April 18 fix"
1082 );
1083 }
1084 }
1085
1086 #[test]
1087 fn compaction_from_one_process_invalidates_refs_in_another() {
1088 // Regression for the PreCompact hook wiring: the host harness
1089 // (e.g. Claude Code) runs `sqz hook precompact` in a short-lived
1090 // process to signal auto-compaction. The actual dedup serving runs
1091 // in a DIFFERENT sqz process (the shell hook). notify_compaction
1092 // must persist through SQLite so the second process sees it.
1093 //
1094 // Before the fix, compaction_marker was Cell<Option<DateTime>>
1095 // in memory only — the precompact process set it, exited, the
1096 // state was lost. Next shell-hook process started with a clean
1097 // marker, served stale refs to the agent, and the agent saw a
1098 // §ref:HASH§ pointing at content no longer in its context.
1099 let dir = tempfile::tempdir().unwrap();
1100 let db_path = dir.path().join("cache.db");
1101 let pipeline = make_pipeline();
1102 let content = b"content that needs stale-marking after compaction";
1103 let path = Path::new("file.txt");
1104 let ttl = Duration::from_secs(3600);
1105
1106 // Process A: populate the cache so the content is dedup-eligible.
1107 {
1108 let store = SessionStore::open_or_create(&db_path).unwrap();
1109 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1110 cm.get_or_compress(path, content, &pipeline).unwrap();
1111 }
1112 // Sleep so the compaction marker is strictly after the touch.
1113 std::thread::sleep(Duration::from_millis(10));
1114
1115 // Process B: simulates `sqz hook precompact`. Just calls
1116 // notify_compaction and exits. No reads.
1117 {
1118 let store = SessionStore::open_or_create(&db_path).unwrap();
1119 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1120 cm.notify_compaction();
1121 }
1122
1123 // Process C: simulates the next `sqz compress` shell-hook call.
1124 // Reads the same content. MUST re-send Fresh, not return a ref
1125 // the agent can no longer resolve.
1126 {
1127 let store = SessionStore::open_or_create(&db_path).unwrap();
1128 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1129 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1130 assert!(
1131 matches!(result, CacheResult::Fresh { .. }),
1132 "post-compaction read from a fresh process must re-send Fresh; \
1133 returning Dedup would be a dangling-ref bug"
1134 );
1135 }
1136 }
1137
1138 use proptest::prelude::*;
1139
1140 // ── Property 8: Cache deduplication ──────────────────────────────────────
1141 // **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
1142 //
1143 // For any file content, reading the file twice through the CacheManager
1144 // (with no content change between reads) SHALL return a cache hit on the
1145 // second read with a reference token of approximately 13 tokens.
1146
1147 proptest! {
1148 /// **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
1149 ///
1150 /// For any file content, the second read through CacheManager SHALL be
1151 /// a cache hit with tokens == 13.
1152 #[test]
1153 fn prop_cache_deduplication(
1154 content in proptest::collection::vec(any::<u8>(), 1..=1000usize),
1155 ) {
1156 let (store, _dir) = in_memory_store();
1157 let cm = CacheManager::new(store, u64::MAX);
1158 let pipeline = make_pipeline();
1159 let path = Path::new("file.txt");
1160
1161 // First read — must be a miss.
1162 let first = cm.get_or_compress(path, &content, &pipeline).unwrap();
1163 prop_assert!(
1164 matches!(first, CacheResult::Fresh { .. }),
1165 "first read should be a cache miss"
1166 );
1167
1168 let second = cm.get_or_compress(path, &content, &pipeline).unwrap();
1169 match second {
1170 CacheResult::Dedup { inline_ref, token_cost } => {
1171 prop_assert_eq!(
1172 token_cost, 13,
1173 "cache hit should report ~13 reference tokens"
1174 );
1175 prop_assert!(
1176 inline_ref.starts_with("§ref:"),
1177 "reference token should start with §ref:"
1178 );
1179 prop_assert!(
1180 inline_ref.ends_with('§'),
1181 "reference token should end with §"
1182 );
1183 }
1184 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1185 prop_assert!(false, "second read should be a cache hit, not a miss");
1186 }
1187 }
1188 }
1189 }
1190
1191 // ── Property 9: Cache invalidation on content change ─────────────────────
1192 // **Validates: Requirements 8.3, 18.3**
1193 //
1194 // For any cached file, if the file content changes (producing a different
1195 // SHA-256 hash), the CacheManager SHALL treat the next read as a cache miss
1196 // and re-compress the updated content.
1197
1198 proptest! {
1199 /// **Validates: Requirements 8.3, 18.3**
1200 ///
1201 /// For any two distinct byte sequences, the first read of each is a
1202 /// cache miss — content change always triggers re-compression.
1203 #[test]
1204 fn prop_cache_invalidation_on_content_change(
1205 content_a in proptest::collection::vec(any::<u8>(), 1..=500usize),
1206 content_b in proptest::collection::vec(any::<u8>(), 1..=500usize),
1207 ) {
1208 // Only meaningful when the two contents differ (different hashes).
1209 prop_assume!(content_a != content_b);
1210
1211 let (store, _dir) = in_memory_store();
1212 let cm = CacheManager::new(store, u64::MAX);
1213 let pipeline = make_pipeline();
1214 let path = Path::new("file.txt");
1215
1216 // Cache content_a.
1217 let r1 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1218 prop_assert!(
1219 matches!(r1, CacheResult::Fresh { .. }),
1220 "first read of content_a should be a miss"
1221 );
1222
1223 let r2 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1224 prop_assert!(
1225 matches!(r2, CacheResult::Dedup { .. }),
1226 "second read of content_a should be a hit"
1227 );
1228
1229 let r3 = cm.get_or_compress(path, &content_b, &pipeline).unwrap();
1230 prop_assert!(
1231 matches!(r3, CacheResult::Fresh { .. } | CacheResult::Delta { .. }),
1232 "read with changed content should be a cache miss or delta"
1233 );
1234 }
1235 }
1236
1237 // ── Property 10: Cache LRU eviction ──────────────────────────────────────
1238 // **Validates: Requirements 8.5**
1239 //
1240 // For any cache state where total size exceeds the configured maximum, the
1241 // CacheManager SHALL evict entries in LRU order until total size is at or
1242 // below the limit.
1243
1244 proptest! {
1245 /// **Validates: Requirements 8.5**
1246 ///
1247 /// After evict_lru, the total remaining cache size SHALL be at or below
1248 /// max_size_bytes.
1249 #[test]
1250 fn prop_cache_lru_eviction(
1251 // Generate 2-8 distinct content entries.
1252 entries in proptest::collection::vec(
1253 proptest::collection::vec(any::<u8>(), 10..=200usize),
1254 2..=8usize,
1255 ),
1256 ) {
1257 // Deduplicate entries so each has a unique hash.
1258 let mut unique_entries: Vec<Vec<u8>> = Vec::new();
1259 for e in &entries {
1260 if !unique_entries.contains(e) {
1261 unique_entries.push(e.clone());
1262 }
1263 }
1264 prop_assume!(unique_entries.len() >= 2);
1265
1266 let (store, _dir) = in_memory_store();
1267 // Use a very small limit (1 byte) to guarantee eviction is needed.
1268 let cm = CacheManager::new(store, 1);
1269 let pipeline = make_pipeline();
1270 let path = Path::new("f.txt");
1271
1272 // Populate the cache.
1273 for entry in &unique_entries {
1274 cm.get_or_compress(path, entry, &pipeline).unwrap();
1275 }
1276
1277 // Evict LRU entries.
1278 let freed = cm.evict_lru().unwrap();
1279
1280 // Bytes freed must be > 0 since total > 1 byte.
1281 prop_assert!(freed > 0, "evict_lru should free bytes when over limit");
1282
1283 // After eviction, total remaining size must be <= max_size_bytes (1).
1284 // We verify by checking that evict_lru now returns 0 (nothing left to evict).
1285 let freed_again = cm.evict_lru().unwrap();
1286 prop_assert_eq!(
1287 freed_again, 0,
1288 "second evict_lru call should free 0 bytes (already at or below limit)"
1289 );
1290 }
1291 }
1292
1293 // ── Property 34: Cache persistence across sessions ────────────────────────
1294 // **Validates: Requirements 18.4**
1295 //
1296 // For any set of cache entries saved to the SessionStore, reloading the
1297 // store (opening the same database file) SHALL produce the same cache
1298 // entries, and a subsequent read with the same content hash SHALL return a
1299 // cache hit.
1300
1301 proptest! {
1302 /// **Validates: Requirements 18.4**
1303 ///
1304 /// Cache entries written in one CacheManager instance SHALL survive
1305 /// a store close/reopen. With the wall-clock freshness model
1306 /// (introduced April 18 2026), a subsequent CacheManager reading
1307 /// the same database SHALL see the entry as fresh (within TTL) and
1308 /// return a Dedup hit on the very first read — this is the whole
1309 /// point of the cross-process fix. Previous behavior (Fresh on
1310 /// first read after restart) was a bug that silently disabled the
1311 /// dedup feature in production.
1312 #[test]
1313 fn prop_cache_persistence_across_sessions(
1314 content in proptest::collection::vec(any::<u8>(), 1..=500usize),
1315 ) {
1316 use crate::session_store::SessionStore;
1317
1318 let dir = tempfile::tempdir().unwrap();
1319 let db_path = dir.path().join("cache.db");
1320 let path = Path::new("file.txt");
1321
1322 // Session 1: populate the cache.
1323 {
1324 let store = SessionStore::open_or_create(&db_path).unwrap();
1325 // Explicit long TTL so tests don't race with wall-clock drift.
1326 let cm = CacheManager::with_ref_age_duration(
1327 store,
1328 u64::MAX,
1329 Duration::from_secs(3600),
1330 );
1331 let pipeline = make_pipeline();
1332
1333 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1334 prop_assert!(
1335 matches!(r, CacheResult::Fresh { .. }),
1336 "first-ever read should be a miss"
1337 );
1338 }
1339
1340 // Session 2: reopen the same database file.
1341 {
1342 let store = SessionStore::open_or_create(&db_path).unwrap();
1343 let cm = CacheManager::with_ref_age_duration(
1344 store,
1345 u64::MAX,
1346 Duration::from_secs(3600),
1347 );
1348 let pipeline = make_pipeline();
1349
1350 // First read in the new session MUST dedup. The entry was
1351 // just written (within TTL), so the wall-clock freshness
1352 // check finds it fresh. This is what makes sqz's dedup
1353 // actually work across shell-hook invocations.
1354 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1355 match r {
1356 CacheResult::Dedup { token_cost, .. } => {
1357 prop_assert_eq!(
1358 token_cost, 13,
1359 "first read after restart must be a 13-token dedup ref"
1360 );
1361 }
1362 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1363 prop_assert!(
1364 false,
1365 "first read after restart must dedup — this was the \
1366 April 18 bug and its fix is the whole reason this \
1367 test exists"
1368 );
1369 }
1370 }
1371 }
1372 }
1373 }
1374}