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