1use std::fs::Metadata;
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock};
28use std::time::UNIX_EPOCH;
29
30use lru::LruCache;
31
32const DEFAULT_BUDGET_MB: usize = 128;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct FileState {
39 pub mtime_ms: u64,
40 pub size_bytes: u64,
41}
42
43impl FileState {
44 pub fn from_metadata(meta: &Metadata) -> Option<Self> {
48 let mtime_ms = meta
49 .modified()
50 .ok()
51 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
52 .map(|d| d.as_millis() as u64)?;
53 Some(Self {
54 mtime_ms,
55 size_bytes: meta.len(),
56 })
57 }
58
59 pub fn from_path(path: &Path) -> Option<Self> {
61 Self::from_metadata(&path.metadata().ok()?)
62 }
63}
64
65struct Entry {
66 state: FileState,
67 content: Arc<str>,
68}
69
70struct Cache {
71 map: LruCache<PathBuf, Entry>,
79 total_bytes: usize,
80 budget_bytes: usize,
81 hits: u64,
82 misses: u64,
83 inserts: u64,
84 evictions: u64,
85}
86
87impl Cache {
88 fn new(budget_bytes: usize) -> Self {
89 Self {
90 map: LruCache::unbounded(),
91 total_bytes: 0,
92 budget_bytes,
93 hits: 0,
94 misses: 0,
95 inserts: 0,
96 evictions: 0,
97 }
98 }
99
100 fn remove_entry(&mut self, path: &Path) {
101 if let Some(old) = self.map.pop(path) {
102 self.total_bytes = self.total_bytes.saturating_sub(old.content.len());
103 }
104 }
105
106 fn evict_to_budget(&mut self) {
108 while self.total_bytes > self.budget_bytes {
109 let Some((_, victim)) = self.map.pop_lru() else {
110 break;
111 };
112 self.total_bytes = self.total_bytes.saturating_sub(victim.content.len());
113 self.evictions += 1;
114 }
115 }
116}
117
118static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
119
120fn budget_bytes() -> usize {
121 let mb = std::env::var("LEAN_CTX_CONTENT_CACHE_MB")
122 .ok()
123 .and_then(|v| v.trim().parse::<usize>().ok())
124 .unwrap_or(DEFAULT_BUDGET_MB);
125 mb.saturating_mul(1024 * 1024)
126}
127
128fn disabled() -> bool {
129 std::env::var("LEAN_CTX_DISABLE_CONTENT_CACHE")
132 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
133 || budget_bytes() == 0
134}
135
136fn cache() -> &'static Mutex<Cache> {
137 CACHE.get_or_init(|| Mutex::new(Cache::new(budget_bytes())))
138}
139
140fn lock() -> std::sync::MutexGuard<'static, Cache> {
141 cache()
142 .lock()
143 .unwrap_or_else(std::sync::PoisonError::into_inner)
144}
145
146pub fn get(path: &Path, current: FileState) -> Option<Arc<str>> {
151 if disabled() {
152 return None;
153 }
154 let mut c = lock();
155 let Some(entry) = c.map.peek(path) else {
158 c.misses += 1;
159 return None;
160 };
161 if entry.state != current {
162 c.remove_entry(path);
164 c.misses += 1;
165 return None;
166 }
167 c.hits += 1;
168 let entry = c.map.get(path)?;
172 Some(Arc::clone(&entry.content))
173}
174
175pub fn insert(path: &Path, state: FileState, content: Arc<str>) {
179 if disabled() || crate::core::memory_guard::is_under_pressure() {
180 return;
181 }
182 let len = content.len();
183 let mut c = lock();
184 if len > c.budget_bytes {
186 return;
187 }
188 c.remove_entry(path);
189 c.map.put(path.to_path_buf(), Entry { state, content });
190 c.total_bytes += len;
191 c.inserts += 1;
192 if c.total_bytes > c.budget_bytes {
193 c.evict_to_budget();
194 }
195}
196
197pub fn get_or_read(path: &Path) -> Option<Arc<str>> {
203 let state = FileState::from_path(path)?;
204 if let Some(hit) = get(path, state) {
205 return Some(hit);
206 }
207 let content = std::fs::read_to_string(path).ok()?;
208 let arc: Arc<str> = Arc::from(content);
209 insert(path, state, Arc::clone(&arc));
210 Some(arc)
211}
212
213pub fn clear() {
216 if CACHE.get().is_none() {
217 return;
218 }
219 let mut c = lock();
220 c.map.clear();
221 c.total_bytes = 0;
222}
223
224pub fn trim_oldest_percent(percent: u8) {
228 if CACHE.get().is_none() {
229 return;
230 }
231 let mut c = lock();
232 if c.map.is_empty() {
233 return;
234 }
235 let pct = (percent.min(100)) as usize;
236 let target_evictions = c.map.len() * pct / 100;
237 for _ in 0..target_evictions {
238 let Some((_, victim)) = c.map.pop_lru() else {
239 break;
240 };
241 c.total_bytes = c.total_bytes.saturating_sub(victim.content.len());
242 c.evictions += 1;
243 }
244}
245
246pub fn memory_usage_bytes() -> usize {
248 if CACHE.get().is_none() {
249 return 0;
250 }
251 lock().total_bytes
252}
253
254#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
256pub struct CacheStats {
257 pub hits: u64,
258 pub misses: u64,
259 pub entries: usize,
260 pub bytes: usize,
261 pub inserts: u64,
262 pub evictions: u64,
263}
264
265pub fn stats() -> CacheStats {
266 if CACHE.get().is_none() {
267 return CacheStats::default();
268 }
269 let c = lock();
270 CacheStats {
271 hits: c.hits,
272 misses: c.misses,
273 entries: c.map.len(),
274 bytes: c.total_bytes,
275 inserts: c.inserts,
276 evictions: c.evictions,
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 static TEST_LOCK: Mutex<()> = Mutex::new(());
287
288 fn fresh_cache(budget_bytes: usize) {
289 crate::test_env::remove_var("LEAN_CTX_CONTENT_CACHE_MB");
290 crate::test_env::remove_var("LEAN_CTX_DISABLE_CONTENT_CACHE");
291 let mut c = lock();
292 *c = Cache::new(budget_bytes);
293 }
294
295 fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
296 let p = dir.join(name);
297 std::fs::write(&p, body).unwrap();
298 p
299 }
300
301 #[test]
302 fn hit_after_insert_with_matching_state() {
303 let _g = TEST_LOCK
304 .lock()
305 .unwrap_or_else(std::sync::PoisonError::into_inner);
306 fresh_cache(1024 * 1024);
307 let dir = tempfile::tempdir().unwrap();
308 let p = write(dir.path(), "a.rs", "fn main() {}\n");
309 let state = FileState::from_path(&p).unwrap();
310 assert!(get(&p, state).is_none(), "cold cache must miss");
311 insert(&p, state, Arc::from("fn main() {}\n"));
312 let got = get(&p, state).expect("warm cache must hit");
313 assert_eq!(&*got, "fn main() {}\n");
314 }
315
316 #[test]
317 fn mtime_or_size_change_invalidates() {
318 let _g = TEST_LOCK
319 .lock()
320 .unwrap_or_else(std::sync::PoisonError::into_inner);
321 fresh_cache(1024 * 1024);
322 let dir = tempfile::tempdir().unwrap();
323 let p = write(dir.path(), "a.rs", "v1\n");
324 let s1 = FileState::from_path(&p).unwrap();
325 insert(&p, s1, Arc::from("v1\n"));
326 assert!(get(&p, s1).is_some());
327
328 let s_bigger = FileState {
330 size_bytes: s1.size_bytes + 10,
331 ..s1
332 };
333 assert!(get(&p, s_bigger).is_none(), "size change must miss");
334 assert!(
335 get(&p, s1).is_none(),
336 "stale entry must be evicted on mismatch"
337 );
338
339 insert(&p, s1, Arc::from("v1\n"));
341 let s_newer = FileState {
342 mtime_ms: s1.mtime_ms + 1,
343 ..s1
344 };
345 assert!(get(&p, s_newer).is_none(), "mtime change must miss");
346 }
347
348 #[test]
349 fn get_or_read_populates_then_serves_from_cache() {
350 let _g = TEST_LOCK
351 .lock()
352 .unwrap_or_else(std::sync::PoisonError::into_inner);
353 fresh_cache(1024 * 1024);
354 let dir = tempfile::tempdir().unwrap();
355 let p = write(dir.path(), "a.rs", "hello world\n");
356
357 let before = stats();
358 let first = get_or_read(&p).unwrap();
359 assert_eq!(&*first, "hello world\n");
360 let after_first = stats();
361 assert_eq!(
362 after_first.inserts,
363 before.inserts + 1,
364 "first read inserts"
365 );
366
367 let second = get_or_read(&p).unwrap();
368 assert_eq!(&*second, "hello world\n");
369 let after_second = stats();
370 assert_eq!(
371 after_second.inserts, after_first.inserts,
372 "second read must NOT re-insert (served from cache)"
373 );
374 assert!(after_second.hits > after_first.hits, "second read is a hit");
375 }
376
377 #[test]
378 fn eviction_keeps_cache_within_budget() {
379 let _g = TEST_LOCK
380 .lock()
381 .unwrap_or_else(std::sync::PoisonError::into_inner);
382 fresh_cache(64);
384 let dir = tempfile::tempdir().unwrap();
385 let pa = write(dir.path(), "a", "aaaaaaaaaaaaaaaaaaaaaaaaaaaa"); let pb = write(dir.path(), "b", "bbbbbbbbbbbbbbbbbbbbbbbbbbbb");
387 let pc = write(dir.path(), "c", "cccccccccccccccccccccccccccc");
388 let sa = FileState::from_path(&pa).unwrap();
389 let sb = FileState::from_path(&pb).unwrap();
390 let sc = FileState::from_path(&pc).unwrap();
391
392 insert(&pa, sa, Arc::from("aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
393 let _ = get(&pa, sa);
395 insert(&pb, sb, Arc::from("bbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
396 let _ = get(&pa, sa);
397 insert(&pc, sc, Arc::from("cccccccccccccccccccccccccccc"));
398
399 let st = stats();
400 assert!(st.bytes <= 64, "cache must respect byte budget: {st:?}");
401 assert!(st.evictions >= 1, "an eviction must have occurred: {st:?}");
402 assert!(get(&pa, sa).is_some(), "recently-used entry must survive");
403 }
404
405 #[test]
406 fn disabled_via_zero_budget_is_passthrough() {
407 let _g = TEST_LOCK
408 .lock()
409 .unwrap_or_else(std::sync::PoisonError::into_inner);
410 fresh_cache(1024 * 1024);
411 crate::test_env::set_var("LEAN_CTX_CONTENT_CACHE_MB", "0");
412 let dir = tempfile::tempdir().unwrap();
413 let p = write(dir.path(), "a.rs", "x\n");
414 let state = FileState::from_path(&p).unwrap();
415 insert(&p, state, Arc::from("x\n"));
416 assert!(get(&p, state).is_none(), "zero-budget cache is a no-op");
417 crate::test_env::remove_var("LEAN_CTX_CONTENT_CACHE_MB");
418 }
419}