Skip to main content

lean_ctx/core/
cache.rs

1use md5::{Digest, Md5};
2use std::collections::HashMap;
3use std::sync::OnceLock;
4use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
5use std::time::{Duration, Instant, SystemTime};
6
7use super::tokens::count_tokens;
8
9/// Process-global monotonic base for encoding `Instant`s into an `AtomicU64`.
10/// Stored as milliseconds since this base, which is sufficient resolution for
11/// LRU/RRF eviction recency while allowing lock-free access on cache hits.
12fn instant_base() -> Instant {
13    static BASE: OnceLock<Instant> = OnceLock::new();
14    *BASE.get_or_init(Instant::now)
15}
16
17fn encode_instant(i: Instant) -> u64 {
18    i.saturating_duration_since(instant_base()).as_millis() as u64
19}
20
21fn decode_instant(ms: u64) -> Instant {
22    instant_base() + Duration::from_millis(ms)
23}
24
25fn normalize_key(path: &str) -> String {
26    crate::core::pathutil::normalize_tool_path(path)
27}
28
29/// Built-in default token budget for the in-memory read cache.
30pub(crate) const DEFAULT_CACHE_MAX_TOKENS: usize = 500_000;
31
32/// Pure resolver for the read-cache token budget. `env` (the raw
33/// `LEAN_CTX_CACHE_MAX_TOKENS` value) wins when it parses to a positive integer,
34/// then the `configured` `[core] cache_max_tokens`, else
35/// [`DEFAULT_CACHE_MAX_TOKENS`]. A `0` (or unparseable env) in either source
36/// means "use the default". Split out so the precedence is unit-testable without
37/// touching the global env or config.
38fn resolve_cache_max_tokens(env: Option<&str>, configured: usize) -> usize {
39    if let Some(raw) = env
40        && let Ok(n) = raw.trim().parse::<usize>()
41        && n > 0
42    {
43        return n;
44    }
45    if configured > 0 {
46        configured
47    } else {
48        DEFAULT_CACHE_MAX_TOKENS
49    }
50}
51
52/// Resolved token budget for the read cache. `LEAN_CTX_CACHE_MAX_TOKENS` wins
53/// (env-first keeps the hot eviction path cheap for power users), then
54/// `[core] cache_max_tokens` in config.toml, else [`DEFAULT_CACHE_MAX_TOKENS`].
55/// Shared with `eviction_orchestrator` so both eviction rails read one budget.
56pub(crate) fn max_cache_tokens() -> usize {
57    resolve_cache_max_tokens(
58        std::env::var("LEAN_CTX_CACHE_MAX_TOKENS").ok().as_deref(),
59        crate::core::config::Config::load().cache_max_tokens,
60    )
61}
62
63/// A cached file read: zstd-compressed content, hash, token count, and access metadata.
64///
65/// `read_count` and `last_access` use interior mutability (atomics) so cache
66/// hits can be recorded under a shared (read) lock — parallel reads of distinct
67/// files no longer serialize on a global write lock.
68#[derive(Debug)]
69pub struct CacheEntry {
70    compressed_content: Vec<u8>,
71    pub hash: String,
72    pub line_count: usize,
73    pub original_tokens: usize,
74    read_count: AtomicU32,
75    pub path: String,
76    last_access: AtomicU64,
77    pub stored_mtime: Option<SystemTime>,
78    /// Mode-specific compressed outputs (e.g. "map", "signatures") cached to avoid re-parsing.
79    pub compressed_outputs: HashMap<String, String>,
80    /// Whether full (uncompressed) content was already delivered for this hash.
81    /// Prevents cache-stub loops when upgrading from compressed to full mode.
82    pub full_content_delivered: bool,
83    /// Last read mode used for this file (for auto-escalation on edit failure).
84    pub last_mode: String,
85}
86
87const ZSTD_LEVEL: i32 = 3;
88
89fn zstd_compress(data: &str) -> Vec<u8> {
90    zstd::encode_all(data.as_bytes(), ZSTD_LEVEL).unwrap_or_else(|_| data.as_bytes().to_vec())
91}
92
93fn zstd_decompress(data: &[u8]) -> Option<String> {
94    zstd::decode_all(data)
95        .ok()
96        .and_then(|v| String::from_utf8(v).ok())
97}
98
99impl CacheEntry {
100    /// Creates a new entry with zstd-compressed content.
101    pub fn new(
102        content: &str,
103        hash: String,
104        line_count: usize,
105        original_tokens: usize,
106        path: String,
107        stored_mtime: Option<SystemTime>,
108    ) -> Self {
109        let compressed_content = zstd_compress(content);
110        Self {
111            compressed_content,
112            hash,
113            line_count,
114            original_tokens,
115            read_count: AtomicU32::new(1),
116            path,
117            last_access: AtomicU64::new(encode_instant(Instant::now())),
118            stored_mtime,
119            compressed_outputs: HashMap::new(),
120            full_content_delivered: false,
121            last_mode: String::new(),
122        }
123    }
124
125    /// Current read count (lock-free).
126    pub fn read_count(&self) -> u32 {
127        self.read_count.load(Ordering::Relaxed)
128    }
129
130    /// Atomically increments the read count and returns the new value (lock-free).
131    pub fn bump_read_count(&self) -> u32 {
132        self.read_count.fetch_add(1, Ordering::Relaxed) + 1
133    }
134
135    /// Overwrites the read count (used by `store` and tests).
136    pub fn set_read_count(&self, n: u32) {
137        self.read_count.store(n, Ordering::Relaxed);
138    }
139
140    /// Last access time, decoded from the atomic millisecond offset.
141    pub fn last_access(&self) -> Instant {
142        decode_instant(self.last_access.load(Ordering::Relaxed))
143    }
144
145    /// Marks the entry as accessed now (lock-free).
146    pub fn touch(&self) {
147        self.last_access
148            .store(encode_instant(Instant::now()), Ordering::Relaxed);
149    }
150
151    /// Overwrites the last-access time (used by tests and eviction setup).
152    pub fn set_last_access(&self, when: Instant) {
153        self.last_access
154            .store(encode_instant(when), Ordering::Relaxed);
155    }
156
157    /// Decompresses and returns the full file content.
158    pub fn content(&self) -> Option<String> {
159        zstd_decompress(&self.compressed_content)
160    }
161
162    /// Replaces the stored content with new zstd-compressed data.
163    pub fn set_content(&mut self, content: &str) {
164        self.compressed_content = zstd_compress(content);
165    }
166
167    /// Approximate RAM usage of the compressed content in bytes.
168    pub fn compressed_size(&self) -> usize {
169        self.compressed_content.len()
170    }
171}
172
173/// Result of a cache store operation, indicating whether it was a hit or new entry.
174#[derive(Debug, Clone)]
175pub struct StoreResult {
176    pub line_count: usize,
177    pub original_tokens: usize,
178    pub read_count: u32,
179    pub was_hit: bool,
180    /// Whether full content was previously delivered for this cache entry.
181    pub full_content_delivered: bool,
182}
183
184impl CacheEntry {
185    /// Computes a legacy eviction score blending recency, frequency, and size.
186    pub fn eviction_score_legacy(&self, now: Instant) -> f64 {
187        let elapsed = now
188            .checked_duration_since(self.last_access())
189            .unwrap_or_default()
190            .as_secs_f64();
191        let recency = 1.0 / (1.0 + elapsed.sqrt());
192        let frequency = (self.read_count() as f64 + 1.0).ln();
193        let size_value = (self.original_tokens as f64 + 1.0).ln();
194        recency * 0.4 + frequency * 0.3 + size_value * 0.3
195    }
196
197    pub fn get_compressed(&self, mode_key: &str) -> Option<&String> {
198        self.compressed_outputs.get(mode_key)
199    }
200
201    pub fn set_compressed(&mut self, mode_key: &str, output: String) {
202        const MAX_COMPRESSED_VARIANTS: usize = 3;
203        if self.compressed_outputs.len() >= MAX_COMPRESSED_VARIANTS
204            && !self.compressed_outputs.contains_key(mode_key)
205            && let Some(oldest_key) = self.compressed_outputs.keys().next().cloned()
206        {
207            self.compressed_outputs.remove(&oldest_key);
208        }
209        self.compressed_outputs.insert(mode_key.to_string(), output);
210    }
211
212    pub fn mark_full_delivered(&mut self) {
213        self.full_content_delivered = true;
214    }
215}
216
217const RRF_K: f64 = 60.0;
218
219/// Hebbian protection added to an entry's RRF eviction score per unit of
220/// association strength with the currently-active working set (#3). Files that
221/// are read together resist eviction together ("fire together, wire together").
222/// Deterministic: a fixed multiplier, no sampling.
223const HEBBIAN_PROTECT_WEIGHT: f64 = 0.05;
224/// Size of the "active working set" (most-recently-accessed entries) against
225/// which Hebbian association is measured during eviction.
226const HEBBIAN_ACTIVE_SET: usize = 8;
227
228/// Compute Reciprocal Rank Fusion eviction scores for a batch of cache entries.
229/// Each signal (recency, frequency, size) produces an independent ranking.
230/// The final score is the sum of `1/(k + rank)` across all signals.
231/// Higher score = more valuable = keep longer.
232pub fn eviction_scores_rrf(entries: &[(&String, &CacheEntry)], now: Instant) -> Vec<(String, f64)> {
233    if entries.is_empty() {
234        return Vec::new();
235    }
236
237    let n = entries.len();
238
239    let mut recency_order: Vec<usize> = (0..n).collect();
240    recency_order.sort_by(|&a, &b| {
241        let elapsed_a = now
242            .checked_duration_since(entries[a].1.last_access())
243            .unwrap_or_default()
244            .as_secs_f64();
245        let elapsed_b = now
246            .checked_duration_since(entries[b].1.last_access())
247            .unwrap_or_default()
248            .as_secs_f64();
249        elapsed_a
250            .partial_cmp(&elapsed_b)
251            .unwrap_or(std::cmp::Ordering::Equal)
252    });
253
254    let mut frequency_order: Vec<usize> = (0..n).collect();
255    frequency_order.sort_by(|&a, &b| entries[b].1.read_count().cmp(&entries[a].1.read_count()));
256
257    let mut size_order: Vec<usize> = (0..n).collect();
258    size_order.sort_by(|&a, &b| {
259        entries[b]
260            .1
261            .original_tokens
262            .cmp(&entries[a].1.original_tokens)
263    });
264
265    let mut recency_ranks = vec![0usize; n];
266    let mut frequency_ranks = vec![0usize; n];
267    let mut size_ranks = vec![0usize; n];
268
269    for (rank, &idx) in recency_order.iter().enumerate() {
270        recency_ranks[idx] = rank;
271    }
272    for (rank, &idx) in frequency_order.iter().enumerate() {
273        frequency_ranks[idx] = rank;
274    }
275    for (rank, &idx) in size_order.iter().enumerate() {
276        size_ranks[idx] = rank;
277    }
278
279    entries
280        .iter()
281        .enumerate()
282        .map(|(i, (path, _))| {
283            let score = 1.0 / (RRF_K + recency_ranks[i] as f64)
284                + 1.0 / (RRF_K + frequency_ranks[i] as f64)
285                + 1.0 / (RRF_K + size_ranks[i] as f64);
286            ((*path).clone(), score)
287        })
288        .collect()
289}
290
291/// Add the Hebbian co-access bonus (#3) to RRF eviction scores in place. A
292/// higher score means "keep longer", so co-accessed entries are protected.
293fn apply_hebbian_bonus(scores: &mut [(String, f64)], bonus: &HashMap<String, f64>) {
294    if bonus.is_empty() {
295        return;
296    }
297    for s in scores.iter_mut() {
298        if let Some(b) = bonus.get(&s.0) {
299            s.1 += *b;
300        }
301    }
302}
303
304/// Aggregated cache statistics: hits, reads, and token savings.
305///
306/// Counters are atomic so they can be updated on the read-locked cache-hit
307/// fast path without taking a write lock.
308#[derive(Debug, Default)]
309pub struct CacheStats {
310    total_reads: AtomicU64,
311    cache_hits: AtomicU64,
312    total_original_tokens: AtomicU64,
313    total_sent_tokens: AtomicU64,
314    files_tracked: AtomicU64,
315}
316
317impl CacheStats {
318    /// Total number of read operations recorded.
319    pub fn total_reads(&self) -> u64 {
320        self.total_reads.load(Ordering::Relaxed)
321    }
322
323    /// Total number of cache hits recorded.
324    pub fn cache_hits(&self) -> u64 {
325        self.cache_hits.load(Ordering::Relaxed)
326    }
327
328    /// Sum of original (uncompressed) token counts across all reads.
329    pub fn total_original_tokens(&self) -> u64 {
330        self.total_original_tokens.load(Ordering::Relaxed)
331    }
332
333    /// Sum of tokens actually sent to the model.
334    pub fn total_sent_tokens(&self) -> u64 {
335        self.total_sent_tokens.load(Ordering::Relaxed)
336    }
337
338    /// Number of distinct files currently tracked.
339    pub fn files_tracked(&self) -> u64 {
340        self.files_tracked.load(Ordering::Relaxed)
341    }
342
343    /// Returns the cache hit rate as a percentage (0–100).
344    pub fn hit_rate(&self) -> f64 {
345        let total = self.total_reads();
346        if total == 0 {
347            return 0.0;
348        }
349        (self.cache_hits() as f64 / total as f64) * 100.0
350    }
351
352    /// Returns the total number of tokens saved by cache hits.
353    pub fn tokens_saved(&self) -> u64 {
354        self.total_original_tokens()
355            .saturating_sub(self.total_sent_tokens())
356    }
357
358    /// Returns the savings as a percentage of total original tokens.
359    pub fn savings_percent(&self) -> f64 {
360        let original = self.total_original_tokens();
361        if original == 0 {
362            return 0.0;
363        }
364        (self.tokens_saved() as f64 / original as f64) * 100.0
365    }
366}
367
368/// A block shared across multiple files, identified by its canonical source.
369#[derive(Clone, Debug)]
370pub struct SharedBlock {
371    pub canonical_path: String,
372    pub canonical_ref: String,
373    pub start_line: usize,
374    pub end_line: usize,
375    pub content: String,
376}
377
378/// In-memory file cache with segmented LRU eviction (probationary vs protected),
379/// file references, and cross-file dedup.
380pub struct SessionCache {
381    entries: HashMap<String, CacheEntry>,
382    file_refs: HashMap<String, String>,
383    next_ref: usize,
384    stats: CacheStats,
385    shared_blocks: Vec<SharedBlock>,
386    /// Hebbian co-access matrix (#3): tracks which files are read together so
387    /// eviction can protect co-accessed clusters. Updated on `store`, consulted
388    /// during eviction.
389    co_access: crate::core::hebbian_cache::CoAccessMatrix,
390}
391
392impl Default for SessionCache {
393    fn default() -> Self {
394        Self::new()
395    }
396}
397
398impl SessionCache {
399    /// Creates an empty session cache with default stats.
400    pub fn new() -> Self {
401        Self {
402            entries: HashMap::new(),
403            file_refs: HashMap::new(),
404            next_ref: 1,
405            shared_blocks: Vec::new(),
406            stats: CacheStats::default(),
407            co_access: crate::core::hebbian_cache::CoAccessMatrix::new(),
408        }
409    }
410
411    /// Record that `path` was accessed, strengthening its Hebbian association
412    /// with other files read in the same burst window (#3). Called on every
413    /// `store`; co-access boundaries are flushed via `flush_co_access`.
414    pub fn record_co_access(&mut self, path: &str) {
415        let key = normalize_key(path);
416        self.co_access
417            .record_access(crate::core::hebbian_cache::path_hash(&key));
418    }
419
420    /// Close the current co-access burst so its associations are committed.
421    /// Call at the end of a logical tool call (post-dispatch).
422    pub fn flush_co_access(&mut self) {
423        self.co_access.end_burst();
424    }
425
426    /// Per-entry Hebbian eviction bonus (#3): each cached entry that is
427    /// co-accessed with the recently-active working set earns a positive bonus
428    /// that is added to its RRF score, so clustered files survive eviction
429    /// together. Deterministic (no sampling); ticks the activation registry when
430    /// any association actually influences the decision.
431    pub(crate) fn hebbian_eviction_bonus(&self) -> HashMap<String, f64> {
432        use crate::core::hebbian_cache::path_hash;
433        if self.entries.is_empty() {
434            return HashMap::new();
435        }
436        let mut by_recency: Vec<(&String, Instant)> = self
437            .entries
438            .iter()
439            .map(|(k, e)| (k, e.last_access()))
440            .collect();
441        by_recency.sort_by_key(|(_, t)| std::cmp::Reverse(*t));
442        let active: Vec<u64> = by_recency
443            .iter()
444            .take(HEBBIAN_ACTIVE_SET)
445            .map(|(k, _)| path_hash(k))
446            .collect();
447
448        let mut out = HashMap::new();
449        for k in self.entries.keys() {
450            let h = path_hash(k);
451            // Exclude self so an entry never "protects itself".
452            let peers: Vec<u64> = active.iter().copied().filter(|&a| a != h).collect();
453            let strength = self.co_access.association_strength(h, &peers);
454            if strength > 0.0 {
455                out.insert(k.clone(), f64::from(strength) * HEBBIAN_PROTECT_WEIGHT);
456            }
457        }
458        if !out.is_empty() {
459            crate::core::introspect::tick("hebbian_cache");
460        }
461        out
462    }
463
464    /// Returns or assigns a short file reference label (F1, F2, ...) for the given path.
465    pub fn get_file_ref(&mut self, path: &str) -> String {
466        let key = normalize_key(path);
467        if let Some(r) = self.file_refs.get(&key) {
468            return r.clone();
469        }
470        let r = format!("F{}", self.next_ref);
471        self.next_ref += 1;
472        self.file_refs.insert(key, r.clone());
473        r
474    }
475
476    /// Returns the file reference label for a path without assigning a new one.
477    pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
478        self.file_refs.get(&normalize_key(path)).cloned()
479    }
480
481    /// Looks up a cached entry by file path.
482    pub fn get(&self, path: &str) -> Option<&CacheEntry> {
483        self.entries.get(&normalize_key(path))
484    }
485
486    /// Mutable lookup of a cached entry by file path.
487    pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
488        self.entries.get_mut(&normalize_key(path))
489    }
490
491    /// Retrieves the full (uncompressed) content for a file path, if cached.
492    /// Used by the CCR (Compress-Cache-Retrieve) mechanism.
493    pub fn get_full_content(&self, path: &str) -> Option<String> {
494        self.entries
495            .get(&normalize_key(path))
496            .and_then(CacheEntry::content)
497    }
498
499    /// Staleness-safe accessor for the *current* full content and its token
500    /// count: returns the cached copy when it is still fresh, or a fresh disk
501    /// re-read when the cached copy is stale (mtime/hash changed since it was
502    /// cached). Returns `None` when there is no cache entry, or the entry is
503    /// stale and the file can no longer be read.
504    ///
505    /// Cross-agent / retrieve paths (`ctx_retrieve`, `ctx_share`) MUST use this
506    /// instead of [`get_full_content`](Self::get_full_content): serving the raw
507    /// cached copy hands an agent a version that may no longer match disk — e.g.
508    /// a handover file edited between two agents — silently feeding it stale
509    /// context. Validation uses the entry's stored absolute `path`, because a
510    /// caller's `path` may be relative and resolve against a different CWD.
511    pub fn current_full_content(&self, path: &str) -> Option<(String, usize)> {
512        let entry = self.entries.get(&normalize_key(path))?;
513        if is_cache_entry_stale_verified(&entry.path, entry.stored_mtime, &entry.hash)
514            && let Ok(fresh) = crate::core::io_boundary::read_file_lossy(&entry.path)
515        {
516            // Cache is behind disk → serve the current bytes. If the file is now
517            // unreadable (deleted/permission), fall through to the cached copy:
518            // last-known content beats nothing, and that fall-through is not the
519            // staleness bug (it only fires when there is no current content).
520            let tokens = count_tokens(&fresh);
521            return Some((fresh, tokens));
522        }
523        Some((entry.content()?, entry.original_tokens))
524    }
525
526    /// Records a cache hit, updates access stats, and emits a cache-hit event.
527    ///
528    /// Takes `&self`: the hit counters use interior-mutable atomics, so this
529    /// runs under a shared (read) lock and lets parallel reads of different
530    /// files proceed concurrently instead of serializing on a write lock.
531    pub fn record_cache_hit(&self, path: &str) -> Option<&CacheEntry> {
532        let key = normalize_key(path);
533        let ref_label = self
534            .file_refs
535            .get(&key)
536            .cloned()
537            .unwrap_or_else(|| "F?".to_string());
538        let entry = self.entries.get(&key)?;
539        let new_count = entry.bump_read_count();
540        entry.touch();
541        self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
542        self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
543        self.stats
544            .total_original_tokens
545            .fetch_add(entry.original_tokens as u64, Ordering::Relaxed);
546        let hit_msg = format!("{ref_label} cached {new_count}t {}L", entry.line_count);
547        self.stats
548            .total_sent_tokens
549            .fetch_add(count_tokens(&hit_msg) as u64, Ordering::Relaxed);
550        crate::core::events::emit_cache_hit(path, entry.original_tokens as u64);
551        Some(entry)
552    }
553
554    /// Stores file content in the cache; returns a hit if content hash matches.
555    pub fn store(&mut self, path: &str, content: &str) -> StoreResult {
556        let key = normalize_key(path);
557        // #3: feed the Hebbian co-access matrix on every read so eviction can
558        // later protect files that are habitually read together.
559        self.co_access
560            .record_access(crate::core::hebbian_cache::path_hash(&key));
561        let hash = compute_md5(content);
562        let line_count = content.lines().count();
563        let original_tokens = count_tokens(content);
564        let stored_mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
565        let now = Instant::now();
566
567        self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
568        self.stats
569            .total_original_tokens
570            .fetch_add(original_tokens as u64, Ordering::Relaxed);
571
572        if let Some(existing) = self.entries.get_mut(&key) {
573            existing.set_last_access(now);
574            if stored_mtime.is_some() {
575                existing.stored_mtime = stored_mtime;
576            }
577            if existing.hash == hash {
578                let new_count = existing.bump_read_count();
579                self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
580                let hit_msg = format!(
581                    "{} cached {new_count}t {}L",
582                    self.file_refs.get(&key).unwrap_or(&"F?".to_string()),
583                    existing.line_count,
584                );
585                self.stats
586                    .total_sent_tokens
587                    .fetch_add(count_tokens(&hit_msg) as u64, Ordering::Relaxed);
588                return StoreResult {
589                    line_count: existing.line_count,
590                    original_tokens: existing.original_tokens,
591                    read_count: new_count,
592                    was_hit: true,
593                    full_content_delivered: existing.full_content_delivered,
594                };
595            }
596            existing.compressed_outputs.clear();
597            existing.set_content(content);
598            existing.hash = hash;
599            existing.line_count = line_count;
600            existing.original_tokens = original_tokens;
601            let new_count = existing.bump_read_count();
602            existing.full_content_delivered = false;
603            if stored_mtime.is_some() {
604                existing.stored_mtime = stored_mtime;
605            }
606            self.stats
607                .total_sent_tokens
608                .fetch_add(original_tokens as u64, Ordering::Relaxed);
609            return StoreResult {
610                line_count,
611                original_tokens,
612                read_count: new_count,
613                was_hit: false,
614                full_content_delivered: false,
615            };
616        }
617
618        self.evict_if_needed(original_tokens);
619        self.get_file_ref(&key);
620
621        let entry = CacheEntry::new(
622            content,
623            hash,
624            line_count,
625            original_tokens,
626            key.clone(),
627            stored_mtime,
628        );
629
630        self.entries.insert(key, entry);
631        self.stats.files_tracked.fetch_add(1, Ordering::Relaxed);
632        self.stats
633            .total_sent_tokens
634            .fetch_add(original_tokens as u64, Ordering::Relaxed);
635        StoreResult {
636            line_count,
637            original_tokens,
638            read_count: 1,
639            was_hit: false,
640            full_content_delivered: false,
641        }
642    }
643
644    /// Returns the sum of original token counts across all cached entries.
645    pub fn total_cached_tokens(&self) -> usize {
646        self.entries.values().map(|e| e.original_tokens).sum()
647    }
648
649    /// Evict until cache fits within token budget using RRF (Reciprocal Rank Fusion).
650    /// Combines recency, frequency, and size signals to evict least-valuable entries first.
651    pub fn evict_if_needed(&mut self, incoming_tokens: usize) {
652        let max_tokens = max_cache_tokens();
653        let current = self.total_cached_tokens();
654        if current + incoming_tokens <= max_tokens {
655            return;
656        }
657
658        let now = Instant::now();
659        let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
660        let mut scores = eviction_scores_rrf(&all, now);
661        apply_hebbian_bonus(&mut scores, &self.hebbian_eviction_bonus());
662        // Sort ascending: lowest RRF score = least valuable = evict first
663        scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
664
665        let mut freed = 0usize;
666        let target = (current + incoming_tokens).saturating_sub(max_tokens);
667
668        for (path, _score) in &scores {
669            if freed >= target {
670                break;
671            }
672            if let Some(entry) = self.entries.remove(path) {
673                freed += entry.original_tokens;
674                self.file_refs.remove(path);
675            }
676        }
677    }
678
679    /// Returns all cached entries as (path, entry) pairs.
680    pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
681        self.entries.iter().collect()
682    }
683
684    /// Returns a reference to the aggregated cache statistics.
685    pub fn get_stats(&self) -> &CacheStats {
686        &self.stats
687    }
688
689    /// Returns the path-to-file-ref mapping (e.g. "/src/main.rs" → "F1").
690    pub fn file_ref_map(&self) -> &HashMap<String, String> {
691        &self.file_refs
692    }
693
694    /// Replaces the cross-file shared blocks used for deduplication.
695    pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
696        self.shared_blocks = blocks;
697    }
698
699    /// Returns the current set of cross-file shared blocks.
700    pub fn get_shared_blocks(&self) -> &[SharedBlock] {
701        &self.shared_blocks
702    }
703
704    /// Replace shared blocks in content with cross-file references.
705    pub fn apply_dedup(&self, path: &str, content: &str) -> Option<String> {
706        if self.shared_blocks.is_empty() {
707            return None;
708        }
709        let refs: Vec<&SharedBlock> = self
710            .shared_blocks
711            .iter()
712            .filter(|b| b.canonical_path != path && content.contains(&b.content))
713            .collect();
714        if refs.is_empty() {
715            return None;
716        }
717        let mut result = content.to_string();
718        for block in refs {
719            result = result.replacen(
720                &block.content,
721                &format!(
722                    "[= {}:{}-{}]",
723                    block.canonical_ref, block.start_line, block.end_line
724                ),
725                1,
726            );
727        }
728        Some(result)
729    }
730
731    /// Removes a file from the cache, forcing a fresh read on next access.
732    pub fn invalidate(&mut self, path: &str) -> bool {
733        self.entries.remove(&normalize_key(path)).is_some()
734    }
735
736    /// Returns a cached compressed output for a given file and mode key.
737    pub fn get_compressed(&self, path: &str, mode_key: &str) -> Option<&String> {
738        self.entries
739            .get(&normalize_key(path))?
740            .get_compressed(mode_key)
741    }
742
743    /// Marks that full (uncompressed) content was delivered for this file.
744    pub fn mark_full_delivered(&mut self, path: &str) {
745        if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
746            entry.mark_full_delivered();
747        }
748    }
749
750    /// Stores a compressed output for a given file and mode key.
751    pub fn set_compressed(&mut self, path: &str, mode_key: &str, output: String) {
752        if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
753            entry.set_compressed(mode_key, output);
754        }
755    }
756
757    /// Resets `full_content_delivered` for all entries without removing them.
758    /// Used after host context compaction — forces re-delivery on next read
759    /// while preserving compressed content and file refs.
760    pub fn reset_delivery_flags(&mut self) -> usize {
761        let mut count = 0;
762        for entry in self.entries.values_mut() {
763            if entry.full_content_delivered {
764                entry.full_content_delivered = false;
765                count += 1;
766            }
767        }
768        count
769    }
770
771    /// Returns whether full content was previously delivered for this path.
772    pub fn is_full_delivered(&self, path: &str) -> bool {
773        self.entries
774            .get(&normalize_key(path))
775            .is_some_and(|e| e.full_content_delivered)
776    }
777
778    /// Removes all compressed output variants (map, signatures, etc.) from every entry,
779    /// keeping the full zstd-compressed content intact. Returns the number of entries trimmed.
780    pub fn trim_compressed_outputs(&mut self) -> usize {
781        let mut trimmed = 0;
782        for entry in self.entries.values_mut() {
783            if !entry.compressed_outputs.is_empty() {
784                entry.compressed_outputs.clear();
785                trimmed += 1;
786            }
787        }
788        trimmed
789    }
790
791    /// Evicts all entries that have been read at most once (probationary).
792    /// Returns the number of entries removed.
793    pub fn evict_probationary(&mut self) -> usize {
794        let to_remove: Vec<String> = self
795            .entries
796            .iter()
797            .filter(|(_, e)| e.read_count() <= 1)
798            .map(|(k, _)| k.clone())
799            .collect();
800        let count = to_remove.len();
801        for key in &to_remove {
802            self.entries.remove(key);
803            self.file_refs.remove(key);
804        }
805        count
806    }
807
808    /// Evicts entries via RRF scoring until total tokens are at or below `target_tokens`.
809    pub fn evict_to_budget(&mut self, target_tokens: usize) {
810        let current = self.total_cached_tokens();
811        if current <= target_tokens {
812            return;
813        }
814        let now = Instant::now();
815        let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
816        let mut scores = eviction_scores_rrf(&all, now);
817        apply_hebbian_bonus(&mut scores, &self.hebbian_eviction_bonus());
818        scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
819
820        let mut freed = 0usize;
821        let target_free = current.saturating_sub(target_tokens);
822        for (path, _score) in &scores {
823            if freed >= target_free {
824                break;
825            }
826            if let Some(entry) = self.entries.remove(path) {
827                freed += entry.original_tokens;
828                self.file_refs.remove(path);
829            }
830        }
831    }
832
833    /// Estimates the approximate heap memory usage in bytes.
834    pub fn approximate_bytes(&self) -> usize {
835        let entries_bytes: usize = self
836            .entries
837            .values()
838            .map(|e| {
839                e.compressed_content.len()
840                    + e.hash.len()
841                    + e.path.len()
842                    + e.compressed_outputs
843                        .iter()
844                        .map(|(k, v)| k.len() + v.len())
845                        .sum::<usize>()
846                    + 128 // fixed overhead per entry
847            })
848            .sum();
849        let refs_bytes: usize = self.file_refs.iter().map(|(k, v)| k.len() + v.len()).sum();
850        let blocks_bytes: usize = self
851            .shared_blocks
852            .iter()
853            .map(|b| b.canonical_path.len() + b.canonical_ref.len() + b.content.len() + 32)
854            .sum();
855        entries_bytes + refs_bytes + blocks_bytes
856    }
857
858    const MAX_SHARED_BLOCKS: usize = 100;
859
860    /// Trims shared blocks to a maximum count, keeping the most recent.
861    pub fn trim_shared_blocks(&mut self) {
862        if self.shared_blocks.len() > Self::MAX_SHARED_BLOCKS {
863            let excess = self.shared_blocks.len() - Self::MAX_SHARED_BLOCKS;
864            self.shared_blocks.drain(..excess);
865        }
866    }
867
868    /// Clears all cached entries, file refs, and resets stats. Returns the number of entries removed.
869    pub fn clear(&mut self) -> usize {
870        let count = self.entries.len();
871        self.entries.clear();
872        self.file_refs.clear();
873        self.shared_blocks.clear();
874        self.next_ref = 1;
875        self.stats = CacheStats::default();
876        count
877    }
878}
879
880pub fn file_mtime(path: &str) -> Option<SystemTime> {
881    std::fs::metadata(path).and_then(|m| m.modified()).ok()
882}
883
884pub fn is_cache_entry_stale(path: &str, cached_mtime: Option<SystemTime>) -> bool {
885    let current = file_mtime(path);
886    match (cached_mtime, current) {
887        // Both unavailable (e.g. WSL DrvFS): can't tell → assume fresh (conservative).
888        (None, None) => false,
889        // One side missing: metadata changed or appeared/disappeared → stale.
890        (Some(_), None) | (None, Some(_)) => true,
891        // `!=`, not `>`: a *backward* mtime (git checkout, touch -t, snapshot
892        // restore) is just as much a content change as a forward one.
893        (Some(cached), Some(current)) => current != cached,
894    }
895}
896
897/// Files larger than this are not content-hashed for stub verification; the
898/// mtime check alone decides. Keeps the stub fast-path O(small-file-read).
899const VERIFY_HASH_CAP_BYTES: u64 = 8 * 1024 * 1024;
900
901fn cache_verify_enabled() -> bool {
902    std::env::var("LEAN_CTX_CACHE_VERIFY").map_or(true, |v| v != "0")
903}
904
905/// Staleness with content verification: like [`is_cache_entry_stale`], but when
906/// the mtime claims "unchanged", additionally compares the md5 of the on-disk
907/// content against the cached hash.
908///
909/// mtime alone cannot be trusted for *correctness*: same-second writes are
910/// invisible on coarse-granularity filesystems (HFS+ 1s, FAT 2s) and mtimes can
911/// be restored by tools. Serving an `[unchanged]` stub for changed content
912/// would silently mislead the agent — the worst failure mode a context layer
913/// can have. The extra disk read costs microseconds for typical source files;
914/// the stub's token savings are unaffected. Opt out: `LEAN_CTX_CACHE_VERIFY=0`.
915///
916/// Note: entries whose stored content differs from disk by design (e.g. secret
917/// redaction) hash differently and therefore never serve stubs — conservative
918/// and correct.
919pub fn is_cache_entry_stale_verified(
920    path: &str,
921    cached_mtime: Option<SystemTime>,
922    cached_hash: &str,
923) -> bool {
924    if is_cache_entry_stale(path, cached_mtime) {
925        return true;
926    }
927    if cached_hash.is_empty() || !cache_verify_enabled() {
928        return false;
929    }
930    let Ok(meta) = std::fs::metadata(path) else {
931        // Can't stat → never serve a stub on top of it.
932        return true;
933    };
934    if meta.len() > VERIFY_HASH_CAP_BYTES {
935        return false;
936    }
937    match std::fs::read(path) {
938        // Hash the same view of the bytes that `store()` hashed (lossy UTF-8).
939        Ok(bytes) => compute_md5(&String::from_utf8_lossy(&bytes)) != cached_hash,
940        Err(_) => true,
941    }
942}
943
944fn compute_md5(content: &str) -> String {
945    let mut hasher = Md5::new();
946    hasher.update(content.as_bytes());
947    crate::core::agent_identity::hex_encode(&hasher.finalize())
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use std::time::Duration;
954
955    #[test]
956    fn cache_stores_and_retrieves() {
957        let mut cache = SessionCache::new();
958        let result = cache.store("/test/file.rs", "fn main() {}");
959        assert!(!result.was_hit);
960        assert_eq!(result.line_count, 1);
961        assert!(cache.get("/test/file.rs").is_some());
962    }
963
964    #[test]
965    fn cache_hit_on_same_content() {
966        let mut cache = SessionCache::new();
967        cache.store("/test/file.rs", "content");
968        let result = cache.store("/test/file.rs", "content");
969        assert!(result.was_hit, "same content should be a cache hit");
970    }
971
972    #[test]
973    fn cache_miss_on_changed_content() {
974        let mut cache = SessionCache::new();
975        cache.store("/test/file.rs", "old content");
976        let result = cache.store("/test/file.rs", "new content");
977        assert!(!result.was_hit, "changed content should not be a cache hit");
978    }
979
980    #[test]
981    fn file_refs_are_sequential() {
982        let mut cache = SessionCache::new();
983        assert_eq!(cache.get_file_ref("/a.rs"), "F1");
984        assert_eq!(cache.get_file_ref("/b.rs"), "F2");
985        assert_eq!(cache.get_file_ref("/a.rs"), "F1"); // stable
986    }
987
988    #[test]
989    fn cache_clear_resets_everything() {
990        let mut cache = SessionCache::new();
991        cache.store("/a.rs", "a");
992        cache.store("/b.rs", "b");
993        let count = cache.clear();
994        assert_eq!(count, 2);
995        assert!(cache.get("/a.rs").is_none());
996        assert_eq!(cache.get_file_ref("/c.rs"), "F1"); // refs reset
997    }
998
999    #[test]
1000    fn cache_invalidate_removes_entry() {
1001        let mut cache = SessionCache::new();
1002        cache.store("/test.rs", "test");
1003        assert!(cache.invalidate("/test.rs"));
1004        assert!(!cache.invalidate("/nonexistent.rs"));
1005    }
1006
1007    #[test]
1008    fn cache_stats_track_correctly() {
1009        let mut cache = SessionCache::new();
1010        cache.store("/a.rs", "hello");
1011        cache.store("/a.rs", "hello"); // hit
1012        let stats = cache.get_stats();
1013        assert_eq!(stats.total_reads(), 2);
1014        assert_eq!(stats.cache_hits(), 1);
1015        assert!(stats.hit_rate() > 0.0);
1016    }
1017
1018    #[test]
1019    fn current_full_content_serves_cached_when_fresh() {
1020        let dir = tempfile::tempdir().unwrap();
1021        let file = dir.path().join("handover.md");
1022        std::fs::write(&file, "HANDOVER V1\n").unwrap();
1023        let path = file.to_str().unwrap();
1024
1025        let mut cache = SessionCache::new();
1026        cache.store(path, "HANDOVER V1\n");
1027
1028        let (content, tokens) = cache.current_full_content(path).unwrap();
1029        assert_eq!(content, "HANDOVER V1\n");
1030        assert!(tokens > 0);
1031    }
1032
1033    #[test]
1034    fn current_full_content_rereads_when_file_changed() {
1035        // Handover staleness: a file cached by agent A and then edited must not
1036        // be served from the stale cache to agent B (ctx_retrieve / ctx_share).
1037        let dir = tempfile::tempdir().unwrap();
1038        let file = dir.path().join("handover.md");
1039        std::fs::write(&file, "HANDOVER V1\n").unwrap();
1040        let path = file.to_str().unwrap();
1041
1042        let mut cache = SessionCache::new();
1043        cache.store(path, "HANDOVER V1\n");
1044
1045        // Simulate an edit between agents (new mtime + new content).
1046        std::thread::sleep(std::time::Duration::from_millis(10));
1047        std::fs::write(&file, "HANDOVER V2 CHANGED\n").unwrap();
1048
1049        let (content, _) = cache.current_full_content(path).unwrap();
1050        assert_eq!(
1051            content, "HANDOVER V2 CHANGED\n",
1052            "stale cached copy must be re-read from disk, not served as-is"
1053        );
1054    }
1055
1056    #[test]
1057    fn current_full_content_none_without_entry() {
1058        let cache = SessionCache::new();
1059        assert!(cache.current_full_content("/no/such/file.rs").is_none());
1060    }
1061
1062    #[test]
1063    fn current_full_content_falls_back_to_cache_when_file_unreadable() {
1064        // Stale + now-unreadable (deleted/moved): there is no current content to
1065        // serve, so the last-known cached copy is returned rather than nothing.
1066        // Canonicalize the temp dir up front so the cache key is stable after the
1067        // file is removed (macOS /var -> /private/var symlink).
1068        let dir = tempfile::tempdir().unwrap();
1069        let canon = dir.path().canonicalize().unwrap();
1070        let file = canon.join("gone.md");
1071        std::fs::write(&file, "ORIGINAL\n").unwrap();
1072        let path = file.to_str().unwrap().to_string();
1073
1074        let mut cache = SessionCache::new();
1075        cache.store(&path, "ORIGINAL\n");
1076        std::fs::remove_file(&file).unwrap();
1077
1078        let (content, _) = cache.current_full_content(&path).unwrap();
1079        assert_eq!(
1080            content, "ORIGINAL\n",
1081            "unreadable file must fall back to last-known cached content"
1082        );
1083    }
1084
1085    #[test]
1086    fn record_cache_hit_works_through_shared_ref() {
1087        let mut cache = SessionCache::new();
1088        cache.store("/x.rs", "hello world");
1089        // &self path: a cache hit can be recorded without a write lock.
1090        let shared: &SessionCache = &cache;
1091        assert!(shared.record_cache_hit("/x.rs").is_some());
1092        assert!(shared.record_cache_hit("/x.rs").is_some());
1093        // store=1 + two hits => read_count 3, cache_hits 2.
1094        assert_eq!(cache.get("/x.rs").unwrap().read_count(), 3);
1095        assert_eq!(cache.get_stats().cache_hits(), 2);
1096    }
1097
1098    #[test]
1099    fn concurrent_cache_hits_are_lossless() {
1100        use std::sync::Arc;
1101        let mut cache = SessionCache::new();
1102        cache.store("/a.rs", "a");
1103        cache.store("/b.rs", "b");
1104        // Shared (no RwLock): proves SessionCache is Sync and hit recording is
1105        // lock-free and atomic — the whole point of the read-mostly refactor.
1106        let cache = Arc::new(cache);
1107        let threads = 8;
1108        let iters = 1_000;
1109        let handles: Vec<_> = (0..threads)
1110            .map(|_| {
1111                let c = Arc::clone(&cache);
1112                std::thread::spawn(move || {
1113                    for _ in 0..iters {
1114                        c.record_cache_hit("/a.rs");
1115                        c.record_cache_hit("/b.rs");
1116                    }
1117                })
1118            })
1119            .collect();
1120        for h in handles {
1121            h.join().unwrap();
1122        }
1123        let total = (threads * iters) as u64;
1124        assert_eq!(cache.get_stats().cache_hits(), total * 2);
1125        assert_eq!(cache.get("/a.rs").unwrap().read_count(), 1 + total as u32);
1126        assert_eq!(cache.get("/b.rs").unwrap().read_count(), 1 + total as u32);
1127    }
1128
1129    #[test]
1130    fn hebbian_eviction_bonus_is_wired() {
1131        // #3: files read together build a Hebbian association via store()'s
1132        // recording, and that association must feed the eviction bonus.
1133        let mut cache = SessionCache::new();
1134        cache.store("/a.rs", "fn a() {}");
1135        cache.store("/b.rs", "fn b() {}");
1136        cache.flush_co_access(); // commit the burst → association (a,b) forms
1137        let bonus = cache.hebbian_eviction_bonus();
1138        assert!(
1139            !bonus.is_empty(),
1140            "co-accessed reads must yield a Hebbian eviction bonus (#3 wired)"
1141        );
1142    }
1143
1144    #[test]
1145    fn md5_is_deterministic() {
1146        let h1 = compute_md5("test content");
1147        let h2 = compute_md5("test content");
1148        assert_eq!(h1, h2);
1149        assert_ne!(h1, compute_md5("different"));
1150    }
1151
1152    #[test]
1153    fn rrf_eviction_prefers_recent() {
1154        let key_a = "a.rs".to_string();
1155        let key_b = "b.rs".to_string();
1156        // Construct entries first so the global instant base is initialized,
1157        // then assign access times relative to a post-init reference.
1158        let recent = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
1159        let old = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
1160        let t_old = Instant::now();
1161        std::thread::sleep(std::time::Duration::from_millis(10));
1162        let t_recent = Instant::now();
1163        old.set_last_access(t_old);
1164        recent.set_last_access(t_recent);
1165        let now = Instant::now();
1166        let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &recent), (&key_b, &old)];
1167        let scores = eviction_scores_rrf(&entries, now);
1168        let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
1169        let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
1170        assert!(
1171            score_a > score_b,
1172            "recently accessed entries should score higher via RRF"
1173        );
1174    }
1175
1176    #[test]
1177    fn rrf_eviction_prefers_frequent() {
1178        let now = Instant::now();
1179        let key_a = "a.rs".to_string();
1180        let key_b = "b.rs".to_string();
1181        let frequent = {
1182            let e = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
1183            e.set_read_count(20);
1184            e
1185        };
1186        let rare = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
1187        let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &frequent), (&key_b, &rare)];
1188        let scores = eviction_scores_rrf(&entries, now);
1189        let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
1190        let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
1191        assert!(
1192            score_a > score_b,
1193            "frequently accessed entries should score higher via RRF"
1194        );
1195    }
1196
1197    #[test]
1198    fn cache_budget_resolver_precedence() {
1199        // env wins when positive
1200        assert_eq!(resolve_cache_max_tokens(Some("250000"), 999), 250_000);
1201        assert_eq!(resolve_cache_max_tokens(Some(" 80000 "), 0), 80_000);
1202        // env 0 / blank / garbage falls through to config
1203        assert_eq!(resolve_cache_max_tokens(Some("0"), 123_456), 123_456);
1204        assert_eq!(resolve_cache_max_tokens(Some(""), 123_456), 123_456);
1205        assert_eq!(resolve_cache_max_tokens(Some("lots"), 123_456), 123_456);
1206        // no env → config field
1207        assert_eq!(resolve_cache_max_tokens(None, 42_000), 42_000);
1208        // nothing set anywhere → built-in default
1209        assert_eq!(resolve_cache_max_tokens(None, 0), DEFAULT_CACHE_MAX_TOKENS);
1210        assert_eq!(
1211            resolve_cache_max_tokens(Some("0"), 0),
1212            DEFAULT_CACHE_MAX_TOKENS
1213        );
1214    }
1215
1216    #[test]
1217    fn evict_if_needed_removes_lowest_score() {
1218        crate::test_env::set_var("LEAN_CTX_CACHE_MAX_TOKENS", "50");
1219        let mut cache = SessionCache::new();
1220        let big_content = "a]".repeat(30); // ~30 tokens
1221        cache.store("/old.rs", &big_content);
1222        // /old.rs now in cache with ~30 tokens
1223
1224        let new_content = "b ".repeat(30); // ~30 tokens incoming
1225        cache.store("/new.rs", &new_content);
1226        // should have evicted /old.rs to make room
1227        // (total would be ~60 which exceeds 50)
1228
1229        // At least one should remain, total should be <= 50
1230        assert!(
1231            cache.total_cached_tokens() <= 60,
1232            "eviction should have kicked in"
1233        );
1234        crate::test_env::remove_var("LEAN_CTX_CACHE_MAX_TOKENS");
1235    }
1236
1237    #[test]
1238    fn stale_detection_flags_newer_file() {
1239        let dir = tempfile::tempdir().unwrap();
1240        let path = dir.path().join("stale.txt");
1241        let p = path.to_string_lossy().to_string();
1242
1243        std::fs::write(&path, "one").unwrap();
1244        let mut cache = SessionCache::new();
1245        cache.store(&p, "one");
1246
1247        let entry = cache.get(&p).unwrap();
1248        assert!(!is_cache_entry_stale(&p, entry.stored_mtime));
1249
1250        // Ensure mtime granularity differences don't make this flaky.
1251        std::thread::sleep(Duration::from_secs(1));
1252        std::fs::write(&path, "two").unwrap();
1253
1254        let entry = cache.get(&p).unwrap();
1255        assert!(is_cache_entry_stale(&p, entry.stored_mtime));
1256    }
1257
1258    // P0-7 (#419): a *backward* mtime (git checkout, touch -t) is a change.
1259    #[test]
1260    fn stale_detection_flags_backward_mtime() {
1261        let dir = tempfile::tempdir().unwrap();
1262        let path = dir.path().join("backward.txt");
1263        let p = path.to_string_lossy().to_string();
1264
1265        std::fs::write(&path, "one").unwrap();
1266        let mut cache = SessionCache::new();
1267        cache.store(&p, "one");
1268        let entry_mtime = cache.get(&p).unwrap().stored_mtime;
1269        assert!(!is_cache_entry_stale(&p, entry_mtime));
1270
1271        // Simulate `git checkout` of an older version: content + older mtime.
1272        std::fs::write(&path, "zero").unwrap();
1273        let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
1274        f.set_modified(SystemTime::now() - Duration::from_hours(1))
1275            .unwrap();
1276        drop(f);
1277
1278        assert!(
1279            is_cache_entry_stale(&p, entry_mtime),
1280            "older mtime must read as stale"
1281        );
1282    }
1283
1284    // P0-7 (#419): identical mtime with different content (same-second write,
1285    // restored timestamps) is caught by the content-hash verification.
1286    #[test]
1287    fn verified_staleness_catches_same_mtime_content_change() {
1288        let dir = tempfile::tempdir().unwrap();
1289        let path = dir.path().join("sneaky.txt");
1290        let p = path.to_string_lossy().to_string();
1291
1292        std::fs::write(&path, "one").unwrap();
1293        let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
1294        let mut cache = SessionCache::new();
1295        cache.store(&p, "one");
1296        let (mtime, hash) = {
1297            let e = cache.get(&p).unwrap();
1298            (e.stored_mtime, e.hash.clone())
1299        };
1300
1301        // Unchanged file: both checks agree it is fresh.
1302        assert!(!is_cache_entry_stale_verified(&p, mtime, &hash));
1303
1304        // Change the content but restore the exact original mtime.
1305        std::fs::write(&path, "two").unwrap();
1306        let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
1307        f.set_modified(original_mtime).unwrap();
1308        drop(f);
1309
1310        assert!(
1311            !is_cache_entry_stale(&p, mtime),
1312            "test premise: the mtime check alone is fooled"
1313        );
1314        assert!(
1315            is_cache_entry_stale_verified(&p, mtime, &hash),
1316            "hash verification must catch the change"
1317        );
1318    }
1319
1320    #[test]
1321    fn verified_staleness_flags_unreadable_file() {
1322        let mut cache = SessionCache::new();
1323        cache.store("/nonexistent/file.rs", "content");
1324        let (mtime, hash) = {
1325            let e = cache.get("/nonexistent/file.rs").unwrap();
1326            (e.stored_mtime, e.hash.clone())
1327        };
1328        assert!(is_cache_entry_stale_verified(
1329            "/nonexistent/file.rs",
1330            mtime,
1331            &hash
1332        ));
1333    }
1334
1335    #[test]
1336    fn compressed_outputs_cached_and_retrieved() {
1337        let mut cache = SessionCache::new();
1338        cache.store("/test.rs", "fn main() {}");
1339        cache.set_compressed("/test.rs", "map", "compressed map output".to_string());
1340        assert_eq!(
1341            cache.get_compressed("/test.rs", "map"),
1342            Some(&"compressed map output".to_string())
1343        );
1344        assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
1345    }
1346
1347    #[test]
1348    fn compressed_outputs_cleared_on_content_change() {
1349        let mut cache = SessionCache::new();
1350        cache.store("/test.rs", "old content");
1351        cache.set_compressed("/test.rs", "map", "old map".to_string());
1352        assert!(cache.get_compressed("/test.rs", "map").is_some());
1353
1354        cache.store("/test.rs", "new content");
1355        assert_eq!(cache.get_compressed("/test.rs", "map"), None);
1356    }
1357
1358    #[test]
1359    fn compressed_outputs_survive_same_content_store() {
1360        let mut cache = SessionCache::new();
1361        cache.store("/test.rs", "content");
1362        cache.set_compressed("/test.rs", "map", "cached map".to_string());
1363
1364        let result = cache.store("/test.rs", "content");
1365        assert!(result.was_hit);
1366        assert_eq!(
1367            cache.get_compressed("/test.rs", "map"),
1368            Some(&"cached map".to_string())
1369        );
1370    }
1371
1372    #[test]
1373    fn compressed_outputs_cleared_on_invalidate() {
1374        let mut cache = SessionCache::new();
1375        cache.store("/test.rs", "content");
1376        cache.set_compressed("/test.rs", "signatures", "cached sigs".to_string());
1377        cache.invalidate("/test.rs");
1378        assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
1379    }
1380
1381    #[test]
1382    fn compressed_outputs_cleared_on_clear() {
1383        let mut cache = SessionCache::new();
1384        cache.store("/a.rs", "a");
1385        cache.set_compressed("/a.rs", "map", "map_a".to_string());
1386        cache.clear();
1387        assert_eq!(cache.get_compressed("/a.rs", "map"), None);
1388    }
1389}