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
9fn 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
29fn max_cache_tokens() -> usize {
30 std::env::var("LEAN_CTX_CACHE_MAX_TOKENS")
31 .ok()
32 .and_then(|v| v.parse().ok())
33 .unwrap_or(500_000)
34}
35
36#[derive(Debug)]
42pub struct CacheEntry {
43 compressed_content: Vec<u8>,
44 pub hash: String,
45 pub line_count: usize,
46 pub original_tokens: usize,
47 read_count: AtomicU32,
48 pub path: String,
49 last_access: AtomicU64,
50 pub stored_mtime: Option<SystemTime>,
51 pub compressed_outputs: HashMap<String, String>,
53 pub full_content_delivered: bool,
56 pub last_mode: String,
58}
59
60const ZSTD_LEVEL: i32 = 3;
61
62fn zstd_compress(data: &str) -> Vec<u8> {
63 zstd::encode_all(data.as_bytes(), ZSTD_LEVEL).unwrap_or_else(|_| data.as_bytes().to_vec())
64}
65
66fn zstd_decompress(data: &[u8]) -> Option<String> {
67 zstd::decode_all(data)
68 .ok()
69 .and_then(|v| String::from_utf8(v).ok())
70}
71
72impl CacheEntry {
73 pub fn new(
75 content: &str,
76 hash: String,
77 line_count: usize,
78 original_tokens: usize,
79 path: String,
80 stored_mtime: Option<SystemTime>,
81 ) -> Self {
82 let compressed_content = zstd_compress(content);
83 Self {
84 compressed_content,
85 hash,
86 line_count,
87 original_tokens,
88 read_count: AtomicU32::new(1),
89 path,
90 last_access: AtomicU64::new(encode_instant(Instant::now())),
91 stored_mtime,
92 compressed_outputs: HashMap::new(),
93 full_content_delivered: false,
94 last_mode: String::new(),
95 }
96 }
97
98 pub fn read_count(&self) -> u32 {
100 self.read_count.load(Ordering::Relaxed)
101 }
102
103 pub fn bump_read_count(&self) -> u32 {
105 self.read_count.fetch_add(1, Ordering::Relaxed) + 1
106 }
107
108 pub fn set_read_count(&self, n: u32) {
110 self.read_count.store(n, Ordering::Relaxed);
111 }
112
113 pub fn last_access(&self) -> Instant {
115 decode_instant(self.last_access.load(Ordering::Relaxed))
116 }
117
118 pub fn touch(&self) {
120 self.last_access
121 .store(encode_instant(Instant::now()), Ordering::Relaxed);
122 }
123
124 pub fn set_last_access(&self, when: Instant) {
126 self.last_access
127 .store(encode_instant(when), Ordering::Relaxed);
128 }
129
130 pub fn content(&self) -> Option<String> {
132 zstd_decompress(&self.compressed_content)
133 }
134
135 pub fn set_content(&mut self, content: &str) {
137 self.compressed_content = zstd_compress(content);
138 }
139
140 pub fn compressed_size(&self) -> usize {
142 self.compressed_content.len()
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct StoreResult {
149 pub line_count: usize,
150 pub original_tokens: usize,
151 pub read_count: u32,
152 pub was_hit: bool,
153 pub full_content_delivered: bool,
155}
156
157impl CacheEntry {
158 pub fn eviction_score_legacy(&self, now: Instant) -> f64 {
160 let elapsed = now
161 .checked_duration_since(self.last_access())
162 .unwrap_or_default()
163 .as_secs_f64();
164 let recency = 1.0 / (1.0 + elapsed.sqrt());
165 let frequency = (self.read_count() as f64 + 1.0).ln();
166 let size_value = (self.original_tokens as f64 + 1.0).ln();
167 recency * 0.4 + frequency * 0.3 + size_value * 0.3
168 }
169
170 pub fn get_compressed(&self, mode_key: &str) -> Option<&String> {
171 self.compressed_outputs.get(mode_key)
172 }
173
174 pub fn set_compressed(&mut self, mode_key: &str, output: String) {
175 const MAX_COMPRESSED_VARIANTS: usize = 3;
176 if self.compressed_outputs.len() >= MAX_COMPRESSED_VARIANTS
177 && !self.compressed_outputs.contains_key(mode_key)
178 && let Some(oldest_key) = self.compressed_outputs.keys().next().cloned()
179 {
180 self.compressed_outputs.remove(&oldest_key);
181 }
182 self.compressed_outputs.insert(mode_key.to_string(), output);
183 }
184
185 pub fn mark_full_delivered(&mut self) {
186 self.full_content_delivered = true;
187 }
188}
189
190const RRF_K: f64 = 60.0;
191
192pub fn eviction_scores_rrf(entries: &[(&String, &CacheEntry)], now: Instant) -> Vec<(String, f64)> {
197 if entries.is_empty() {
198 return Vec::new();
199 }
200
201 let n = entries.len();
202
203 let mut recency_order: Vec<usize> = (0..n).collect();
204 recency_order.sort_by(|&a, &b| {
205 let elapsed_a = now
206 .checked_duration_since(entries[a].1.last_access())
207 .unwrap_or_default()
208 .as_secs_f64();
209 let elapsed_b = now
210 .checked_duration_since(entries[b].1.last_access())
211 .unwrap_or_default()
212 .as_secs_f64();
213 elapsed_a
214 .partial_cmp(&elapsed_b)
215 .unwrap_or(std::cmp::Ordering::Equal)
216 });
217
218 let mut frequency_order: Vec<usize> = (0..n).collect();
219 frequency_order.sort_by(|&a, &b| entries[b].1.read_count().cmp(&entries[a].1.read_count()));
220
221 let mut size_order: Vec<usize> = (0..n).collect();
222 size_order.sort_by(|&a, &b| {
223 entries[b]
224 .1
225 .original_tokens
226 .cmp(&entries[a].1.original_tokens)
227 });
228
229 let mut recency_ranks = vec![0usize; n];
230 let mut frequency_ranks = vec![0usize; n];
231 let mut size_ranks = vec![0usize; n];
232
233 for (rank, &idx) in recency_order.iter().enumerate() {
234 recency_ranks[idx] = rank;
235 }
236 for (rank, &idx) in frequency_order.iter().enumerate() {
237 frequency_ranks[idx] = rank;
238 }
239 for (rank, &idx) in size_order.iter().enumerate() {
240 size_ranks[idx] = rank;
241 }
242
243 entries
244 .iter()
245 .enumerate()
246 .map(|(i, (path, _))| {
247 let score = 1.0 / (RRF_K + recency_ranks[i] as f64)
248 + 1.0 / (RRF_K + frequency_ranks[i] as f64)
249 + 1.0 / (RRF_K + size_ranks[i] as f64);
250 ((*path).clone(), score)
251 })
252 .collect()
253}
254
255#[derive(Debug, Default)]
260pub struct CacheStats {
261 total_reads: AtomicU64,
262 cache_hits: AtomicU64,
263 total_original_tokens: AtomicU64,
264 total_sent_tokens: AtomicU64,
265 files_tracked: AtomicU64,
266}
267
268impl CacheStats {
269 pub fn total_reads(&self) -> u64 {
271 self.total_reads.load(Ordering::Relaxed)
272 }
273
274 pub fn cache_hits(&self) -> u64 {
276 self.cache_hits.load(Ordering::Relaxed)
277 }
278
279 pub fn total_original_tokens(&self) -> u64 {
281 self.total_original_tokens.load(Ordering::Relaxed)
282 }
283
284 pub fn total_sent_tokens(&self) -> u64 {
286 self.total_sent_tokens.load(Ordering::Relaxed)
287 }
288
289 pub fn files_tracked(&self) -> u64 {
291 self.files_tracked.load(Ordering::Relaxed)
292 }
293
294 pub fn hit_rate(&self) -> f64 {
296 let total = self.total_reads();
297 if total == 0 {
298 return 0.0;
299 }
300 (self.cache_hits() as f64 / total as f64) * 100.0
301 }
302
303 pub fn tokens_saved(&self) -> u64 {
305 self.total_original_tokens()
306 .saturating_sub(self.total_sent_tokens())
307 }
308
309 pub fn savings_percent(&self) -> f64 {
311 let original = self.total_original_tokens();
312 if original == 0 {
313 return 0.0;
314 }
315 (self.tokens_saved() as f64 / original as f64) * 100.0
316 }
317}
318
319#[derive(Clone, Debug)]
321pub struct SharedBlock {
322 pub canonical_path: String,
323 pub canonical_ref: String,
324 pub start_line: usize,
325 pub end_line: usize,
326 pub content: String,
327}
328
329pub struct SessionCache {
332 entries: HashMap<String, CacheEntry>,
333 file_refs: HashMap<String, String>,
334 next_ref: usize,
335 stats: CacheStats,
336 shared_blocks: Vec<SharedBlock>,
337}
338
339impl Default for SessionCache {
340 fn default() -> Self {
341 Self::new()
342 }
343}
344
345impl SessionCache {
346 pub fn new() -> Self {
348 Self {
349 entries: HashMap::new(),
350 file_refs: HashMap::new(),
351 next_ref: 1,
352 shared_blocks: Vec::new(),
353 stats: CacheStats::default(),
354 }
355 }
356
357 pub fn get_file_ref(&mut self, path: &str) -> String {
359 let key = normalize_key(path);
360 if let Some(r) = self.file_refs.get(&key) {
361 return r.clone();
362 }
363 let r = format!("F{}", self.next_ref);
364 self.next_ref += 1;
365 self.file_refs.insert(key, r.clone());
366 r
367 }
368
369 pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
371 self.file_refs.get(&normalize_key(path)).cloned()
372 }
373
374 pub fn get(&self, path: &str) -> Option<&CacheEntry> {
376 self.entries.get(&normalize_key(path))
377 }
378
379 pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
381 self.entries.get_mut(&normalize_key(path))
382 }
383
384 pub fn get_full_content(&self, path: &str) -> Option<String> {
387 self.entries
388 .get(&normalize_key(path))
389 .and_then(CacheEntry::content)
390 }
391
392 pub fn current_full_content(&self, path: &str) -> Option<(String, usize)> {
405 let entry = self.entries.get(&normalize_key(path))?;
406 if is_cache_entry_stale_verified(&entry.path, entry.stored_mtime, &entry.hash)
407 && let Ok(fresh) = crate::core::io_boundary::read_file_lossy(&entry.path)
408 {
409 let tokens = count_tokens(&fresh);
414 return Some((fresh, tokens));
415 }
416 Some((entry.content()?, entry.original_tokens))
417 }
418
419 pub fn record_cache_hit(&self, path: &str) -> Option<&CacheEntry> {
425 let key = normalize_key(path);
426 let ref_label = self
427 .file_refs
428 .get(&key)
429 .cloned()
430 .unwrap_or_else(|| "F?".to_string());
431 let entry = self.entries.get(&key)?;
432 let new_count = entry.bump_read_count();
433 entry.touch();
434 self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
435 self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
436 self.stats
437 .total_original_tokens
438 .fetch_add(entry.original_tokens as u64, Ordering::Relaxed);
439 let hit_msg = format!("{ref_label} cached {new_count}t {}L", entry.line_count);
440 self.stats
441 .total_sent_tokens
442 .fetch_add(count_tokens(&hit_msg) as u64, Ordering::Relaxed);
443 crate::core::events::emit_cache_hit(path, entry.original_tokens as u64);
444 Some(entry)
445 }
446
447 pub fn store(&mut self, path: &str, content: &str) -> StoreResult {
449 let key = normalize_key(path);
450 let hash = compute_md5(content);
451 let line_count = content.lines().count();
452 let original_tokens = count_tokens(content);
453 let stored_mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
454 let now = Instant::now();
455
456 self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
457 self.stats
458 .total_original_tokens
459 .fetch_add(original_tokens as u64, Ordering::Relaxed);
460
461 if let Some(existing) = self.entries.get_mut(&key) {
462 existing.set_last_access(now);
463 if stored_mtime.is_some() {
464 existing.stored_mtime = stored_mtime;
465 }
466 if existing.hash == hash {
467 let new_count = existing.bump_read_count();
468 self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
469 let hit_msg = format!(
470 "{} cached {new_count}t {}L",
471 self.file_refs.get(&key).unwrap_or(&"F?".to_string()),
472 existing.line_count,
473 );
474 self.stats
475 .total_sent_tokens
476 .fetch_add(count_tokens(&hit_msg) as u64, Ordering::Relaxed);
477 return StoreResult {
478 line_count: existing.line_count,
479 original_tokens: existing.original_tokens,
480 read_count: new_count,
481 was_hit: true,
482 full_content_delivered: existing.full_content_delivered,
483 };
484 }
485 existing.compressed_outputs.clear();
486 existing.set_content(content);
487 existing.hash = hash;
488 existing.line_count = line_count;
489 existing.original_tokens = original_tokens;
490 let new_count = existing.bump_read_count();
491 existing.full_content_delivered = false;
492 if stored_mtime.is_some() {
493 existing.stored_mtime = stored_mtime;
494 }
495 self.stats
496 .total_sent_tokens
497 .fetch_add(original_tokens as u64, Ordering::Relaxed);
498 return StoreResult {
499 line_count,
500 original_tokens,
501 read_count: new_count,
502 was_hit: false,
503 full_content_delivered: false,
504 };
505 }
506
507 self.evict_if_needed(original_tokens);
508 self.get_file_ref(&key);
509
510 let entry = CacheEntry::new(
511 content,
512 hash,
513 line_count,
514 original_tokens,
515 key.clone(),
516 stored_mtime,
517 );
518
519 self.entries.insert(key, entry);
520 self.stats.files_tracked.fetch_add(1, Ordering::Relaxed);
521 self.stats
522 .total_sent_tokens
523 .fetch_add(original_tokens as u64, Ordering::Relaxed);
524 StoreResult {
525 line_count,
526 original_tokens,
527 read_count: 1,
528 was_hit: false,
529 full_content_delivered: false,
530 }
531 }
532
533 pub fn total_cached_tokens(&self) -> usize {
535 self.entries.values().map(|e| e.original_tokens).sum()
536 }
537
538 pub fn evict_if_needed(&mut self, incoming_tokens: usize) {
541 let max_tokens = max_cache_tokens();
542 let current = self.total_cached_tokens();
543 if current + incoming_tokens <= max_tokens {
544 return;
545 }
546
547 let now = Instant::now();
548 let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
549 let mut scores = eviction_scores_rrf(&all, now);
550 scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
552
553 let mut freed = 0usize;
554 let target = (current + incoming_tokens).saturating_sub(max_tokens);
555
556 for (path, _score) in &scores {
557 if freed >= target {
558 break;
559 }
560 if let Some(entry) = self.entries.remove(path) {
561 freed += entry.original_tokens;
562 self.file_refs.remove(path);
563 }
564 }
565 }
566
567 pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
569 self.entries.iter().collect()
570 }
571
572 pub fn get_stats(&self) -> &CacheStats {
574 &self.stats
575 }
576
577 pub fn file_ref_map(&self) -> &HashMap<String, String> {
579 &self.file_refs
580 }
581
582 pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
584 self.shared_blocks = blocks;
585 }
586
587 pub fn get_shared_blocks(&self) -> &[SharedBlock] {
589 &self.shared_blocks
590 }
591
592 pub fn apply_dedup(&self, path: &str, content: &str) -> Option<String> {
594 if self.shared_blocks.is_empty() {
595 return None;
596 }
597 let refs: Vec<&SharedBlock> = self
598 .shared_blocks
599 .iter()
600 .filter(|b| b.canonical_path != path && content.contains(&b.content))
601 .collect();
602 if refs.is_empty() {
603 return None;
604 }
605 let mut result = content.to_string();
606 for block in refs {
607 result = result.replacen(
608 &block.content,
609 &format!(
610 "[= {}:{}-{}]",
611 block.canonical_ref, block.start_line, block.end_line
612 ),
613 1,
614 );
615 }
616 Some(result)
617 }
618
619 pub fn invalidate(&mut self, path: &str) -> bool {
621 self.entries.remove(&normalize_key(path)).is_some()
622 }
623
624 pub fn get_compressed(&self, path: &str, mode_key: &str) -> Option<&String> {
626 self.entries
627 .get(&normalize_key(path))?
628 .get_compressed(mode_key)
629 }
630
631 pub fn mark_full_delivered(&mut self, path: &str) {
633 if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
634 entry.mark_full_delivered();
635 }
636 }
637
638 pub fn set_compressed(&mut self, path: &str, mode_key: &str, output: String) {
640 if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
641 entry.set_compressed(mode_key, output);
642 }
643 }
644
645 pub fn reset_delivery_flags(&mut self) -> usize {
649 let mut count = 0;
650 for entry in self.entries.values_mut() {
651 if entry.full_content_delivered {
652 entry.full_content_delivered = false;
653 count += 1;
654 }
655 }
656 count
657 }
658
659 pub fn is_full_delivered(&self, path: &str) -> bool {
661 self.entries
662 .get(&normalize_key(path))
663 .is_some_and(|e| e.full_content_delivered)
664 }
665
666 pub fn trim_compressed_outputs(&mut self) -> usize {
669 let mut trimmed = 0;
670 for entry in self.entries.values_mut() {
671 if !entry.compressed_outputs.is_empty() {
672 entry.compressed_outputs.clear();
673 trimmed += 1;
674 }
675 }
676 trimmed
677 }
678
679 pub fn evict_probationary(&mut self) -> usize {
682 let to_remove: Vec<String> = self
683 .entries
684 .iter()
685 .filter(|(_, e)| e.read_count() <= 1)
686 .map(|(k, _)| k.clone())
687 .collect();
688 let count = to_remove.len();
689 for key in &to_remove {
690 self.entries.remove(key);
691 self.file_refs.remove(key);
692 }
693 count
694 }
695
696 pub fn evict_to_budget(&mut self, target_tokens: usize) {
698 let current = self.total_cached_tokens();
699 if current <= target_tokens {
700 return;
701 }
702 let now = Instant::now();
703 let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
704 let mut scores = eviction_scores_rrf(&all, now);
705 scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
706
707 let mut freed = 0usize;
708 let target_free = current.saturating_sub(target_tokens);
709 for (path, _score) in &scores {
710 if freed >= target_free {
711 break;
712 }
713 if let Some(entry) = self.entries.remove(path) {
714 freed += entry.original_tokens;
715 self.file_refs.remove(path);
716 }
717 }
718 }
719
720 pub fn approximate_bytes(&self) -> usize {
722 let entries_bytes: usize = self
723 .entries
724 .values()
725 .map(|e| {
726 e.compressed_content.len()
727 + e.hash.len()
728 + e.path.len()
729 + e.compressed_outputs
730 .iter()
731 .map(|(k, v)| k.len() + v.len())
732 .sum::<usize>()
733 + 128 })
735 .sum();
736 let refs_bytes: usize = self.file_refs.iter().map(|(k, v)| k.len() + v.len()).sum();
737 let blocks_bytes: usize = self
738 .shared_blocks
739 .iter()
740 .map(|b| b.canonical_path.len() + b.canonical_ref.len() + b.content.len() + 32)
741 .sum();
742 entries_bytes + refs_bytes + blocks_bytes
743 }
744
745 const MAX_SHARED_BLOCKS: usize = 100;
746
747 pub fn trim_shared_blocks(&mut self) {
749 if self.shared_blocks.len() > Self::MAX_SHARED_BLOCKS {
750 let excess = self.shared_blocks.len() - Self::MAX_SHARED_BLOCKS;
751 self.shared_blocks.drain(..excess);
752 }
753 }
754
755 pub fn clear(&mut self) -> usize {
757 let count = self.entries.len();
758 self.entries.clear();
759 self.file_refs.clear();
760 self.shared_blocks.clear();
761 self.next_ref = 1;
762 self.stats = CacheStats::default();
763 count
764 }
765}
766
767pub fn file_mtime(path: &str) -> Option<SystemTime> {
768 std::fs::metadata(path).and_then(|m| m.modified()).ok()
769}
770
771pub fn is_cache_entry_stale(path: &str, cached_mtime: Option<SystemTime>) -> bool {
772 let current = file_mtime(path);
773 match (cached_mtime, current) {
774 (None, None) => false,
776 (Some(_), None) | (None, Some(_)) => true,
778 (Some(cached), Some(current)) => current != cached,
781 }
782}
783
784const VERIFY_HASH_CAP_BYTES: u64 = 8 * 1024 * 1024;
787
788fn cache_verify_enabled() -> bool {
789 std::env::var("LEAN_CTX_CACHE_VERIFY").map_or(true, |v| v != "0")
790}
791
792pub fn is_cache_entry_stale_verified(
807 path: &str,
808 cached_mtime: Option<SystemTime>,
809 cached_hash: &str,
810) -> bool {
811 if is_cache_entry_stale(path, cached_mtime) {
812 return true;
813 }
814 if cached_hash.is_empty() || !cache_verify_enabled() {
815 return false;
816 }
817 let Ok(meta) = std::fs::metadata(path) else {
818 return true;
820 };
821 if meta.len() > VERIFY_HASH_CAP_BYTES {
822 return false;
823 }
824 match std::fs::read(path) {
825 Ok(bytes) => compute_md5(&String::from_utf8_lossy(&bytes)) != cached_hash,
827 Err(_) => true,
828 }
829}
830
831fn compute_md5(content: &str) -> String {
832 let mut hasher = Md5::new();
833 hasher.update(content.as_bytes());
834 crate::core::agent_identity::hex_encode(&hasher.finalize())
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840 use std::time::Duration;
841
842 #[test]
843 fn cache_stores_and_retrieves() {
844 let mut cache = SessionCache::new();
845 let result = cache.store("/test/file.rs", "fn main() {}");
846 assert!(!result.was_hit);
847 assert_eq!(result.line_count, 1);
848 assert!(cache.get("/test/file.rs").is_some());
849 }
850
851 #[test]
852 fn cache_hit_on_same_content() {
853 let mut cache = SessionCache::new();
854 cache.store("/test/file.rs", "content");
855 let result = cache.store("/test/file.rs", "content");
856 assert!(result.was_hit, "same content should be a cache hit");
857 }
858
859 #[test]
860 fn cache_miss_on_changed_content() {
861 let mut cache = SessionCache::new();
862 cache.store("/test/file.rs", "old content");
863 let result = cache.store("/test/file.rs", "new content");
864 assert!(!result.was_hit, "changed content should not be a cache hit");
865 }
866
867 #[test]
868 fn file_refs_are_sequential() {
869 let mut cache = SessionCache::new();
870 assert_eq!(cache.get_file_ref("/a.rs"), "F1");
871 assert_eq!(cache.get_file_ref("/b.rs"), "F2");
872 assert_eq!(cache.get_file_ref("/a.rs"), "F1"); }
874
875 #[test]
876 fn cache_clear_resets_everything() {
877 let mut cache = SessionCache::new();
878 cache.store("/a.rs", "a");
879 cache.store("/b.rs", "b");
880 let count = cache.clear();
881 assert_eq!(count, 2);
882 assert!(cache.get("/a.rs").is_none());
883 assert_eq!(cache.get_file_ref("/c.rs"), "F1"); }
885
886 #[test]
887 fn cache_invalidate_removes_entry() {
888 let mut cache = SessionCache::new();
889 cache.store("/test.rs", "test");
890 assert!(cache.invalidate("/test.rs"));
891 assert!(!cache.invalidate("/nonexistent.rs"));
892 }
893
894 #[test]
895 fn cache_stats_track_correctly() {
896 let mut cache = SessionCache::new();
897 cache.store("/a.rs", "hello");
898 cache.store("/a.rs", "hello"); let stats = cache.get_stats();
900 assert_eq!(stats.total_reads(), 2);
901 assert_eq!(stats.cache_hits(), 1);
902 assert!(stats.hit_rate() > 0.0);
903 }
904
905 #[test]
906 fn current_full_content_serves_cached_when_fresh() {
907 let dir = tempfile::tempdir().unwrap();
908 let file = dir.path().join("handover.md");
909 std::fs::write(&file, "HANDOVER V1\n").unwrap();
910 let path = file.to_str().unwrap();
911
912 let mut cache = SessionCache::new();
913 cache.store(path, "HANDOVER V1\n");
914
915 let (content, tokens) = cache.current_full_content(path).unwrap();
916 assert_eq!(content, "HANDOVER V1\n");
917 assert!(tokens > 0);
918 }
919
920 #[test]
921 fn current_full_content_rereads_when_file_changed() {
922 let dir = tempfile::tempdir().unwrap();
925 let file = dir.path().join("handover.md");
926 std::fs::write(&file, "HANDOVER V1\n").unwrap();
927 let path = file.to_str().unwrap();
928
929 let mut cache = SessionCache::new();
930 cache.store(path, "HANDOVER V1\n");
931
932 std::thread::sleep(std::time::Duration::from_millis(10));
934 std::fs::write(&file, "HANDOVER V2 CHANGED\n").unwrap();
935
936 let (content, _) = cache.current_full_content(path).unwrap();
937 assert_eq!(
938 content, "HANDOVER V2 CHANGED\n",
939 "stale cached copy must be re-read from disk, not served as-is"
940 );
941 }
942
943 #[test]
944 fn current_full_content_none_without_entry() {
945 let cache = SessionCache::new();
946 assert!(cache.current_full_content("/no/such/file.rs").is_none());
947 }
948
949 #[test]
950 fn current_full_content_falls_back_to_cache_when_file_unreadable() {
951 let dir = tempfile::tempdir().unwrap();
956 let canon = dir.path().canonicalize().unwrap();
957 let file = canon.join("gone.md");
958 std::fs::write(&file, "ORIGINAL\n").unwrap();
959 let path = file.to_str().unwrap().to_string();
960
961 let mut cache = SessionCache::new();
962 cache.store(&path, "ORIGINAL\n");
963 std::fs::remove_file(&file).unwrap();
964
965 let (content, _) = cache.current_full_content(&path).unwrap();
966 assert_eq!(
967 content, "ORIGINAL\n",
968 "unreadable file must fall back to last-known cached content"
969 );
970 }
971
972 #[test]
973 fn record_cache_hit_works_through_shared_ref() {
974 let mut cache = SessionCache::new();
975 cache.store("/x.rs", "hello world");
976 let shared: &SessionCache = &cache;
978 assert!(shared.record_cache_hit("/x.rs").is_some());
979 assert!(shared.record_cache_hit("/x.rs").is_some());
980 assert_eq!(cache.get("/x.rs").unwrap().read_count(), 3);
982 assert_eq!(cache.get_stats().cache_hits(), 2);
983 }
984
985 #[test]
986 fn concurrent_cache_hits_are_lossless() {
987 use std::sync::Arc;
988 let mut cache = SessionCache::new();
989 cache.store("/a.rs", "a");
990 cache.store("/b.rs", "b");
991 let cache = Arc::new(cache);
994 let threads = 8;
995 let iters = 1_000;
996 let handles: Vec<_> = (0..threads)
997 .map(|_| {
998 let c = Arc::clone(&cache);
999 std::thread::spawn(move || {
1000 for _ in 0..iters {
1001 c.record_cache_hit("/a.rs");
1002 c.record_cache_hit("/b.rs");
1003 }
1004 })
1005 })
1006 .collect();
1007 for h in handles {
1008 h.join().unwrap();
1009 }
1010 let total = (threads * iters) as u64;
1011 assert_eq!(cache.get_stats().cache_hits(), total * 2);
1012 assert_eq!(cache.get("/a.rs").unwrap().read_count(), 1 + total as u32);
1013 assert_eq!(cache.get("/b.rs").unwrap().read_count(), 1 + total as u32);
1014 }
1015
1016 #[test]
1017 fn md5_is_deterministic() {
1018 let h1 = compute_md5("test content");
1019 let h2 = compute_md5("test content");
1020 assert_eq!(h1, h2);
1021 assert_ne!(h1, compute_md5("different"));
1022 }
1023
1024 #[test]
1025 fn rrf_eviction_prefers_recent() {
1026 let key_a = "a.rs".to_string();
1027 let key_b = "b.rs".to_string();
1028 let recent = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
1031 let old = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
1032 let t_old = Instant::now();
1033 std::thread::sleep(std::time::Duration::from_millis(10));
1034 let t_recent = Instant::now();
1035 old.set_last_access(t_old);
1036 recent.set_last_access(t_recent);
1037 let now = Instant::now();
1038 let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &recent), (&key_b, &old)];
1039 let scores = eviction_scores_rrf(&entries, now);
1040 let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
1041 let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
1042 assert!(
1043 score_a > score_b,
1044 "recently accessed entries should score higher via RRF"
1045 );
1046 }
1047
1048 #[test]
1049 fn rrf_eviction_prefers_frequent() {
1050 let now = Instant::now();
1051 let key_a = "a.rs".to_string();
1052 let key_b = "b.rs".to_string();
1053 let frequent = {
1054 let e = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
1055 e.set_read_count(20);
1056 e
1057 };
1058 let rare = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
1059 let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &frequent), (&key_b, &rare)];
1060 let scores = eviction_scores_rrf(&entries, now);
1061 let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
1062 let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
1063 assert!(
1064 score_a > score_b,
1065 "frequently accessed entries should score higher via RRF"
1066 );
1067 }
1068
1069 #[test]
1070 fn evict_if_needed_removes_lowest_score() {
1071 crate::test_env::set_var("LEAN_CTX_CACHE_MAX_TOKENS", "50");
1072 let mut cache = SessionCache::new();
1073 let big_content = "a]".repeat(30); cache.store("/old.rs", &big_content);
1075 let new_content = "b ".repeat(30); cache.store("/new.rs", &new_content);
1079 assert!(
1084 cache.total_cached_tokens() <= 60,
1085 "eviction should have kicked in"
1086 );
1087 crate::test_env::remove_var("LEAN_CTX_CACHE_MAX_TOKENS");
1088 }
1089
1090 #[test]
1091 fn stale_detection_flags_newer_file() {
1092 let dir = tempfile::tempdir().unwrap();
1093 let path = dir.path().join("stale.txt");
1094 let p = path.to_string_lossy().to_string();
1095
1096 std::fs::write(&path, "one").unwrap();
1097 let mut cache = SessionCache::new();
1098 cache.store(&p, "one");
1099
1100 let entry = cache.get(&p).unwrap();
1101 assert!(!is_cache_entry_stale(&p, entry.stored_mtime));
1102
1103 std::thread::sleep(Duration::from_secs(1));
1105 std::fs::write(&path, "two").unwrap();
1106
1107 let entry = cache.get(&p).unwrap();
1108 assert!(is_cache_entry_stale(&p, entry.stored_mtime));
1109 }
1110
1111 #[test]
1113 fn stale_detection_flags_backward_mtime() {
1114 let dir = tempfile::tempdir().unwrap();
1115 let path = dir.path().join("backward.txt");
1116 let p = path.to_string_lossy().to_string();
1117
1118 std::fs::write(&path, "one").unwrap();
1119 let mut cache = SessionCache::new();
1120 cache.store(&p, "one");
1121 let entry_mtime = cache.get(&p).unwrap().stored_mtime;
1122 assert!(!is_cache_entry_stale(&p, entry_mtime));
1123
1124 std::fs::write(&path, "zero").unwrap();
1126 let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
1127 f.set_modified(SystemTime::now() - Duration::from_hours(1))
1128 .unwrap();
1129 drop(f);
1130
1131 assert!(
1132 is_cache_entry_stale(&p, entry_mtime),
1133 "older mtime must read as stale"
1134 );
1135 }
1136
1137 #[test]
1140 fn verified_staleness_catches_same_mtime_content_change() {
1141 let dir = tempfile::tempdir().unwrap();
1142 let path = dir.path().join("sneaky.txt");
1143 let p = path.to_string_lossy().to_string();
1144
1145 std::fs::write(&path, "one").unwrap();
1146 let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
1147 let mut cache = SessionCache::new();
1148 cache.store(&p, "one");
1149 let (mtime, hash) = {
1150 let e = cache.get(&p).unwrap();
1151 (e.stored_mtime, e.hash.clone())
1152 };
1153
1154 assert!(!is_cache_entry_stale_verified(&p, mtime, &hash));
1156
1157 std::fs::write(&path, "two").unwrap();
1159 let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
1160 f.set_modified(original_mtime).unwrap();
1161 drop(f);
1162
1163 assert!(
1164 !is_cache_entry_stale(&p, mtime),
1165 "test premise: the mtime check alone is fooled"
1166 );
1167 assert!(
1168 is_cache_entry_stale_verified(&p, mtime, &hash),
1169 "hash verification must catch the change"
1170 );
1171 }
1172
1173 #[test]
1174 fn verified_staleness_flags_unreadable_file() {
1175 let mut cache = SessionCache::new();
1176 cache.store("/nonexistent/file.rs", "content");
1177 let (mtime, hash) = {
1178 let e = cache.get("/nonexistent/file.rs").unwrap();
1179 (e.stored_mtime, e.hash.clone())
1180 };
1181 assert!(is_cache_entry_stale_verified(
1182 "/nonexistent/file.rs",
1183 mtime,
1184 &hash
1185 ));
1186 }
1187
1188 #[test]
1189 fn compressed_outputs_cached_and_retrieved() {
1190 let mut cache = SessionCache::new();
1191 cache.store("/test.rs", "fn main() {}");
1192 cache.set_compressed("/test.rs", "map", "compressed map output".to_string());
1193 assert_eq!(
1194 cache.get_compressed("/test.rs", "map"),
1195 Some(&"compressed map output".to_string())
1196 );
1197 assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
1198 }
1199
1200 #[test]
1201 fn compressed_outputs_cleared_on_content_change() {
1202 let mut cache = SessionCache::new();
1203 cache.store("/test.rs", "old content");
1204 cache.set_compressed("/test.rs", "map", "old map".to_string());
1205 assert!(cache.get_compressed("/test.rs", "map").is_some());
1206
1207 cache.store("/test.rs", "new content");
1208 assert_eq!(cache.get_compressed("/test.rs", "map"), None);
1209 }
1210
1211 #[test]
1212 fn compressed_outputs_survive_same_content_store() {
1213 let mut cache = SessionCache::new();
1214 cache.store("/test.rs", "content");
1215 cache.set_compressed("/test.rs", "map", "cached map".to_string());
1216
1217 let result = cache.store("/test.rs", "content");
1218 assert!(result.was_hit);
1219 assert_eq!(
1220 cache.get_compressed("/test.rs", "map"),
1221 Some(&"cached map".to_string())
1222 );
1223 }
1224
1225 #[test]
1226 fn compressed_outputs_cleared_on_invalidate() {
1227 let mut cache = SessionCache::new();
1228 cache.store("/test.rs", "content");
1229 cache.set_compressed("/test.rs", "signatures", "cached sigs".to_string());
1230 cache.invalidate("/test.rs");
1231 assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
1232 }
1233
1234 #[test]
1235 fn compressed_outputs_cleared_on_clear() {
1236 let mut cache = SessionCache::new();
1237 cache.store("/a.rs", "a");
1238 cache.set_compressed("/a.rs", "map", "map_a".to_string());
1239 cache.clear();
1240 assert_eq!(cache.get_compressed("/a.rs", "map"), None);
1241 }
1242}