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    /// Session-lifetime MinHash LSH index over stored entries, used to find
122    /// near-duplicate candidates for delta encoding beyond the recent-LRU
123    /// window. In-memory only; rebuilt organically as entries are stored.
124    lsh: std::cell::RefCell<crate::minhash_lsh::MinHashLsh>,
125    /// doc_id (index) → content hash for entries in `lsh`.
126    lsh_hashes: std::cell::RefCell<Vec<String>>,
127}
128
129impl CacheManager {
130    /// Create a new cache manager backed by the given session store.
131    ///
132    /// `max_size_bytes` controls when LRU eviction kicks in. A good default
133    /// is 512 MB (`512 * 1024 * 1024`). Dedup refs go stale after 30 minutes
134    /// of wall-clock time by default — use [`with_ref_age`] to tune.
135    pub fn new(store: SessionStore, max_size_bytes: u64) -> Self {
136        Self::with_ref_age_duration(store, max_size_bytes, Duration::from_secs(30 * 60))
137    }
138
139    /// Create a CacheManager with a custom ref staleness threshold measured
140    /// in turns. The turn count is converted to wall-clock time by assuming
141    /// ~1 minute per turn (a rough approximation; the real freshness check
142    /// uses SQLite timestamps). This constructor exists for backward
143    /// compatibility with tests that previously advanced a turn counter.
144    #[doc(hidden)]
145    pub fn with_ref_age(store: SessionStore, max_size_bytes: u64, max_ref_age_turns: u64) -> Self {
146        Self::with_ref_age_duration(
147            store,
148            max_size_bytes,
149            Duration::from_secs(max_ref_age_turns.saturating_mul(60)),
150        )
151    }
152
153    /// Create a CacheManager with an explicit wall-clock ref-age cap.
154    pub fn with_ref_age_duration(
155        store: SessionStore,
156        max_size_bytes: u64,
157        max_ref_age: Duration,
158    ) -> Self {
159        Self {
160            store,
161            max_size_bytes,
162            delta_encoder: DeltaEncoder::new(),
163            turn_counter: std::cell::Cell::new(0),
164            ref_tracker: std::cell::RefCell::new(HashMap::new()),
165            max_ref_age,
166            compaction_marker: std::cell::Cell::new(None),
167            lsh: std::cell::RefCell::new(crate::minhash_lsh::MinHashLsh::new()),
168            lsh_hashes: std::cell::RefCell::new(Vec::new()),
169        }
170    }
171
172    /// Register a stored entry in the similarity index. Capped so a very
173    /// long session can't grow the index unboundedly; beyond the cap the
174    /// recent-LRU fallback in [`Self::try_delta_encode`] still applies.
175    fn index_for_similarity(&self, hash: &str, data: &str) {
176        const MAX_INDEXED: usize = 512;
177        let mut hashes = self.lsh_hashes.borrow_mut();
178        if hashes.len() >= MAX_INDEXED || hashes.iter().any(|h| h == hash) {
179            return;
180        }
181        let doc_id = hashes.len() as u64;
182        self.lsh.borrow_mut().insert(doc_id, data);
183        hashes.push(hash.to_string());
184    }
185
186    /// Compute the SHA-256 hex digest of `bytes`.
187    pub fn sha256_hex(bytes: &[u8]) -> String {
188        let mut hasher = Sha256::new();
189        hasher.update(bytes);
190        format!("{:x}", hasher.finalize())
191    }
192
193    /// Advance the turn counter. Retained for API compatibility; not used
194    /// for freshness. The context_evictor still reads `current_turn` for
195    /// LRU scoring during `sqz compact`.
196    pub fn advance_turn(&self) {
197        self.turn_counter.set(self.turn_counter.get() + 1);
198    }
199
200    /// Get the current turn number. Used by the context_evictor for scoring.
201    pub fn current_turn(&self) -> u64 {
202        self.turn_counter.get()
203    }
204
205    /// Notify the cache that a context compaction has occurred.
206    ///
207    /// Persists a compaction timestamp into the session store so any cache
208    /// entry whose `accessed_at` predates the marker is considered stale
209    /// by **every subsequent sqz process**, not just this one. The shell-
210    /// hook invocation model means this method is typically called from a
211    /// short-lived `sqz hook precompact` process, and the check runs in a
212    /// different `sqz compress` process milliseconds later.
213    ///
214    /// Call this when:
215    /// - The harness signals a compaction event (PreCompact hook)
216    /// - A session is resumed after being idle
217    /// - The user runs `sqz compact`
218    pub fn notify_compaction(&self) {
219        let now = chrono::Utc::now();
220        self.compaction_marker.set(Some(now));
221        self.ref_tracker.borrow_mut().clear();
222        // Persist the marker so other sqz processes see the invalidation.
223        // Silently swallow a write error: losing the marker means some
224        // refs may survive the compaction and show as dedup hits in the
225        // next few calls — annoying, not wrong (the agent still receives
226        // valid content; it just sees a short-ref it has to resolve).
227        let _ = self
228            .store
229            .set_metadata("last_compaction_at", &now.to_rfc3339());
230    }
231
232    /// Check if a dedup ref for the given hash is still fresh (likely still
233    /// in the LLM's context window).
234    ///
235    /// Uses the SQLite `accessed_at` timestamp rather than the in-memory
236    /// turn counter. This works across sqz process invocations: shell hooks
237    /// spawn a new sqz process per intercepted command, so any in-memory
238    /// counter would reset every time. The database survives.
239    ///
240    /// The compaction marker is read from SQLite on every check so that
241    /// a `sqz hook precompact` call from another process immediately
242    /// invalidates refs in the current process. Without the persistent
243    /// read, the invalidation would only affect the process that called
244    /// notify_compaction — which is never the same process that serves
245    /// dedup hits.
246    fn is_ref_fresh(&self, hash: &str) -> bool {
247        // Pinned entries bypass TTL and compaction-marker checks. They
248        // represent stable knowledge-base content that's persistently
249        // re-injected into every agent, so the original is never gone.
250        if let Ok(true) = self.store.is_cache_entry_pinned(hash) {
251            return true;
252        }
253        let accessed = match self.store.get_cache_entry_accessed_at(hash) {
254            Ok(Some(ts)) => ts,
255            _ => return false,
256        };
257        // In-memory compaction marker (set in this process).
258        if let Some(marker) = self.compaction_marker.get() {
259            if accessed < marker {
260                return false;
261            }
262        }
263        // Persistent compaction marker — set by `sqz hook precompact` in
264        // a different process. Without this read the in-memory marker is
265        // never consulted because each hook invocation is a fresh process.
266        if let Ok(Some(raw)) = self.store.get_metadata("last_compaction_at") {
267            if let Ok(marker) = raw.parse::<chrono::DateTime<chrono::Utc>>() {
268                if accessed < marker {
269                    return false;
270                }
271            }
272        }
273        let age = (chrono::Utc::now() - accessed)
274            .to_std()
275            .unwrap_or(Duration::from_secs(0));
276        age < self.max_ref_age
277    }
278
279    /// Record that a dedup ref was sent for the given hash. Updates the
280    /// persistent `accessed_at` timestamp so subsequent freshness checks
281    /// see this send. Silently swallows SQLite errors — losing a touch
282    /// means the next call may treat the ref as stale and re-send, which
283    /// is strictly worse on tokens but never wrong.
284    fn record_ref_sent(&self, hash: &str) {
285        let _ = self.store.touch_cache_entry(hash);
286    }
287
288    /// Look up `content` in the cache with compaction awareness.
289    ///
290    /// - On exact dedup with fresh ref: return `CacheResult::Dedup` (~13 tokens).
291    /// - On exact dedup with stale ref: re-compress and return `CacheResult::Fresh`
292    ///   (the original content may have been compacted out of the LLM's context).
293    /// - On near-duplicate: return `CacheResult::Delta` with a compact diff.
294    /// - On cache miss: compress via `pipeline`, persist, return `CacheResult::Fresh`.
295    pub fn get_or_compress(
296        &self,
297        path: &Path,
298        content: &[u8],
299        pipeline: &CompressionPipeline,
300    ) -> Result<CacheResult> {
301        self.get_or_compress_inner(path, content, pipeline, false)
302    }
303
304    /// Lossless variant used by the file-read MCP tools (`sqz_read_file` /
305    /// `sqz_grep` / `sqz_list_dir`): on a cache miss the content is returned
306    /// faithfully via [`CompressionPipeline::compress_lossless`] (no entropy
307    /// truncation / pruning / re-encoding). The general `compress` tool keeps
308    /// using [`get_or_compress`] for aggressive compression. See issue #32.
309    pub fn get_or_compress_lossless(
310        &self,
311        path: &Path,
312        content: &[u8],
313        pipeline: &CompressionPipeline,
314    ) -> Result<CacheResult> {
315        self.get_or_compress_inner(path, content, pipeline, true)
316    }
317
318    fn get_or_compress_inner(
319        &self,
320        _path: &Path,
321        content: &[u8],
322        pipeline: &CompressionPipeline,
323        lossless: bool,
324    ) -> Result<CacheResult> {
325        let hash = Self::sha256_hex(content);
326
327        // Exact match — check if the ref is still fresh
328        // Exact match — probe without touching accessed_at, then check
329        // freshness. Touching on the probe would make every ref appear
330        // fresh immediately (the timestamp we just wrote is `now`).
331        let exists = self.store.cache_entry_exists(&hash)?;
332        if exists {
333            if self.is_ref_fresh(&hash) {
334                // Ref is fresh — the LLM likely still has the original in context
335                let hash_prefix = &hash[..16];
336                let inline_ref = format!("§ref:{hash_prefix}§");
337                // Update the sent timestamp
338                self.record_ref_sent(&hash);
339                let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache hit record error: {e}"));
340                return Ok(CacheResult::Dedup {
341                    inline_ref,
342                    token_cost: 13,
343                });
344            } else {
345                // Ref is stale — re-send the full compressed content.
346                // The original may have been compacted out of the LLM's context.
347                let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache stale-hit record error: {e}"));
348                let text = String::from_utf8_lossy(content).into_owned();
349                let ctx = SessionContext {
350                    session_id: "cache".to_string(),
351                };
352                let preset = Preset::default();
353                let compressed = if lossless {
354                    pipeline.compress_lossless(&text, &ctx, &preset)?
355                } else {
356                    pipeline.compress(&text, &ctx, &preset)?
357                };
358                // Record that we re-sent this content
359                self.record_ref_sent(&hash);
360                return Ok(CacheResult::Fresh { output: compressed });
361            }
362        }
363
364        // Near-duplicate check: compare against recent cache entries
365        let text = String::from_utf8_lossy(content).into_owned();
366        let _ = self.store.record_cache_miss().map_err(|e| eprintln!("[sqz] cache miss record error: {e}"));
367        if let Some(delta_result) = self.try_delta_encode(&text)? {
368            // Store the new content in cache for future exact matches
369            let ctx = SessionContext {
370                session_id: "cache".to_string(),
371            };
372            let preset = Preset::default();
373            let compressed = if lossless {
374                pipeline.compress_lossless(&text, &ctx, &preset)?
375            } else {
376                pipeline.compress(&text, &ctx, &preset)?
377            };
378            // Persist the raw bytes so `sqz expand <prefix>` can round-trip.
379            self.store
380                .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
381            self.index_for_similarity(&hash, &compressed.data);
382            self.record_ref_sent(&hash);
383
384            let token_cost = (delta_result.delta_text.len() / 4) as u32;
385            return Ok(CacheResult::Delta {
386                delta_text: delta_result.delta_text,
387                token_cost: token_cost.max(5),
388                similarity: delta_result.similarity,
389            });
390        }
391
392        let ctx = SessionContext {
393            session_id: "cache".to_string(),
394        };
395        let preset = Preset::default();
396        let compressed = if lossless {
397            pipeline.compress_lossless(&text, &ctx, &preset)?
398        } else {
399            pipeline.compress(&text, &ctx, &preset)?
400        };
401        self.store
402            .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
403        self.index_for_similarity(&hash, &compressed.data);
404        // Record that this content was sent at the current turn
405        self.record_ref_sent(&hash);
406
407        Ok(CacheResult::Fresh { output: compressed })
408    }
409
410    /// Try to delta-encode content against cached entries.
411    /// Returns Some(DeltaResult) if a near-duplicate was found.
412    ///
413    /// Candidates come from two sources, in order:
414    /// 1. The MinHash LSH index — finds near-duplicates anywhere in the
415    ///    session, not just the recent window. Candidates are ranked by
416    ///    estimated Jaccard similarity and the best 5 are tried.
417    /// 2. Fallback: the 10 most recent LRU entries (covers content stored
418    ///    before this process built its in-memory index).
419    fn try_delta_encode(
420        &self,
421        new_content: &str,
422    ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
423        // LSH path.
424        let candidates: Vec<String> = {
425            let lsh = self.lsh.borrow();
426            let hashes = self.lsh_hashes.borrow();
427            let query_sig = crate::minhash_lsh::MinHashLsh::compute_signature(new_content);
428            let mut scored: Vec<(f64, &String)> = lsh
429                .query(new_content)
430                .into_iter()
431                .filter_map(|doc_id| {
432                    let hash = hashes.get(doc_id as usize)?;
433                    let sim = lsh.get_signature(doc_id)?.jaccard_similarity(&query_sig);
434                    (sim >= 0.5).then_some((sim, hash))
435                })
436                .collect();
437            scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
438            scored.into_iter().take(5).map(|(_, h)| h.clone()).collect()
439        };
440        for hash in &candidates {
441            if let Some(delta) = self.try_delta_against(hash, new_content)? {
442                return Ok(Some(delta));
443            }
444        }
445
446        // Recent-LRU fallback.
447        let entries = self.store.list_cache_entries_lru()?;
448        let check_count = entries.len().min(10);
449        for (hash, _) in entries.iter().rev().take(check_count) {
450            if candidates.iter().any(|c| c == hash) {
451                continue;
452            }
453            if let Some(delta) = self.try_delta_against(hash, new_content)? {
454                return Ok(Some(delta));
455            }
456        }
457
458        Ok(None)
459    }
460
461    fn try_delta_against(
462        &self,
463        hash: &str,
464        new_content: &str,
465    ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
466        if let Some(cached) = self.store.get_cache_entry(hash)? {
467            let hash_prefix = &hash[..hash.len().min(16)];
468            if let Ok(Some(delta)) = self.delta_encoder.encode(&cached.data, new_content, hash_prefix) {
469                if delta.delta_text.len() < new_content.len() {
470                    return Ok(Some(delta));
471                }
472            }
473        }
474        Ok(None)
475    }
476
477    /// Check if `content` is already in the persistent cache (dedup lookup only).
478    ///
479    /// Returns `Some(inline_ref)` if cached AND the ref is still fresh,
480    /// `None` if the content is not cached or the ref is stale.
481    ///
482    /// Unlike [`get_or_compress`], this method does not touch `accessed_at`
483    /// until after the freshness check — otherwise every read would make
484    /// itself "fresh."
485    pub fn check_dedup(&self, content: &[u8]) -> Result<Option<String>> {
486        let hash = Self::sha256_hex(content);
487        // Probe existence without touching accessed_at.
488        let fresh = self.is_ref_fresh(&hash);
489        if fresh {
490            let hash_prefix = &hash[..16];
491            self.record_ref_sent(&hash);
492            Ok(Some(format!("§ref:{hash_prefix}§")))
493        } else {
494            // If the entry exists but is stale, don't return a dangling ref.
495            // If it doesn't exist at all, same result: no dedup.
496            Ok(None)
497        }
498    }
499
500    /// Store a compressed result in the persistent cache, keyed by the
501    /// SHA-256 hash of the original content.
502    ///
503    /// Also records the ref as sent at the current turn for compaction tracking.
504    /// Persists `original_content` alongside `compressed` so that
505    /// `sqz expand <prefix>` can recover the raw bytes for agents that
506    /// cannot parse `§ref:…§` dedup tokens.
507    pub fn store_compressed(
508        &self,
509        original_content: &[u8],
510        compressed: &CompressedContent,
511    ) -> Result<()> {
512        let hash = Self::sha256_hex(original_content);
513        self.store
514            .save_cache_entry_with_original(&hash, compressed, Some(original_content))?;
515        self.index_for_similarity(&hash, &compressed.data);
516        self.record_ref_sent(&hash);
517        Ok(())
518    }
519
520    /// Resolve a hex prefix (the 16-char tail of a `§ref:<prefix>§` token,
521    /// or any longer hex string pasted by a user) to the cached content.
522    ///
523    /// Designed for the `sqz expand <prefix>` CLI: the agent sees a ref
524    /// token it can't parse, runs `sqz expand a1b2c3d4e5f6g7h8`, and gets
525    /// back the raw bytes that produced the ref. This is the
526    /// user-visible escape hatch SquireNed asked for.
527    ///
528    /// Three outcomes:
529    ///
530    /// * `Ok(Some(Original))` — the original bytes were captured when
531    ///   the entry was stored (new cache entries from v0.10.0+ always
532    ///   capture the original). Write `bytes` to stdout.
533    /// * `Ok(Some(CompressedOnly))` — the entry exists but its `original`
534    ///   column is `NULL` (pre-migration data). We still return the
535    ///   compressed form — always legible, always useful — and the CLI
536    ///   surfaces a note that tells the user to re-run their original
537    ///   command with `--no-cache` to capture a truly uncompressed copy.
538    /// * `Ok(None)` — no entry matches. Usually means the ref was
539    ///   truncated from a different sqz database (a different machine,
540    ///   a wiped `~/.sqz/sessions.db`, etc.).
541    /// * `Err(_)` — prefix was ambiguous or the DB is broken. The error
542    ///   carries a user-readable message explaining what went wrong.
543    ///
544    /// Touches `accessed_at` on hit (consistent with `get_cache_entry`).
545    pub fn expand_prefix(&self, prefix: &str) -> Result<Option<ExpandResult>> {
546        let Some((hash, compressed_entry)) = self.store.get_cache_entry_by_prefix(prefix)? else {
547            return Ok(None);
548        };
549
550        // Prefer the original bytes when we have them — that's the
551        // whole point of this feature.
552        if let Some(bytes) = self.store.get_cache_entry_original(&hash)? {
553            return Ok(Some(ExpandResult::Original { hash, bytes }));
554        }
555        Ok(Some(ExpandResult::CompressedOnly {
556            hash,
557            compressed: compressed_entry.data,
558        }))
559    }
560
561    /// Invalidate the cache entry for `path` if its current content is known.
562    ///
563    /// Reads the file at `path`, computes its hash, and removes the matching
564    /// entry from the store.  If the file does not exist the call is a no-op.
565    pub fn invalidate(&self, path: &Path) -> Result<()> {
566        if !path.exists() {
567            return Ok(());
568        }
569        let bytes = std::fs::read(path)?;
570        let hash = Self::sha256_hex(&bytes);
571        self.store.delete_cache_entry(&hash)?;
572        Ok(())
573    }
574
575    /// Evict least-recently-used entries until total cache size is at or below
576    /// `max_size_bytes`.
577    ///
578    /// Returns the number of bytes freed.
579    pub fn evict_lru(&self) -> Result<u64> {
580        let entries = self.store.list_cache_entries_lru()?;
581
582        // Compute current total size.
583        let total: u64 = entries.iter().map(|(_, sz)| sz).sum();
584        if total <= self.max_size_bytes {
585            return Ok(0);
586        }
587
588        let mut freed: u64 = 0;
589        let mut remaining = total;
590
591        for (hash, size) in &entries {
592            if remaining <= self.max_size_bytes {
593                break;
594            }
595            self.store.delete_cache_entry(hash)?;
596            freed += size;
597            remaining -= size;
598        }
599
600        Ok(freed)
601    }
602}
603
604// ── Tests ─────────────────────────────────────────────────────────────────────
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use crate::preset::{
610        BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
611        CustomTransformsConfig, ModelConfig, PresetMeta, StripNullsConfig, TerseModeConfig,
612        ToolSelectionConfig, TruncateStringsConfig,
613    };
614    use crate::session_store::SessionStore;
615
616    fn in_memory_store() -> (SessionStore, tempfile::TempDir) {
617        let dir = tempfile::tempdir().unwrap();
618        let path = dir.path().join("test.db");
619        let store = SessionStore::open_or_create(&path).unwrap();
620        (store, dir)
621    }
622
623    fn test_preset() -> Preset {
624        Preset {
625            preset: PresetMeta {
626                name: "test".into(),
627                version: "1.0".into(),
628                description: String::new(),
629            },
630            compression: CompressionConfig {
631                stages: vec![],
632                keep_fields: None,
633                strip_fields: None,
634                condense: Some(CondenseConfig {
635                    enabled: true,
636                    max_repeated_lines: 3,
637                }),
638                git_diff_fold: None,
639                strip_nulls: Some(StripNullsConfig { enabled: true }),
640                flatten: None,
641                truncate_strings: Some(TruncateStringsConfig {
642                    enabled: true,
643                    max_length: 500,
644                }),
645                collapse_arrays: Some(CollapseArraysConfig {
646                    enabled: true,
647                    max_items: 5,
648                    summary_template: "... and {remaining} more items".into(),
649                }),
650                custom_transforms: Some(CustomTransformsConfig { enabled: true }),
651            },
652            tool_selection: ToolSelectionConfig {
653                max_tools: 5,
654                similarity_threshold: 0.7,
655                default_tools: vec![],
656            },
657            budget: BudgetConfig {
658                warning_threshold: 0.70,
659                ceiling_threshold: 0.85,
660                default_window_size: 200_000,
661                agents: Default::default(),
662            },
663            terse_mode: TerseModeConfig {
664                enabled: false,
665                level: crate::preset::TerseLevel::Moderate,
666            },
667            model: ModelConfig {
668                family: "anthropic".into(),
669                primary: "claude-sonnet-4-20250514".into(),
670                local: String::new(),
671                complexity_threshold: 0.4,
672                pricing: None,
673            },
674        }
675    }
676
677    fn make_pipeline() -> CompressionPipeline {
678        CompressionPipeline::new(&test_preset())
679    }
680
681    #[test]
682    fn first_read_is_miss() {
683        let (store, _dir) = in_memory_store();
684        let cm = CacheManager::new(store, u64::MAX);
685        let pipeline = make_pipeline();
686        let content = b"hello world";
687        let result = cm
688            .get_or_compress(Path::new("file.txt"), content, &pipeline)
689            .unwrap();
690        assert!(matches!(result, CacheResult::Fresh { .. }));
691    }
692
693    /// Regression for https://github.com/ojuschugh1/sqz/issues/32 — the MCP
694    /// file-read path (`get_or_compress_lossless`, used by sqz_read_file /
695    /// sqz_grep / sqz_list_dir) returns content faithfully, while the general
696    /// `compress` tool keeps using the lossy `get_or_compress`.
697    #[test]
698    fn get_or_compress_lossless_does_not_truncate_source() {
699        let pipeline = make_pipeline();
700
701        // >500 bytes of multi-segment non-JSON "source" that the lossy path
702        // entropy-truncates by roughly half.
703        let mut segs = Vec::new();
704        // 24 segments: clears the benign-aware 1000-byte threshold.
705        for i in 0..24 {
706            if i % 2 == 0 {
707                segs.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
708            } else {
709                segs.push(format!(
710                    "SEG{i} the quick brown fox jumps over {i} lazy dogs by rivers"
711                ));
712            }
713        }
714        let content = segs.join("\n\n");
715        assert!(content.len() > 1000);
716
717        // Lossy path (the general `compress` tool) still truncates.
718        let (s1, _d1) = in_memory_store();
719        let lossy = CacheManager::new(s1, u64::MAX);
720        match lossy
721            .get_or_compress(Path::new("x"), content.as_bytes(), &pipeline)
722            .unwrap()
723        {
724            CacheResult::Fresh { output } => {
725                assert!(output.data.contains("omitted"), "lossy path should truncate");
726            }
727            _ => panic!("expected Fresh on a cold read"),
728        }
729
730        // Lossless path (file tools) keeps every segment.
731        let (s2, _d2) = in_memory_store();
732        let cm = CacheManager::new(s2, u64::MAX);
733        match cm
734            .get_or_compress_lossless(Path::new("src/lib.rs"), content.as_bytes(), &pipeline)
735            .unwrap()
736        {
737            CacheResult::Fresh { output } => {
738                assert!(
739                    !output.data.contains("omitted"),
740                    "file-read path truncated content: {}",
741                    output.data
742                );
743                for i in 0..10 {
744                    assert!(
745                        output.data.contains(&format!("SEG{i}")),
746                        "file-read path dropped SEG{i}"
747                    );
748                }
749            }
750            _ => panic!("expected CacheResult::Fresh on a cold read"),
751        }
752    }
753
754    /// MinHash LSH finds a near-duplicate even after 12 unrelated entries
755    /// have pushed the original out of the recent-10 LRU fallback window.
756    #[test]
757    fn lsh_finds_near_duplicate_beyond_lru_window() {
758        let (store, _dir) = in_memory_store();
759        let cm = CacheManager::new(store, u64::MAX);
760        let pipeline = make_pipeline();
761
762        let base: String = (0..40)
763            .map(|i| format!("stable log line number {i} with steady content here\n"))
764            .collect();
765        cm.get_or_compress(Path::new("base"), base.as_bytes(), &pipeline)
766            .unwrap();
767
768        // Bury it under 12 distinct entries.
769        for n in 0..12 {
770            let filler: String = (0..30)
771                .map(|i| format!("filler doc {n} row {i} totally different subject matter\n"))
772                .collect();
773            cm.get_or_compress(Path::new("filler"), filler.as_bytes(), &pipeline)
774                .unwrap();
775        }
776
777        // One line changed: a near-duplicate of the buried base.
778        let variant = base.replace(
779            "stable log line number 7 with steady content here",
780            "stable log line number 7 with slightly altered content",
781        );
782        let result = cm
783            .get_or_compress(Path::new("variant"), variant.as_bytes(), &pipeline)
784            .unwrap();
785        let kind = match &result {
786            CacheResult::Dedup { .. } => "Dedup",
787            CacheResult::Delta { .. } => "Delta",
788            CacheResult::Fresh { .. } => "Fresh",
789        };
790        assert!(
791            matches!(result, CacheResult::Delta { .. }),
792            "near-duplicate beyond the LRU window must resolve via LSH, got {kind}"
793        );
794    }
795
796    #[test]
797    fn second_read_is_hit() {
798        let (store, _dir) = in_memory_store();
799        let cm = CacheManager::new(store, u64::MAX);
800        let pipeline = make_pipeline();
801        let content = b"hello world";
802        let path = Path::new("file.txt");
803
804        // First read — miss
805        cm.get_or_compress(path, content, &pipeline).unwrap();
806
807        // Second read — hit
808        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
809        match result {
810            CacheResult::Dedup {
811                inline_ref,
812                token_cost,
813            } => {
814                assert!(inline_ref.starts_with("§ref:"));
815                assert!(inline_ref.ends_with('§'));
816                assert_eq!(token_cost, 13);
817            }
818            CacheResult::Fresh { .. } | CacheResult::Delta { .. } => panic!("expected cache hit"),
819        }
820    }
821
822    #[test]
823    fn different_content_is_miss() {
824        let (store, _dir) = in_memory_store();
825        let cm = CacheManager::new(store, u64::MAX);
826        let pipeline = make_pipeline();
827        let path = Path::new("file.txt");
828
829        cm.get_or_compress(path, b"content v1", &pipeline).unwrap();
830        let result = cm
831            .get_or_compress(path, b"content v2", &pipeline)
832            .unwrap();
833        assert!(matches!(result, CacheResult::Fresh { .. } | CacheResult::Delta { .. }));
834    }
835
836    #[test]
837    fn evict_lru_frees_bytes_when_over_limit() {
838        let (store, _dir) = in_memory_store();
839        // Very small limit so eviction triggers immediately.
840        let cm = CacheManager::new(store, 1);
841        let pipeline = make_pipeline();
842        let path = Path::new("f.txt");
843
844        // Populate cache with a few entries.
845        cm.get_or_compress(path, b"entry one", &pipeline).unwrap();
846        cm.get_or_compress(path, b"entry two", &pipeline).unwrap();
847        cm.get_or_compress(path, b"entry three", &pipeline).unwrap();
848
849        let freed = cm.evict_lru().unwrap();
850        assert!(freed > 0, "expected bytes to be freed");
851    }
852
853    #[test]
854    fn evict_lru_no_op_when_under_limit() {
855        let (store, _dir) = in_memory_store();
856        let cm = CacheManager::new(store, u64::MAX);
857        let pipeline = make_pipeline();
858
859        cm.get_or_compress(Path::new("f.txt"), b"data", &pipeline)
860            .unwrap();
861
862        let freed = cm.evict_lru().unwrap();
863        assert_eq!(freed, 0);
864    }
865
866    #[test]
867    fn invalidate_removes_entry() {
868        let dir = tempfile::tempdir().unwrap();
869        let file_path = dir.path().join("test.txt");
870        std::fs::write(&file_path, b"some content").unwrap();
871
872        let store_path = dir.path().join("store.db");
873        let store = SessionStore::open_or_create(&store_path).unwrap();
874        let cm = CacheManager::new(store, u64::MAX);
875        let pipeline = make_pipeline();
876
877        // Populate cache.
878        let content = std::fs::read(&file_path).unwrap();
879        cm.get_or_compress(&file_path, &content, &pipeline).unwrap();
880
881        // Verify it's a hit.
882        let hit = cm
883            .get_or_compress(&file_path, &content, &pipeline)
884            .unwrap();
885        assert!(matches!(hit, CacheResult::Dedup { .. }));
886
887        cm.invalidate(&file_path).unwrap();
888
889        let miss = cm
890            .get_or_compress(&file_path, &content, &pipeline)
891            .unwrap();
892        assert!(matches!(miss, CacheResult::Fresh { .. }));
893    }
894
895    #[test]
896    fn invalidate_nonexistent_path_is_noop() {
897        let (store, _dir) = in_memory_store();
898        let cm = CacheManager::new(store, u64::MAX);
899        // Should not error.
900        cm.invalidate(Path::new("/nonexistent/path/file.txt"))
901            .unwrap();
902    }
903
904    // ── Compaction / freshness tests ──────────────────────────────────────
905    //
906    // These tests used to exercise an in-memory turn counter. Freshness is
907    // now computed from SQLite `accessed_at` timestamps so dedup works
908    // across the shell-hook model (each hook invocation is a fresh
909    // process). The tests below use wall-clock durations instead.
910
911    #[test]
912    fn stale_ref_returns_fresh_instead_of_dedup() {
913        let (store, _dir) = in_memory_store();
914        // Set max_ref_age to 0 — every ref goes stale immediately.
915        let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::ZERO);
916        let pipeline = make_pipeline();
917        let content = b"hello world";
918        let path = Path::new("file.txt");
919
920        // First read — miss. accessed_at recorded.
921        cm.get_or_compress(path, content, &pipeline).unwrap();
922
923        // Second read — with TTL=0 the ref is already stale, should re-send.
924        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
925        assert!(
926            matches!(result, CacheResult::Fresh { .. }),
927            "stale ref (TTL=0) should return Fresh, not Dedup"
928        );
929    }
930
931    #[test]
932    fn fresh_ref_returns_dedup() {
933        let (store, _dir) = in_memory_store();
934        // Generous TTL: one day. Refs stay fresh for the life of the test.
935        let cm = CacheManager::with_ref_age_duration(
936            store,
937            u64::MAX,
938            Duration::from_secs(86_400),
939        );
940        let pipeline = make_pipeline();
941        let content = b"hello world";
942        let path = Path::new("file.txt");
943
944        cm.get_or_compress(path, content, &pipeline).unwrap();
945        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
946        assert!(
947            matches!(result, CacheResult::Dedup { .. }),
948            "fresh ref should dedup"
949        );
950    }
951
952    #[test]
953    fn notify_compaction_invalidates_all_refs() {
954        let (store, _dir) = in_memory_store();
955        let cm = CacheManager::with_ref_age_duration(
956            store,
957            u64::MAX,
958            Duration::from_secs(86_400),
959        );
960        let pipeline = make_pipeline();
961        let path = Path::new("file.txt");
962
963        // Populate cache — every subsequent read is a dedup hit.
964        cm.get_or_compress(path, b"content A", &pipeline).unwrap();
965        cm.get_or_compress(path, b"content B", &pipeline).unwrap();
966        assert!(matches!(
967            cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
968            CacheResult::Dedup { .. }
969        ));
970        assert!(matches!(
971            cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
972            CacheResult::Dedup { .. }
973        ));
974
975        // Simulate a context compaction. The compaction marker is set to
976        // `now`; any cache entry whose accessed_at predates this moment is
977        // treated as stale even though the TTL hasn't expired.
978        // Sleep 10ms to ensure `now` is strictly after the last touch.
979        std::thread::sleep(std::time::Duration::from_millis(10));
980        cm.notify_compaction();
981
982        // After compaction, refs predate the marker — re-send full content.
983        assert!(matches!(
984            cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
985            CacheResult::Fresh { .. }
986        ));
987        assert!(matches!(
988            cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
989            CacheResult::Fresh { .. }
990        ));
991    }
992
993    #[test]
994    fn ref_refreshed_after_resend() {
995        let (store, _dir) = in_memory_store();
996        // Generous TTL so the "fresh after re-send" check is never a timing
997        // race on a slow CI runner; staleness is forced via compaction below.
998        let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
999        let pipeline = make_pipeline();
1000        let content = b"hello world";
1001        let path = Path::new("file.txt");
1002
1003        cm.get_or_compress(path, content, &pipeline).unwrap();
1004        // Force staleness deterministically: set the compaction marker strictly
1005        // after the seed's accessed_at, so the ref predates it (no TTL race).
1006        std::thread::sleep(std::time::Duration::from_millis(10));
1007        cm.notify_compaction();
1008
1009        // Stale (predates the marker) — must re-send Fresh. The re-send bumps
1010        // accessed_at past the marker.
1011        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1012        assert!(matches!(result, CacheResult::Fresh { .. }));
1013
1014        // Now fresh (accessed_at after the marker, within the generous TTL).
1015        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1016        assert!(
1017            matches!(result, CacheResult::Dedup { .. }),
1018            "ref should be fresh after re-send"
1019        );
1020    }
1021
1022    #[test]
1023    fn check_dedup_returns_none_for_stale_ref() {
1024        let pipeline = make_pipeline();
1025        let content = b"test content";
1026        let path = Path::new("file.txt");
1027
1028        // Fresh ref (generous TTL): check_dedup returns Some.
1029        let (store, _dir) = in_memory_store();
1030        let fresh =
1031            CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1032        fresh.get_or_compress(path, content, &pipeline).unwrap();
1033        assert!(fresh.check_dedup(content).unwrap().is_some());
1034
1035        // Stale ref (TTL=0): check_dedup returns None — deterministic, no sleep.
1036        let (store2, _dir2) = in_memory_store();
1037        let stale = CacheManager::with_ref_age_duration(store2, u64::MAX, Duration::ZERO);
1038        stale.get_or_compress(path, content, &pipeline).unwrap();
1039        assert!(
1040            stale.check_dedup(content).unwrap().is_none(),
1041            "stale ref should not be returned by check_dedup"
1042        );
1043    }
1044
1045    #[test]
1046    fn pinned_ref_stays_fresh_past_ttl() {
1047        let (store, _dir) = in_memory_store();
1048        let cm = CacheManager::with_ref_age_duration(
1049            store,
1050            u64::MAX,
1051            Duration::from_millis(10),
1052        );
1053        let pipeline = make_pipeline();
1054        let content = b"wiki: stable knowledge base content";
1055        let path = Path::new("wiki.md");
1056
1057        // Seed the cache, then pin.
1058        cm.get_or_compress(path, content, &pipeline).unwrap();
1059        let hash = CacheManager::sha256_hex(content);
1060        assert!(cm.store.pin_cache_entry(&hash).unwrap());
1061
1062        // Wait well past the 10ms TTL.
1063        std::thread::sleep(std::time::Duration::from_millis(40));
1064
1065        // Pinned: still dedup, even though accessed_at is way past TTL.
1066        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1067        assert!(
1068            matches!(result, CacheResult::Dedup { .. }),
1069            "pinned entry should stay fresh past TTL"
1070        );
1071        assert!(cm.check_dedup(content).unwrap().is_some());
1072    }
1073
1074    #[test]
1075    fn unpin_returns_to_ttl_behaviour() {
1076        let (store, _dir) = in_memory_store();
1077        let cm = CacheManager::with_ref_age_duration(
1078            store,
1079            u64::MAX,
1080            Duration::from_millis(10),
1081        );
1082        let pipeline = make_pipeline();
1083        let content = b"transient session content";
1084        let path = Path::new("file.txt");
1085
1086        cm.get_or_compress(path, content, &pipeline).unwrap();
1087        let hash = CacheManager::sha256_hex(content);
1088        cm.store.pin_cache_entry(&hash).unwrap();
1089
1090        // Pin keeps it fresh past TTL.
1091        std::thread::sleep(std::time::Duration::from_millis(25));
1092        assert!(matches!(
1093            cm.get_or_compress(path, content, &pipeline).unwrap(),
1094            CacheResult::Dedup { .. }
1095        ));
1096
1097        // Unpin and let TTL expire again.
1098        cm.store.unpin_cache_entry(&hash).unwrap();
1099        std::thread::sleep(std::time::Duration::from_millis(25));
1100        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1101        assert!(
1102            matches!(result, CacheResult::Fresh { .. }),
1103            "unpinned entry should obey TTL"
1104        );
1105    }
1106
1107    #[test]
1108    fn pinned_ref_survives_compaction_marker() {
1109        let (store, _dir) = in_memory_store();
1110        let cm = CacheManager::with_ref_age_duration(
1111            store,
1112            u64::MAX,
1113            Duration::from_secs(3600),
1114        );
1115        let pipeline = make_pipeline();
1116        let content = b"system prompt: shared across agents";
1117        let path = Path::new("system.md");
1118
1119        cm.get_or_compress(path, content, &pipeline).unwrap();
1120        let hash = CacheManager::sha256_hex(content);
1121        cm.store.pin_cache_entry(&hash).unwrap();
1122
1123        // Compaction would normally invalidate every pre-marker entry.
1124        std::thread::sleep(std::time::Duration::from_millis(10));
1125        cm.notify_compaction();
1126
1127        // Pinned entry survives — wiki content is persistent across
1128        // compactions because it's re-injected into every agent.
1129        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1130        assert!(
1131            matches!(result, CacheResult::Dedup { .. }),
1132            "pinned entry should survive compaction"
1133        );
1134    }
1135
1136    #[test]
1137    fn pin_visible_across_cachemanager_instances() {
1138        // Real-world scenario: agent A pins wiki content; agent B in a
1139        // separate process queries the same SQLite store and gets the
1140        // dedup hit because the pin lives in the shared store, not in
1141        // any single CacheManager's memory.
1142        let dir = tempfile::tempdir().unwrap();
1143        let path = dir.path().join("shared.db");
1144
1145        let store_a = SessionStore::open_or_create(&path).unwrap();
1146        let cm_a = CacheManager::with_ref_age_duration(
1147            store_a,
1148            u64::MAX,
1149            Duration::from_millis(5),
1150        );
1151        let pipeline = make_pipeline();
1152        let content = b"shared wiki content for multi-agent dedup";
1153
1154        cm_a.get_or_compress(Path::new("wiki.md"), content, &pipeline)
1155            .unwrap();
1156        let hash = CacheManager::sha256_hex(content);
1157        cm_a.store.pin_cache_entry(&hash).unwrap();
1158
1159        // Drop agent A. Wait past TTL. Spin up agent B against the same DB.
1160        drop(cm_a);
1161        std::thread::sleep(std::time::Duration::from_millis(20));
1162
1163        let store_b = SessionStore::open_or_create(&path).unwrap();
1164        let cm_b = CacheManager::with_ref_age_duration(
1165            store_b,
1166            u64::MAX,
1167            Duration::from_millis(5),
1168        );
1169        let result = cm_b
1170            .get_or_compress(Path::new("wiki.md"), content, &pipeline)
1171            .unwrap();
1172        assert!(
1173            matches!(result, CacheResult::Dedup { .. }),
1174            "second agent should see the pin from the shared store"
1175        );
1176    }
1177
1178    #[test]
1179    fn advance_turn_increments_counter() {
1180        // The counter is retained for context_evictor compatibility.
1181        let (store, _dir) = in_memory_store();
1182        let cm = CacheManager::new(store, u64::MAX);
1183        assert_eq!(cm.current_turn(), 0);
1184        cm.advance_turn();
1185        assert_eq!(cm.current_turn(), 1);
1186        cm.advance_turn();
1187        assert_eq!(cm.current_turn(), 2);
1188    }
1189
1190    // ── Expand feature tests ────────────────────────────────────────────
1191
1192    #[test]
1193    fn expand_returns_original_bytes_for_new_entry() {
1194        // New cache entries (written after the `original` column migration)
1195        // must always round-trip back to the exact bytes the agent sent.
1196        // This is the core guarantee `sqz expand` exists to provide.
1197        let (store, _dir) = in_memory_store();
1198        let cm = CacheManager::new(store, u64::MAX);
1199        let pipeline = make_pipeline();
1200        let path = Path::new("x.txt");
1201        let content = b"hello\nworld\nthis is the original\n";
1202
1203        // Seed the cache by running the content through get_or_compress.
1204        let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1205        let CacheResult::Fresh { .. } = first else {
1206            panic!("first read should be a Fresh miss, got something else");
1207        };
1208
1209        // Find the ref prefix by doing a second read (which dedups).
1210        let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1211        let inline_ref = match second {
1212            CacheResult::Dedup { inline_ref, .. } => inline_ref,
1213            _ => panic!("second read should dedup"),
1214        };
1215
1216        // Strip the `§ref:` / `§` wrappers to get the raw prefix.
1217        let prefix = inline_ref
1218            .strip_prefix("§ref:")
1219            .and_then(|s| s.strip_suffix('§'))
1220            .expect("unexpected ref format");
1221
1222        let result = cm.expand_prefix(prefix).unwrap().expect("expand hit expected");
1223        match result {
1224            ExpandResult::Original { bytes, hash } => {
1225                assert_eq!(bytes, content, "expand must return exact original bytes");
1226                assert!(
1227                    hash.starts_with(prefix),
1228                    "returned full hash {hash} must start with prefix {prefix}"
1229                );
1230                assert_eq!(hash.len(), 64, "full SHA-256 must be 64 hex chars");
1231            }
1232            ExpandResult::CompressedOnly { .. } => {
1233                panic!("new cache entries must carry the original bytes");
1234            }
1235        }
1236    }
1237
1238    #[test]
1239    fn expand_prefix_rejects_nonhex_input() {
1240        // Agents paste all sorts of things — ref tokens with the §'s still
1241        // attached, mixed-case hashes, stray whitespace. The store layer
1242        // normalises most of this but we also want to reject obvious
1243        // garbage without touching SQLite.
1244        let (store, _dir) = in_memory_store();
1245        let cm = CacheManager::new(store, u64::MAX);
1246        assert!(cm.expand_prefix("").unwrap().is_none(), "empty prefix is no-match");
1247        assert!(cm.expand_prefix("not a hex").unwrap().is_none());
1248        assert!(cm.expand_prefix("ABCDEF").unwrap().is_none(), "uppercase hex should not match (sqz emits lowercase)");
1249        assert!(cm.expand_prefix("g0g0g0").unwrap().is_none());
1250    }
1251
1252    #[test]
1253    fn expand_prefix_returns_none_for_unknown_prefix() {
1254        // Very common in practice: agent pastes a ref from a different
1255        // machine, a different sqz install, or after a cache wipe.
1256        let (store, _dir) = in_memory_store();
1257        let cm = CacheManager::new(store, u64::MAX);
1258        let pipeline = make_pipeline();
1259        // Seed one entry.
1260        let _ = cm
1261            .get_or_compress(
1262                Path::new("y.txt"),
1263                b"some cached content that won't match the prefix below",
1264                &pipeline,
1265            )
1266            .unwrap();
1267        assert!(
1268            cm.expand_prefix("0000000000000000").unwrap().is_none(),
1269            "prefix that matches nothing must return None, not error"
1270        );
1271    }
1272
1273    #[test]
1274    fn expand_prefix_errors_on_ambiguous_match() {
1275        // Two entries whose hashes both start with the same hex prefix
1276        // must be detected and reported. The caller is expected to
1277        // surface "use a longer prefix" — we don't want to silently
1278        // pick one.
1279        let (store, _dir) = in_memory_store();
1280        // Hand-craft two entries with prefixes that collide on "ab".
1281        let compressed = crate::types::CompressedContent {
1282            data: "compressed-a".to_string(),
1283            tokens_compressed: 1,
1284            tokens_original: 10,
1285            stages_applied: vec![],
1286            compression_ratio: 0.1,
1287            provenance: Default::default(),
1288            verify: None,
1289        };
1290        store
1291            .save_cache_entry_with_original(
1292                &format!("ab{}", "0".repeat(62)),
1293                &compressed,
1294                Some(b"content a"),
1295            )
1296            .unwrap();
1297        store
1298            .save_cache_entry_with_original(
1299                &format!("ab{}", "1".repeat(62)),
1300                &compressed,
1301                Some(b"content b"),
1302            )
1303            .unwrap();
1304
1305        let cm = CacheManager::new(store, u64::MAX);
1306        // Unambiguous 3-char prefixes resolve:
1307        let ok_a = cm.expand_prefix(&format!("ab{}", "0")).unwrap().unwrap();
1308        assert!(matches!(ok_a, ExpandResult::Original { .. }));
1309        // Ambiguous 2-char prefix errors out.
1310        let err = cm.expand_prefix("ab").unwrap_err();
1311        let msg = err.to_string();
1312        assert!(
1313            msg.contains("multiple entries") || msg.contains("longer prefix"),
1314            "ambiguity error should mention multiple matches / longer prefix, got: {msg}"
1315        );
1316    }
1317
1318    #[test]
1319    fn expand_returns_compressed_only_for_pre_migration_entry() {
1320        // Entries written via `save_cache_entry` (without the `_with_original`
1321        // suffix) leave the `original` column NULL. Expand must still return
1322        // something useful — the compressed blob — with a note (handled by
1323        // the CLI). Here we assert the engine returns the right variant.
1324        let (store, _dir) = in_memory_store();
1325        let compressed = crate::types::CompressedContent {
1326            data: "the compressed version".to_string(),
1327            tokens_compressed: 4,
1328            tokens_original: 100,
1329            stages_applied: vec!["condense".to_string()],
1330            compression_ratio: 0.04,
1331            provenance: Default::default(),
1332            verify: None,
1333        };
1334        // Deliberately use the legacy save_cache_entry (no original).
1335        let hash = format!("deadbeef{}", "0".repeat(56));
1336        store.save_cache_entry(&hash, &compressed).unwrap();
1337
1338        let cm = CacheManager::new(store, u64::MAX);
1339        let result = cm
1340            .expand_prefix("deadbeef")
1341            .unwrap()
1342            .expect("deadbeef prefix should match");
1343        match result {
1344            ExpandResult::CompressedOnly { compressed, hash: h } => {
1345                assert_eq!(compressed, "the compressed version");
1346                assert_eq!(h, hash);
1347            }
1348            ExpandResult::Original { .. } => {
1349                panic!("legacy entry without original bytes should return CompressedOnly");
1350            }
1351        }
1352    }
1353
1354    #[test]
1355    fn expand_preserves_non_utf8_original_bytes() {
1356        // Shell output routinely contains non-UTF-8 sequences (terminal
1357        // control codes, binary blobs from `cat` on a wrong file, etc.).
1358        // Expand must round-trip byte-for-byte — not through a UTF-8
1359        // lossy conversion — or agents auditing the cache will see
1360        // spurious `\uFFFD` REPLACEMENT CHARACTER bytes.
1361        let (store, _dir) = in_memory_store();
1362        let cm = CacheManager::new(store, u64::MAX);
1363        let pipeline = make_pipeline();
1364
1365        // Mix of valid UTF-8 and isolated 0xFF bytes.
1366        let content: Vec<u8> = vec![
1367            b'h', b'e', b'l', b'l', b'o', b'\n',
1368            0xFF, 0xFE, 0xFD,
1369            b'\n',
1370        ];
1371        let path = Path::new("bin.dat");
1372        let _ = cm.get_or_compress(path, &content, &pipeline).unwrap();
1373        // Round-trip via expand_prefix.
1374        let hash = CacheManager::sha256_hex(&content);
1375        let result = cm.expand_prefix(&hash[..16]).unwrap().unwrap();
1376        match result {
1377            ExpandResult::Original { bytes, .. } => {
1378                assert_eq!(bytes, content, "non-UTF-8 bytes must round-trip exactly");
1379            }
1380            ExpandResult::CompressedOnly { .. } => {
1381                panic!("fresh entry should have original bytes");
1382            }
1383        }
1384    }
1385
1386    #[test]
1387    fn dedup_survives_cache_manager_restart() {
1388        // Regression for the April 18 bug: the turn counter was in-memory
1389        // only, so every new sqz process saw an empty ref tracker and the
1390        // dedup feature silently produced Fresh results forever. With
1391        // accessed_at-based freshness, a fresh CacheManager reading the
1392        // same SQLite store picks up the dedup correctly.
1393        let dir = tempfile::tempdir().unwrap();
1394        let db_path = dir.path().join("cache.db");
1395        let pipeline = make_pipeline();
1396        let content = b"a substantial chunk of content to dedup";
1397        let path = Path::new("x.txt");
1398
1399        // First "process": populate cache.
1400        {
1401            let store = SessionStore::open_or_create(&db_path).unwrap();
1402            let cm = CacheManager::with_ref_age_duration(
1403                store,
1404                u64::MAX,
1405                Duration::from_secs(3600),
1406            );
1407            let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1408            assert!(matches!(first, CacheResult::Fresh { .. }));
1409        }
1410
1411        // Second "process": new CacheManager, same DB. Dedup must fire.
1412        {
1413            let store = SessionStore::open_or_create(&db_path).unwrap();
1414            let cm = CacheManager::with_ref_age_duration(
1415                store,
1416                u64::MAX,
1417                Duration::from_secs(3600),
1418            );
1419            let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1420            assert!(
1421                matches!(second, CacheResult::Dedup { .. }),
1422                "second-process read must dedup — this was broken before the April 18 fix"
1423            );
1424        }
1425    }
1426
1427    #[test]
1428    fn compaction_from_one_process_invalidates_refs_in_another() {
1429        // Regression for the PreCompact hook wiring: the host harness
1430        // (e.g. Claude Code) runs `sqz hook precompact` in a short-lived
1431        // process to signal auto-compaction. The actual dedup serving runs
1432        // in a DIFFERENT sqz process (the shell hook). notify_compaction
1433        // must persist through SQLite so the second process sees it.
1434        //
1435        // Before the fix, compaction_marker was Cell<Option<DateTime>>
1436        // in memory only — the precompact process set it, exited, the
1437        // state was lost. Next shell-hook process started with a clean
1438        // marker, served stale refs to the agent, and the agent saw a
1439        // §ref:HASH§ pointing at content no longer in its context.
1440        let dir = tempfile::tempdir().unwrap();
1441        let db_path = dir.path().join("cache.db");
1442        let pipeline = make_pipeline();
1443        let content = b"content that needs stale-marking after compaction";
1444        let path = Path::new("file.txt");
1445        let ttl = Duration::from_secs(3600);
1446
1447        // Process A: populate the cache so the content is dedup-eligible.
1448        {
1449            let store = SessionStore::open_or_create(&db_path).unwrap();
1450            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1451            cm.get_or_compress(path, content, &pipeline).unwrap();
1452        }
1453        // Sleep so the compaction marker is strictly after the touch.
1454        std::thread::sleep(Duration::from_millis(10));
1455
1456        // Process B: simulates `sqz hook precompact`. Just calls
1457        // notify_compaction and exits. No reads.
1458        {
1459            let store = SessionStore::open_or_create(&db_path).unwrap();
1460            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1461            cm.notify_compaction();
1462        }
1463
1464        // Process C: simulates the next `sqz compress` shell-hook call.
1465        // Reads the same content. MUST re-send Fresh, not return a ref
1466        // the agent can no longer resolve.
1467        {
1468            let store = SessionStore::open_or_create(&db_path).unwrap();
1469            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1470            let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1471            assert!(
1472                matches!(result, CacheResult::Fresh { .. }),
1473                "post-compaction read from a fresh process must re-send Fresh; \
1474                 returning Dedup would be a dangling-ref bug"
1475            );
1476        }
1477    }
1478
1479    use proptest::prelude::*;
1480
1481    // ── Property 8: Cache deduplication ──────────────────────────────────────
1482    // **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
1483    //
1484    // For any file content, reading the file twice through the CacheManager
1485    // (with no content change between reads) SHALL return a cache hit on the
1486    // second read with a reference token of approximately 13 tokens.
1487
1488    proptest! {
1489        /// **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
1490        ///
1491        /// For any file content, the second read through CacheManager SHALL be
1492        /// a cache hit with tokens == 13.
1493        #[test]
1494        fn prop_cache_deduplication(
1495            content in proptest::collection::vec(any::<u8>(), 1..=1000usize),
1496        ) {
1497            let (store, _dir) = in_memory_store();
1498            let cm = CacheManager::new(store, u64::MAX);
1499            let pipeline = make_pipeline();
1500            let path = Path::new("file.txt");
1501
1502            // First read — must be a miss.
1503            let first = cm.get_or_compress(path, &content, &pipeline).unwrap();
1504            prop_assert!(
1505                matches!(first, CacheResult::Fresh { .. }),
1506                "first read should be a cache miss"
1507            );
1508
1509            let second = cm.get_or_compress(path, &content, &pipeline).unwrap();
1510            match second {
1511                CacheResult::Dedup { inline_ref, token_cost } => {
1512                    prop_assert_eq!(
1513                        token_cost, 13,
1514                        "cache hit should report ~13 reference tokens"
1515                    );
1516                    prop_assert!(
1517                        inline_ref.starts_with("§ref:"),
1518                        "reference token should start with §ref:"
1519                    );
1520                    prop_assert!(
1521                        inline_ref.ends_with('§'),
1522                        "reference token should end with §"
1523                    );
1524                }
1525                CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1526                    prop_assert!(false, "second read should be a cache hit, not a miss");
1527                }
1528            }
1529        }
1530    }
1531
1532    // ── Property 9: Cache invalidation on content change ─────────────────────
1533    // **Validates: Requirements 8.3, 18.3**
1534    //
1535    // For any cached file, if the file content changes (producing a different
1536    // SHA-256 hash), the CacheManager SHALL treat the next read as a cache miss
1537    // and re-compress the updated content.
1538
1539    proptest! {
1540        /// **Validates: Requirements 8.3, 18.3**
1541        ///
1542        /// For any two distinct byte sequences, the first read of each is a
1543        /// cache miss — content change always triggers re-compression.
1544        #[test]
1545        fn prop_cache_invalidation_on_content_change(
1546            content_a in proptest::collection::vec(any::<u8>(), 1..=500usize),
1547            content_b in proptest::collection::vec(any::<u8>(), 1..=500usize),
1548        ) {
1549            // Only meaningful when the two contents differ (different hashes).
1550            prop_assume!(content_a != content_b);
1551
1552            let (store, _dir) = in_memory_store();
1553            let cm = CacheManager::new(store, u64::MAX);
1554            let pipeline = make_pipeline();
1555            let path = Path::new("file.txt");
1556
1557            // Cache content_a.
1558            let r1 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1559            prop_assert!(
1560                matches!(r1, CacheResult::Fresh { .. }),
1561                "first read of content_a should be a miss"
1562            );
1563
1564            let r2 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1565            prop_assert!(
1566                matches!(r2, CacheResult::Dedup { .. }),
1567                "second read of content_a should be a hit"
1568            );
1569
1570            let r3 = cm.get_or_compress(path, &content_b, &pipeline).unwrap();
1571            prop_assert!(
1572                matches!(r3, CacheResult::Fresh { .. } | CacheResult::Delta { .. }),
1573                "read with changed content should be a cache miss or delta"
1574            );
1575        }
1576    }
1577
1578    // ── Property 10: Cache LRU eviction ──────────────────────────────────────
1579    // **Validates: Requirements 8.5**
1580    //
1581    // For any cache state where total size exceeds the configured maximum, the
1582    // CacheManager SHALL evict entries in LRU order until total size is at or
1583    // below the limit.
1584
1585    proptest! {
1586        /// **Validates: Requirements 8.5**
1587        ///
1588        /// After evict_lru, the total remaining cache size SHALL be at or below
1589        /// max_size_bytes.
1590        #[test]
1591        fn prop_cache_lru_eviction(
1592            // Generate 2-8 distinct content entries.
1593            entries in proptest::collection::vec(
1594                proptest::collection::vec(any::<u8>(), 10..=200usize),
1595                2..=8usize,
1596            ),
1597        ) {
1598            // Deduplicate entries so each has a unique hash.
1599            let mut unique_entries: Vec<Vec<u8>> = Vec::new();
1600            for e in &entries {
1601                if !unique_entries.contains(e) {
1602                    unique_entries.push(e.clone());
1603                }
1604            }
1605            prop_assume!(unique_entries.len() >= 2);
1606
1607            let (store, _dir) = in_memory_store();
1608            // Use a very small limit (1 byte) to guarantee eviction is needed.
1609            let cm = CacheManager::new(store, 1);
1610            let pipeline = make_pipeline();
1611            let path = Path::new("f.txt");
1612
1613            // Populate the cache.
1614            for entry in &unique_entries {
1615                cm.get_or_compress(path, entry, &pipeline).unwrap();
1616            }
1617
1618            // Evict LRU entries.
1619            let freed = cm.evict_lru().unwrap();
1620
1621            // Bytes freed must be > 0 since total > 1 byte.
1622            prop_assert!(freed > 0, "evict_lru should free bytes when over limit");
1623
1624            // After eviction, total remaining size must be <= max_size_bytes (1).
1625            // We verify by checking that evict_lru now returns 0 (nothing left to evict).
1626            let freed_again = cm.evict_lru().unwrap();
1627            prop_assert_eq!(
1628                freed_again, 0,
1629                "second evict_lru call should free 0 bytes (already at or below limit)"
1630            );
1631        }
1632    }
1633
1634    // ── Property 34: Cache persistence across sessions ────────────────────────
1635    // **Validates: Requirements 18.4**
1636    //
1637    // For any set of cache entries saved to the SessionStore, reloading the
1638    // store (opening the same database file) SHALL produce the same cache
1639    // entries, and a subsequent read with the same content hash SHALL return a
1640    // cache hit.
1641
1642    proptest! {
1643        /// **Validates: Requirements 18.4**
1644        ///
1645        /// Cache entries written in one CacheManager instance SHALL survive
1646        /// a store close/reopen. With the wall-clock freshness model
1647        /// (introduced April 18 2026), a subsequent CacheManager reading
1648        /// the same database SHALL see the entry as fresh (within TTL) and
1649        /// return a Dedup hit on the very first read — this is the whole
1650        /// point of the cross-process fix. Previous behavior (Fresh on
1651        /// first read after restart) was a bug that silently disabled the
1652        /// dedup feature in production.
1653        #[test]
1654        fn prop_cache_persistence_across_sessions(
1655            content in proptest::collection::vec(any::<u8>(), 1..=500usize),
1656        ) {
1657            use crate::session_store::SessionStore;
1658
1659            let dir = tempfile::tempdir().unwrap();
1660            let db_path = dir.path().join("cache.db");
1661            let path = Path::new("file.txt");
1662
1663            // Session 1: populate the cache.
1664            {
1665                let store = SessionStore::open_or_create(&db_path).unwrap();
1666                // Explicit long TTL so tests don't race with wall-clock drift.
1667                let cm = CacheManager::with_ref_age_duration(
1668                    store,
1669                    u64::MAX,
1670                    Duration::from_secs(3600),
1671                );
1672                let pipeline = make_pipeline();
1673
1674                let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1675                prop_assert!(
1676                    matches!(r, CacheResult::Fresh { .. }),
1677                    "first-ever read should be a miss"
1678                );
1679            }
1680
1681            // Session 2: reopen the same database file.
1682            {
1683                let store = SessionStore::open_or_create(&db_path).unwrap();
1684                let cm = CacheManager::with_ref_age_duration(
1685                    store,
1686                    u64::MAX,
1687                    Duration::from_secs(3600),
1688                );
1689                let pipeline = make_pipeline();
1690
1691                // First read in the new session MUST dedup. The entry was
1692                // just written (within TTL), so the wall-clock freshness
1693                // check finds it fresh. This is what makes sqz's dedup
1694                // actually work across shell-hook invocations.
1695                let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1696                match r {
1697                    CacheResult::Dedup { token_cost, .. } => {
1698                        prop_assert_eq!(
1699                            token_cost, 13,
1700                            "first read after restart must be a 13-token dedup ref"
1701                        );
1702                    }
1703                    CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1704                        prop_assert!(
1705                            false,
1706                            "first read after restart must dedup — this was the \
1707                             April 18 bug and its fix is the whole reason this \
1708                             test exists"
1709                        );
1710                    }
1711                }
1712            }
1713        }
1714    }
1715}