Skip to main content

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}