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