Skip to main content

lean_ctx/core/
cache.rs

1use md5::{Digest, Md5};
2use std::collections::HashMap;
3use std::time::{Instant, SystemTime};
4
5use super::tokens::count_tokens;
6
7fn normalize_key(path: &str) -> String {
8    crate::core::pathutil::normalize_tool_path(path)
9}
10
11fn max_cache_tokens() -> usize {
12    std::env::var("LEAN_CTX_CACHE_MAX_TOKENS")
13        .ok()
14        .and_then(|v| v.parse().ok())
15        .unwrap_or(500_000)
16}
17
18/// A cached file read: zstd-compressed content, hash, token count, and access metadata.
19#[derive(Clone, Debug)]
20pub struct CacheEntry {
21    compressed_content: Vec<u8>,
22    pub hash: String,
23    pub line_count: usize,
24    pub original_tokens: usize,
25    pub read_count: u32,
26    pub path: String,
27    pub last_access: Instant,
28    pub stored_mtime: Option<SystemTime>,
29    /// Mode-specific compressed outputs (e.g. "map", "signatures") cached to avoid re-parsing.
30    pub compressed_outputs: HashMap<String, String>,
31    /// Whether full (uncompressed) content was already delivered for this hash.
32    /// Prevents cache-stub loops when upgrading from compressed to full mode.
33    pub full_content_delivered: bool,
34    /// Last read mode used for this file (for auto-escalation on edit failure).
35    pub last_mode: String,
36}
37
38const ZSTD_LEVEL: i32 = 3;
39
40fn zstd_compress(data: &str) -> Vec<u8> {
41    zstd::encode_all(data.as_bytes(), ZSTD_LEVEL).unwrap_or_else(|_| data.as_bytes().to_vec())
42}
43
44fn zstd_decompress(data: &[u8]) -> Option<String> {
45    zstd::decode_all(data)
46        .ok()
47        .and_then(|v| String::from_utf8(v).ok())
48}
49
50impl CacheEntry {
51    /// Creates a new entry with zstd-compressed content.
52    pub fn new(
53        content: &str,
54        hash: String,
55        line_count: usize,
56        original_tokens: usize,
57        path: String,
58        stored_mtime: Option<SystemTime>,
59    ) -> Self {
60        let compressed_content = zstd_compress(content);
61        Self {
62            compressed_content,
63            hash,
64            line_count,
65            original_tokens,
66            read_count: 1,
67            path,
68            last_access: Instant::now(),
69            stored_mtime,
70            compressed_outputs: HashMap::new(),
71            full_content_delivered: false,
72            last_mode: String::new(),
73        }
74    }
75
76    /// Decompresses and returns the full file content.
77    pub fn content(&self) -> Option<String> {
78        zstd_decompress(&self.compressed_content)
79    }
80
81    /// Replaces the stored content with new zstd-compressed data.
82    pub fn set_content(&mut self, content: &str) {
83        self.compressed_content = zstd_compress(content);
84    }
85
86    /// Approximate RAM usage of the compressed content in bytes.
87    pub fn compressed_size(&self) -> usize {
88        self.compressed_content.len()
89    }
90}
91
92/// Result of a cache store operation, indicating whether it was a hit or new entry.
93#[derive(Debug, Clone)]
94pub struct StoreResult {
95    pub line_count: usize,
96    pub original_tokens: usize,
97    pub read_count: u32,
98    pub was_hit: bool,
99    /// Whether full content was previously delivered for this cache entry.
100    pub full_content_delivered: bool,
101}
102
103impl CacheEntry {
104    /// Computes a legacy eviction score blending recency, frequency, and size.
105    pub fn eviction_score_legacy(&self, now: Instant) -> f64 {
106        let elapsed = now
107            .checked_duration_since(self.last_access)
108            .unwrap_or_default()
109            .as_secs_f64();
110        let recency = 1.0 / (1.0 + elapsed.sqrt());
111        let frequency = (self.read_count as f64 + 1.0).ln();
112        let size_value = (self.original_tokens as f64 + 1.0).ln();
113        recency * 0.4 + frequency * 0.3 + size_value * 0.3
114    }
115
116    pub fn get_compressed(&self, mode_key: &str) -> Option<&String> {
117        self.compressed_outputs.get(mode_key)
118    }
119
120    pub fn set_compressed(&mut self, mode_key: &str, output: String) {
121        const MAX_COMPRESSED_VARIANTS: usize = 3;
122        if self.compressed_outputs.len() >= MAX_COMPRESSED_VARIANTS
123            && !self.compressed_outputs.contains_key(mode_key)
124        {
125            if let Some(oldest_key) = self.compressed_outputs.keys().next().cloned() {
126                self.compressed_outputs.remove(&oldest_key);
127            }
128        }
129        self.compressed_outputs.insert(mode_key.to_string(), output);
130    }
131
132    pub fn mark_full_delivered(&mut self) {
133        self.full_content_delivered = true;
134    }
135}
136
137const RRF_K: f64 = 60.0;
138
139/// Compute Reciprocal Rank Fusion eviction scores for a batch of cache entries.
140/// Each signal (recency, frequency, size) produces an independent ranking.
141/// The final score is the sum of `1/(k + rank)` across all signals.
142/// Higher score = more valuable = keep longer.
143pub fn eviction_scores_rrf(entries: &[(&String, &CacheEntry)], now: Instant) -> Vec<(String, f64)> {
144    if entries.is_empty() {
145        return Vec::new();
146    }
147
148    let n = entries.len();
149
150    let mut recency_order: Vec<usize> = (0..n).collect();
151    recency_order.sort_by(|&a, &b| {
152        let elapsed_a = now
153            .checked_duration_since(entries[a].1.last_access)
154            .unwrap_or_default()
155            .as_secs_f64();
156        let elapsed_b = now
157            .checked_duration_since(entries[b].1.last_access)
158            .unwrap_or_default()
159            .as_secs_f64();
160        elapsed_a
161            .partial_cmp(&elapsed_b)
162            .unwrap_or(std::cmp::Ordering::Equal)
163    });
164
165    let mut frequency_order: Vec<usize> = (0..n).collect();
166    frequency_order.sort_by(|&a, &b| entries[b].1.read_count.cmp(&entries[a].1.read_count));
167
168    let mut size_order: Vec<usize> = (0..n).collect();
169    size_order.sort_by(|&a, &b| {
170        entries[b]
171            .1
172            .original_tokens
173            .cmp(&entries[a].1.original_tokens)
174    });
175
176    let mut recency_ranks = vec![0usize; n];
177    let mut frequency_ranks = vec![0usize; n];
178    let mut size_ranks = vec![0usize; n];
179
180    for (rank, &idx) in recency_order.iter().enumerate() {
181        recency_ranks[idx] = rank;
182    }
183    for (rank, &idx) in frequency_order.iter().enumerate() {
184        frequency_ranks[idx] = rank;
185    }
186    for (rank, &idx) in size_order.iter().enumerate() {
187        size_ranks[idx] = rank;
188    }
189
190    entries
191        .iter()
192        .enumerate()
193        .map(|(i, (path, _))| {
194            let score = 1.0 / (RRF_K + recency_ranks[i] as f64)
195                + 1.0 / (RRF_K + frequency_ranks[i] as f64)
196                + 1.0 / (RRF_K + size_ranks[i] as f64);
197            ((*path).clone(), score)
198        })
199        .collect()
200}
201
202/// Aggregated cache statistics: hits, reads, and token savings.
203#[derive(Debug)]
204pub struct CacheStats {
205    pub total_reads: u64,
206    pub cache_hits: u64,
207    pub total_original_tokens: u64,
208    pub total_sent_tokens: u64,
209    pub files_tracked: usize,
210}
211
212impl CacheStats {
213    /// Returns the cache hit rate as a percentage (0–100).
214    pub fn hit_rate(&self) -> f64 {
215        if self.total_reads == 0 {
216            return 0.0;
217        }
218        (self.cache_hits as f64 / self.total_reads as f64) * 100.0
219    }
220
221    /// Returns the total number of tokens saved by cache hits.
222    pub fn tokens_saved(&self) -> u64 {
223        self.total_original_tokens
224            .saturating_sub(self.total_sent_tokens)
225    }
226
227    /// Returns the savings as a percentage of total original tokens.
228    pub fn savings_percent(&self) -> f64 {
229        if self.total_original_tokens == 0 {
230            return 0.0;
231        }
232        (self.tokens_saved() as f64 / self.total_original_tokens as f64) * 100.0
233    }
234}
235
236/// A block shared across multiple files, identified by its canonical source.
237#[derive(Clone, Debug)]
238pub struct SharedBlock {
239    pub canonical_path: String,
240    pub canonical_ref: String,
241    pub start_line: usize,
242    pub end_line: usize,
243    pub content: String,
244}
245
246/// In-memory file cache with segmented LRU eviction (probationary vs protected),
247/// file references, and cross-file dedup.
248pub struct SessionCache {
249    entries: HashMap<String, CacheEntry>,
250    file_refs: HashMap<String, String>,
251    next_ref: usize,
252    stats: CacheStats,
253    shared_blocks: Vec<SharedBlock>,
254}
255
256impl Default for SessionCache {
257    fn default() -> Self {
258        Self::new()
259    }
260}
261
262impl SessionCache {
263    /// Creates an empty session cache with default stats.
264    pub fn new() -> Self {
265        Self {
266            entries: HashMap::new(),
267            file_refs: HashMap::new(),
268            next_ref: 1,
269            shared_blocks: Vec::new(),
270            stats: CacheStats {
271                total_reads: 0,
272                cache_hits: 0,
273                total_original_tokens: 0,
274                total_sent_tokens: 0,
275                files_tracked: 0,
276            },
277        }
278    }
279
280    /// Returns or assigns a short file reference label (F1, F2, ...) for the given path.
281    pub fn get_file_ref(&mut self, path: &str) -> String {
282        let key = normalize_key(path);
283        if let Some(r) = self.file_refs.get(&key) {
284            return r.clone();
285        }
286        let r = format!("F{}", self.next_ref);
287        self.next_ref += 1;
288        self.file_refs.insert(key, r.clone());
289        r
290    }
291
292    /// Returns the file reference label for a path without assigning a new one.
293    pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
294        self.file_refs.get(&normalize_key(path)).cloned()
295    }
296
297    /// Looks up a cached entry by file path.
298    pub fn get(&self, path: &str) -> Option<&CacheEntry> {
299        self.entries.get(&normalize_key(path))
300    }
301
302    /// Mutable lookup of a cached entry by file path.
303    pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
304        self.entries.get_mut(&normalize_key(path))
305    }
306
307    /// Retrieves the full (uncompressed) content for a file path, if cached.
308    /// Used by the CCR (Compress-Cache-Retrieve) mechanism.
309    pub fn get_full_content(&self, path: &str) -> Option<String> {
310        self.entries
311            .get(&normalize_key(path))
312            .and_then(CacheEntry::content)
313    }
314
315    /// Records a cache hit, updates access stats, and emits a cache-hit event.
316    pub fn record_cache_hit(&mut self, path: &str) -> Option<&CacheEntry> {
317        let key = normalize_key(path);
318        let ref_label = self
319            .file_refs
320            .get(&key)
321            .cloned()
322            .unwrap_or_else(|| "F?".to_string());
323        if let Some(entry) = self.entries.get_mut(&key) {
324            entry.read_count += 1;
325            entry.last_access = Instant::now();
326            self.stats.total_reads += 1;
327            self.stats.cache_hits += 1;
328            self.stats.total_original_tokens += entry.original_tokens as u64;
329            let hit_msg = format!(
330                "{ref_label} cached {}t {}L",
331                entry.read_count, entry.line_count
332            );
333            self.stats.total_sent_tokens += count_tokens(&hit_msg) as u64;
334            crate::core::events::emit_cache_hit(path, entry.original_tokens as u64);
335            Some(entry)
336        } else {
337            None
338        }
339    }
340
341    /// Stores file content in the cache; returns a hit if content hash matches.
342    pub fn store(&mut self, path: &str, content: &str) -> StoreResult {
343        let key = normalize_key(path);
344        let hash = compute_md5(content);
345        let line_count = content.lines().count();
346        let original_tokens = count_tokens(content);
347        let stored_mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
348        let now = Instant::now();
349
350        self.stats.total_reads += 1;
351        self.stats.total_original_tokens += original_tokens as u64;
352
353        if let Some(existing) = self.entries.get_mut(&key) {
354            existing.last_access = now;
355            if stored_mtime.is_some() {
356                existing.stored_mtime = stored_mtime;
357            }
358            if existing.hash == hash {
359                existing.read_count += 1;
360                self.stats.cache_hits += 1;
361                let hit_msg = format!(
362                    "{} cached {}t {}L",
363                    self.file_refs.get(&key).unwrap_or(&"F?".to_string()),
364                    existing.read_count,
365                    existing.line_count,
366                );
367                self.stats.total_sent_tokens += count_tokens(&hit_msg) as u64;
368                return StoreResult {
369                    line_count: existing.line_count,
370                    original_tokens: existing.original_tokens,
371                    read_count: existing.read_count,
372                    was_hit: true,
373                    full_content_delivered: existing.full_content_delivered,
374                };
375            }
376            existing.compressed_outputs.clear();
377            existing.set_content(content);
378            existing.hash = hash;
379            existing.line_count = line_count;
380            existing.original_tokens = original_tokens;
381            existing.read_count += 1;
382            existing.full_content_delivered = false;
383            if stored_mtime.is_some() {
384                existing.stored_mtime = stored_mtime;
385            }
386            self.stats.total_sent_tokens += original_tokens as u64;
387            return StoreResult {
388                line_count,
389                original_tokens,
390                read_count: existing.read_count,
391                was_hit: false,
392                full_content_delivered: false,
393            };
394        }
395
396        self.evict_if_needed(original_tokens);
397        self.get_file_ref(&key);
398
399        let entry = CacheEntry::new(
400            content,
401            hash,
402            line_count,
403            original_tokens,
404            key.clone(),
405            stored_mtime,
406        );
407
408        self.entries.insert(key, entry);
409        self.stats.files_tracked += 1;
410        self.stats.total_sent_tokens += original_tokens as u64;
411        StoreResult {
412            line_count,
413            original_tokens,
414            read_count: 1,
415            was_hit: false,
416            full_content_delivered: false,
417        }
418    }
419
420    /// Returns the sum of original token counts across all cached entries.
421    pub fn total_cached_tokens(&self) -> usize {
422        self.entries.values().map(|e| e.original_tokens).sum()
423    }
424
425    /// Evict until cache fits within token budget using RRF (Reciprocal Rank Fusion).
426    /// Combines recency, frequency, and size signals to evict least-valuable entries first.
427    pub fn evict_if_needed(&mut self, incoming_tokens: usize) {
428        let max_tokens = max_cache_tokens();
429        let current = self.total_cached_tokens();
430        if current + incoming_tokens <= max_tokens {
431            return;
432        }
433
434        let now = Instant::now();
435        let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
436        let mut scores = eviction_scores_rrf(&all, now);
437        // Sort ascending: lowest RRF score = least valuable = evict first
438        scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
439
440        let mut freed = 0usize;
441        let target = (current + incoming_tokens).saturating_sub(max_tokens);
442
443        for (path, _score) in &scores {
444            if freed >= target {
445                break;
446            }
447            if let Some(entry) = self.entries.remove(path) {
448                freed += entry.original_tokens;
449                self.file_refs.remove(path);
450            }
451        }
452    }
453
454    /// Returns all cached entries as (path, entry) pairs.
455    pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
456        self.entries.iter().collect()
457    }
458
459    /// Returns a reference to the aggregated cache statistics.
460    pub fn get_stats(&self) -> &CacheStats {
461        &self.stats
462    }
463
464    /// Returns the path-to-file-ref mapping (e.g. "/src/main.rs" → "F1").
465    pub fn file_ref_map(&self) -> &HashMap<String, String> {
466        &self.file_refs
467    }
468
469    /// Replaces the cross-file shared blocks used for deduplication.
470    pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
471        self.shared_blocks = blocks;
472    }
473
474    /// Returns the current set of cross-file shared blocks.
475    pub fn get_shared_blocks(&self) -> &[SharedBlock] {
476        &self.shared_blocks
477    }
478
479    /// Replace shared blocks in content with cross-file references.
480    pub fn apply_dedup(&self, path: &str, content: &str) -> Option<String> {
481        if self.shared_blocks.is_empty() {
482            return None;
483        }
484        let refs: Vec<&SharedBlock> = self
485            .shared_blocks
486            .iter()
487            .filter(|b| b.canonical_path != path && content.contains(&b.content))
488            .collect();
489        if refs.is_empty() {
490            return None;
491        }
492        let mut result = content.to_string();
493        for block in refs {
494            result = result.replacen(
495                &block.content,
496                &format!(
497                    "[= {}:{}-{}]",
498                    block.canonical_ref, block.start_line, block.end_line
499                ),
500                1,
501            );
502        }
503        Some(result)
504    }
505
506    /// Removes a file from the cache, forcing a fresh read on next access.
507    pub fn invalidate(&mut self, path: &str) -> bool {
508        self.entries.remove(&normalize_key(path)).is_some()
509    }
510
511    /// Returns a cached compressed output for a given file and mode key.
512    pub fn get_compressed(&self, path: &str, mode_key: &str) -> Option<&String> {
513        self.entries
514            .get(&normalize_key(path))?
515            .get_compressed(mode_key)
516    }
517
518    /// Marks that full (uncompressed) content was delivered for this file.
519    pub fn mark_full_delivered(&mut self, path: &str) {
520        if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
521            entry.mark_full_delivered();
522        }
523    }
524
525    /// Stores a compressed output for a given file and mode key.
526    pub fn set_compressed(&mut self, path: &str, mode_key: &str, output: String) {
527        if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
528            entry.set_compressed(mode_key, output);
529        }
530    }
531
532    /// Resets `full_content_delivered` for all entries without removing them.
533    /// Used after host context compaction — forces re-delivery on next read
534    /// while preserving compressed content and file refs.
535    pub fn reset_delivery_flags(&mut self) -> usize {
536        let mut count = 0;
537        for entry in self.entries.values_mut() {
538            if entry.full_content_delivered {
539                entry.full_content_delivered = false;
540                count += 1;
541            }
542        }
543        count
544    }
545
546    /// Returns whether full content was previously delivered for this path.
547    pub fn is_full_delivered(&self, path: &str) -> bool {
548        self.entries
549            .get(&normalize_key(path))
550            .is_some_and(|e| e.full_content_delivered)
551    }
552
553    /// Clears all cached entries, file refs, and resets stats. Returns the number of entries removed.
554    pub fn clear(&mut self) -> usize {
555        let count = self.entries.len();
556        self.entries.clear();
557        self.file_refs.clear();
558        self.shared_blocks.clear();
559        self.next_ref = 1;
560        self.stats = CacheStats {
561            total_reads: 0,
562            cache_hits: 0,
563            total_original_tokens: 0,
564            total_sent_tokens: 0,
565            files_tracked: 0,
566        };
567        count
568    }
569}
570
571pub fn file_mtime(path: &str) -> Option<SystemTime> {
572    std::fs::metadata(path).and_then(|m| m.modified()).ok()
573}
574
575pub fn is_cache_entry_stale(path: &str, cached_mtime: Option<SystemTime>) -> bool {
576    let current = file_mtime(path);
577    match (cached_mtime, current) {
578        (_, None) | (None, Some(_)) => true,
579        (Some(cached), Some(current)) => current > cached,
580    }
581}
582
583fn compute_md5(content: &str) -> String {
584    let mut hasher = Md5::new();
585    hasher.update(content.as_bytes());
586    format!("{:x}", hasher.finalize())
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use std::time::Duration;
593
594    #[test]
595    fn cache_stores_and_retrieves() {
596        let mut cache = SessionCache::new();
597        let result = cache.store("/test/file.rs", "fn main() {}");
598        assert!(!result.was_hit);
599        assert_eq!(result.line_count, 1);
600        assert!(cache.get("/test/file.rs").is_some());
601    }
602
603    #[test]
604    fn cache_hit_on_same_content() {
605        let mut cache = SessionCache::new();
606        cache.store("/test/file.rs", "content");
607        let result = cache.store("/test/file.rs", "content");
608        assert!(result.was_hit, "same content should be a cache hit");
609    }
610
611    #[test]
612    fn cache_miss_on_changed_content() {
613        let mut cache = SessionCache::new();
614        cache.store("/test/file.rs", "old content");
615        let result = cache.store("/test/file.rs", "new content");
616        assert!(!result.was_hit, "changed content should not be a cache hit");
617    }
618
619    #[test]
620    fn file_refs_are_sequential() {
621        let mut cache = SessionCache::new();
622        assert_eq!(cache.get_file_ref("/a.rs"), "F1");
623        assert_eq!(cache.get_file_ref("/b.rs"), "F2");
624        assert_eq!(cache.get_file_ref("/a.rs"), "F1"); // stable
625    }
626
627    #[test]
628    fn cache_clear_resets_everything() {
629        let mut cache = SessionCache::new();
630        cache.store("/a.rs", "a");
631        cache.store("/b.rs", "b");
632        let count = cache.clear();
633        assert_eq!(count, 2);
634        assert!(cache.get("/a.rs").is_none());
635        assert_eq!(cache.get_file_ref("/c.rs"), "F1"); // refs reset
636    }
637
638    #[test]
639    fn cache_invalidate_removes_entry() {
640        let mut cache = SessionCache::new();
641        cache.store("/test.rs", "test");
642        assert!(cache.invalidate("/test.rs"));
643        assert!(!cache.invalidate("/nonexistent.rs"));
644    }
645
646    #[test]
647    fn cache_stats_track_correctly() {
648        let mut cache = SessionCache::new();
649        cache.store("/a.rs", "hello");
650        cache.store("/a.rs", "hello"); // hit
651        let stats = cache.get_stats();
652        assert_eq!(stats.total_reads, 2);
653        assert_eq!(stats.cache_hits, 1);
654        assert!(stats.hit_rate() > 0.0);
655    }
656
657    #[test]
658    fn md5_is_deterministic() {
659        let h1 = compute_md5("test content");
660        let h2 = compute_md5("test content");
661        assert_eq!(h1, h2);
662        assert_ne!(h1, compute_md5("different"));
663    }
664
665    #[test]
666    fn rrf_eviction_prefers_recent() {
667        let base = Instant::now();
668        std::thread::sleep(std::time::Duration::from_millis(5));
669        let now = Instant::now();
670        let key_a = "a.rs".to_string();
671        let key_b = "b.rs".to_string();
672        let recent = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
673        let old = {
674            let mut e = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
675            e.last_access = base;
676            e
677        };
678        let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &recent), (&key_b, &old)];
679        let scores = eviction_scores_rrf(&entries, now);
680        let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
681        let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
682        assert!(
683            score_a > score_b,
684            "recently accessed entries should score higher via RRF"
685        );
686    }
687
688    #[test]
689    fn rrf_eviction_prefers_frequent() {
690        let now = Instant::now();
691        let key_a = "a.rs".to_string();
692        let key_b = "b.rs".to_string();
693        let frequent = {
694            let mut e = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
695            e.read_count = 20;
696            e
697        };
698        let rare = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
699        let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &frequent), (&key_b, &rare)];
700        let scores = eviction_scores_rrf(&entries, now);
701        let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
702        let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
703        assert!(
704            score_a > score_b,
705            "frequently accessed entries should score higher via RRF"
706        );
707    }
708
709    #[test]
710    fn evict_if_needed_removes_lowest_score() {
711        std::env::set_var("LEAN_CTX_CACHE_MAX_TOKENS", "50");
712        let mut cache = SessionCache::new();
713        let big_content = "a]".repeat(30); // ~30 tokens
714        cache.store("/old.rs", &big_content);
715        // /old.rs now in cache with ~30 tokens
716
717        let new_content = "b ".repeat(30); // ~30 tokens incoming
718        cache.store("/new.rs", &new_content);
719        // should have evicted /old.rs to make room
720        // (total would be ~60 which exceeds 50)
721
722        // At least one should remain, total should be <= 50
723        assert!(
724            cache.total_cached_tokens() <= 60,
725            "eviction should have kicked in"
726        );
727        std::env::remove_var("LEAN_CTX_CACHE_MAX_TOKENS");
728    }
729
730    #[test]
731    fn stale_detection_flags_newer_file() {
732        let dir = tempfile::tempdir().unwrap();
733        let path = dir.path().join("stale.txt");
734        let p = path.to_string_lossy().to_string();
735
736        std::fs::write(&path, "one").unwrap();
737        let mut cache = SessionCache::new();
738        cache.store(&p, "one");
739
740        let entry = cache.get(&p).unwrap();
741        assert!(!is_cache_entry_stale(&p, entry.stored_mtime));
742
743        // Ensure mtime granularity differences don't make this flaky.
744        std::thread::sleep(Duration::from_secs(1));
745        std::fs::write(&path, "two").unwrap();
746
747        let entry = cache.get(&p).unwrap();
748        assert!(is_cache_entry_stale(&p, entry.stored_mtime));
749    }
750
751    #[test]
752    fn compressed_outputs_cached_and_retrieved() {
753        let mut cache = SessionCache::new();
754        cache.store("/test.rs", "fn main() {}");
755        cache.set_compressed("/test.rs", "map", "compressed map output".to_string());
756        assert_eq!(
757            cache.get_compressed("/test.rs", "map"),
758            Some(&"compressed map output".to_string())
759        );
760        assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
761    }
762
763    #[test]
764    fn compressed_outputs_cleared_on_content_change() {
765        let mut cache = SessionCache::new();
766        cache.store("/test.rs", "old content");
767        cache.set_compressed("/test.rs", "map", "old map".to_string());
768        assert!(cache.get_compressed("/test.rs", "map").is_some());
769
770        cache.store("/test.rs", "new content");
771        assert_eq!(cache.get_compressed("/test.rs", "map"), None);
772    }
773
774    #[test]
775    fn compressed_outputs_survive_same_content_store() {
776        let mut cache = SessionCache::new();
777        cache.store("/test.rs", "content");
778        cache.set_compressed("/test.rs", "map", "cached map".to_string());
779
780        let result = cache.store("/test.rs", "content");
781        assert!(result.was_hit);
782        assert_eq!(
783            cache.get_compressed("/test.rs", "map"),
784            Some(&"cached map".to_string())
785        );
786    }
787
788    #[test]
789    fn compressed_outputs_cleared_on_invalidate() {
790        let mut cache = SessionCache::new();
791        cache.store("/test.rs", "content");
792        cache.set_compressed("/test.rs", "signatures", "cached sigs".to_string());
793        cache.invalidate("/test.rs");
794        assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
795    }
796
797    #[test]
798    fn compressed_outputs_cleared_on_clear() {
799        let mut cache = SessionCache::new();
800        cache.store("/a.rs", "a");
801        cache.set_compressed("/a.rs", "map", "map_a".to_string());
802        cache.clear();
803        assert_eq!(cache.get_compressed("/a.rs", "map"), None);
804    }
805}