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#[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 pub compressed_outputs: HashMap<String, String>,
31 pub full_content_delivered: bool,
34 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 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 pub fn content(&self) -> Option<String> {
78 zstd_decompress(&self.compressed_content)
79 }
80
81 pub fn set_content(&mut self, content: &str) {
83 self.compressed_content = zstd_compress(content);
84 }
85
86 pub fn compressed_size(&self) -> usize {
88 self.compressed_content.len()
89 }
90}
91
92#[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 pub full_content_delivered: bool,
101}
102
103impl CacheEntry {
104 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
139pub 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#[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 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 pub fn tokens_saved(&self) -> u64 {
223 self.total_original_tokens
224 .saturating_sub(self.total_sent_tokens)
225 }
226
227 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#[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
246pub 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 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 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 pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
294 self.file_refs.get(&normalize_key(path)).cloned()
295 }
296
297 pub fn get(&self, path: &str) -> Option<&CacheEntry> {
299 self.entries.get(&normalize_key(path))
300 }
301
302 pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
304 self.entries.get_mut(&normalize_key(path))
305 }
306
307 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 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 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 pub fn total_cached_tokens(&self) -> usize {
422 self.entries.values().map(|e| e.original_tokens).sum()
423 }
424
425 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 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 pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
456 self.entries.iter().collect()
457 }
458
459 pub fn get_stats(&self) -> &CacheStats {
461 &self.stats
462 }
463
464 pub fn file_ref_map(&self) -> &HashMap<String, String> {
466 &self.file_refs
467 }
468
469 pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
471 self.shared_blocks = blocks;
472 }
473
474 pub fn get_shared_blocks(&self) -> &[SharedBlock] {
476 &self.shared_blocks
477 }
478
479 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 pub fn invalidate(&mut self, path: &str) -> bool {
508 self.entries.remove(&normalize_key(path)).is_some()
509 }
510
511 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 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 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 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 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 pub fn trim_compressed_outputs(&mut self) -> usize {
556 let mut trimmed = 0;
557 for entry in self.entries.values_mut() {
558 if !entry.compressed_outputs.is_empty() {
559 entry.compressed_outputs.clear();
560 trimmed += 1;
561 }
562 }
563 trimmed
564 }
565
566 pub fn evict_probationary(&mut self) -> usize {
569 let to_remove: Vec<String> = self
570 .entries
571 .iter()
572 .filter(|(_, e)| e.read_count <= 1)
573 .map(|(k, _)| k.clone())
574 .collect();
575 let count = to_remove.len();
576 for key in &to_remove {
577 self.entries.remove(key);
578 self.file_refs.remove(key);
579 }
580 count
581 }
582
583 pub fn evict_to_budget(&mut self, target_tokens: usize) {
585 let current = self.total_cached_tokens();
586 if current <= target_tokens {
587 return;
588 }
589 let now = Instant::now();
590 let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
591 let mut scores = eviction_scores_rrf(&all, now);
592 scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
593
594 let mut freed = 0usize;
595 let target_free = current.saturating_sub(target_tokens);
596 for (path, _score) in &scores {
597 if freed >= target_free {
598 break;
599 }
600 if let Some(entry) = self.entries.remove(path) {
601 freed += entry.original_tokens;
602 self.file_refs.remove(path);
603 }
604 }
605 }
606
607 pub fn approximate_bytes(&self) -> usize {
609 let entries_bytes: usize = self
610 .entries
611 .values()
612 .map(|e| {
613 e.compressed_content.len()
614 + e.hash.len()
615 + e.path.len()
616 + e.compressed_outputs
617 .iter()
618 .map(|(k, v)| k.len() + v.len())
619 .sum::<usize>()
620 + 128 })
622 .sum();
623 let refs_bytes: usize = self.file_refs.iter().map(|(k, v)| k.len() + v.len()).sum();
624 let blocks_bytes: usize = self
625 .shared_blocks
626 .iter()
627 .map(|b| b.canonical_path.len() + b.canonical_ref.len() + b.content.len() + 32)
628 .sum();
629 entries_bytes + refs_bytes + blocks_bytes
630 }
631
632 const MAX_SHARED_BLOCKS: usize = 100;
633
634 pub fn trim_shared_blocks(&mut self) {
636 if self.shared_blocks.len() > Self::MAX_SHARED_BLOCKS {
637 let excess = self.shared_blocks.len() - Self::MAX_SHARED_BLOCKS;
638 self.shared_blocks.drain(..excess);
639 }
640 }
641
642 pub fn clear(&mut self) -> usize {
644 let count = self.entries.len();
645 self.entries.clear();
646 self.file_refs.clear();
647 self.shared_blocks.clear();
648 self.next_ref = 1;
649 self.stats = CacheStats {
650 total_reads: 0,
651 cache_hits: 0,
652 total_original_tokens: 0,
653 total_sent_tokens: 0,
654 files_tracked: 0,
655 };
656 count
657 }
658}
659
660pub fn file_mtime(path: &str) -> Option<SystemTime> {
661 std::fs::metadata(path).and_then(|m| m.modified()).ok()
662}
663
664pub fn is_cache_entry_stale(path: &str, cached_mtime: Option<SystemTime>) -> bool {
665 let current = file_mtime(path);
666 match (cached_mtime, current) {
667 (_, None) | (None, Some(_)) => true,
668 (Some(cached), Some(current)) => current > cached,
669 }
670}
671
672fn compute_md5(content: &str) -> String {
673 let mut hasher = Md5::new();
674 hasher.update(content.as_bytes());
675 format!("{:x}", hasher.finalize())
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use std::time::Duration;
682
683 #[test]
684 fn cache_stores_and_retrieves() {
685 let mut cache = SessionCache::new();
686 let result = cache.store("/test/file.rs", "fn main() {}");
687 assert!(!result.was_hit);
688 assert_eq!(result.line_count, 1);
689 assert!(cache.get("/test/file.rs").is_some());
690 }
691
692 #[test]
693 fn cache_hit_on_same_content() {
694 let mut cache = SessionCache::new();
695 cache.store("/test/file.rs", "content");
696 let result = cache.store("/test/file.rs", "content");
697 assert!(result.was_hit, "same content should be a cache hit");
698 }
699
700 #[test]
701 fn cache_miss_on_changed_content() {
702 let mut cache = SessionCache::new();
703 cache.store("/test/file.rs", "old content");
704 let result = cache.store("/test/file.rs", "new content");
705 assert!(!result.was_hit, "changed content should not be a cache hit");
706 }
707
708 #[test]
709 fn file_refs_are_sequential() {
710 let mut cache = SessionCache::new();
711 assert_eq!(cache.get_file_ref("/a.rs"), "F1");
712 assert_eq!(cache.get_file_ref("/b.rs"), "F2");
713 assert_eq!(cache.get_file_ref("/a.rs"), "F1"); }
715
716 #[test]
717 fn cache_clear_resets_everything() {
718 let mut cache = SessionCache::new();
719 cache.store("/a.rs", "a");
720 cache.store("/b.rs", "b");
721 let count = cache.clear();
722 assert_eq!(count, 2);
723 assert!(cache.get("/a.rs").is_none());
724 assert_eq!(cache.get_file_ref("/c.rs"), "F1"); }
726
727 #[test]
728 fn cache_invalidate_removes_entry() {
729 let mut cache = SessionCache::new();
730 cache.store("/test.rs", "test");
731 assert!(cache.invalidate("/test.rs"));
732 assert!(!cache.invalidate("/nonexistent.rs"));
733 }
734
735 #[test]
736 fn cache_stats_track_correctly() {
737 let mut cache = SessionCache::new();
738 cache.store("/a.rs", "hello");
739 cache.store("/a.rs", "hello"); let stats = cache.get_stats();
741 assert_eq!(stats.total_reads, 2);
742 assert_eq!(stats.cache_hits, 1);
743 assert!(stats.hit_rate() > 0.0);
744 }
745
746 #[test]
747 fn md5_is_deterministic() {
748 let h1 = compute_md5("test content");
749 let h2 = compute_md5("test content");
750 assert_eq!(h1, h2);
751 assert_ne!(h1, compute_md5("different"));
752 }
753
754 #[test]
755 fn rrf_eviction_prefers_recent() {
756 let base = Instant::now();
757 std::thread::sleep(std::time::Duration::from_millis(5));
758 let now = Instant::now();
759 let key_a = "a.rs".to_string();
760 let key_b = "b.rs".to_string();
761 let recent = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
762 let old = {
763 let mut e = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
764 e.last_access = base;
765 e
766 };
767 let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &recent), (&key_b, &old)];
768 let scores = eviction_scores_rrf(&entries, now);
769 let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
770 let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
771 assert!(
772 score_a > score_b,
773 "recently accessed entries should score higher via RRF"
774 );
775 }
776
777 #[test]
778 fn rrf_eviction_prefers_frequent() {
779 let now = Instant::now();
780 let key_a = "a.rs".to_string();
781 let key_b = "b.rs".to_string();
782 let frequent = {
783 let mut e = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
784 e.read_count = 20;
785 e
786 };
787 let rare = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
788 let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &frequent), (&key_b, &rare)];
789 let scores = eviction_scores_rrf(&entries, now);
790 let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
791 let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
792 assert!(
793 score_a > score_b,
794 "frequently accessed entries should score higher via RRF"
795 );
796 }
797
798 #[test]
799 fn evict_if_needed_removes_lowest_score() {
800 std::env::set_var("LEAN_CTX_CACHE_MAX_TOKENS", "50");
801 let mut cache = SessionCache::new();
802 let big_content = "a]".repeat(30); cache.store("/old.rs", &big_content);
804 let new_content = "b ".repeat(30); cache.store("/new.rs", &new_content);
808 assert!(
813 cache.total_cached_tokens() <= 60,
814 "eviction should have kicked in"
815 );
816 std::env::remove_var("LEAN_CTX_CACHE_MAX_TOKENS");
817 }
818
819 #[test]
820 fn stale_detection_flags_newer_file() {
821 let dir = tempfile::tempdir().unwrap();
822 let path = dir.path().join("stale.txt");
823 let p = path.to_string_lossy().to_string();
824
825 std::fs::write(&path, "one").unwrap();
826 let mut cache = SessionCache::new();
827 cache.store(&p, "one");
828
829 let entry = cache.get(&p).unwrap();
830 assert!(!is_cache_entry_stale(&p, entry.stored_mtime));
831
832 std::thread::sleep(Duration::from_secs(1));
834 std::fs::write(&path, "two").unwrap();
835
836 let entry = cache.get(&p).unwrap();
837 assert!(is_cache_entry_stale(&p, entry.stored_mtime));
838 }
839
840 #[test]
841 fn compressed_outputs_cached_and_retrieved() {
842 let mut cache = SessionCache::new();
843 cache.store("/test.rs", "fn main() {}");
844 cache.set_compressed("/test.rs", "map", "compressed map output".to_string());
845 assert_eq!(
846 cache.get_compressed("/test.rs", "map"),
847 Some(&"compressed map output".to_string())
848 );
849 assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
850 }
851
852 #[test]
853 fn compressed_outputs_cleared_on_content_change() {
854 let mut cache = SessionCache::new();
855 cache.store("/test.rs", "old content");
856 cache.set_compressed("/test.rs", "map", "old map".to_string());
857 assert!(cache.get_compressed("/test.rs", "map").is_some());
858
859 cache.store("/test.rs", "new content");
860 assert_eq!(cache.get_compressed("/test.rs", "map"), None);
861 }
862
863 #[test]
864 fn compressed_outputs_survive_same_content_store() {
865 let mut cache = SessionCache::new();
866 cache.store("/test.rs", "content");
867 cache.set_compressed("/test.rs", "map", "cached map".to_string());
868
869 let result = cache.store("/test.rs", "content");
870 assert!(result.was_hit);
871 assert_eq!(
872 cache.get_compressed("/test.rs", "map"),
873 Some(&"cached map".to_string())
874 );
875 }
876
877 #[test]
878 fn compressed_outputs_cleared_on_invalidate() {
879 let mut cache = SessionCache::new();
880 cache.store("/test.rs", "content");
881 cache.set_compressed("/test.rs", "signatures", "cached sigs".to_string());
882 cache.invalidate("/test.rs");
883 assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
884 }
885
886 #[test]
887 fn compressed_outputs_cleared_on_clear() {
888 let mut cache = SessionCache::new();
889 cache.store("/a.rs", "a");
890 cache.set_compressed("/a.rs", "map", "map_a".to_string());
891 cache.clear();
892 assert_eq!(cache.get_compressed("/a.rs", "map"), None);
893 }
894}