1use md5::{Digest, Md5};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::OnceLock;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10const CACHE_TTL_SECS: u64 = 300;
11const MAX_ENTRIES: usize = 200;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CliCacheEntry {
15 pub path: String,
16 pub hash: String,
17 pub line_count: usize,
18 pub original_tokens: usize,
19 pub timestamp: u64,
20 pub read_count: u32,
21 #[serde(default)]
28 pub nonce: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub(crate) struct CliCacheStore {
33 pub entries: HashMap<String, CliCacheEntry>,
34 pub total_hits: u64,
35 pub total_reads: u64,
36}
37
38pub enum CacheResult {
39 Hit {
40 entry: CliCacheEntry,
41 file_ref: String,
42 },
43 Miss {
44 content: String,
45 },
46}
47
48fn cache_dir() -> Option<PathBuf> {
49 crate::core::data_dir::lean_ctx_data_dir()
50 .ok()
51 .map(|d| d.join("cli-cache"))
52}
53
54fn cache_file() -> Option<PathBuf> {
55 cache_dir().map(|d| d.join("cache.json"))
56}
57
58fn now_secs() -> u64 {
59 SystemTime::now()
60 .duration_since(UNIX_EPOCH)
61 .unwrap_or_default()
62 .as_secs()
63}
64
65fn process_nonce() -> &'static str {
70 static NONCE: OnceLock<String> = OnceLock::new();
71 NONCE
72 .get_or_init(|| {
73 let nanos = SystemTime::now()
74 .duration_since(UNIX_EPOCH)
75 .unwrap_or_default()
76 .as_nanos();
77 format!("{}-{}", std::process::id(), nanos)
78 })
79 .as_str()
80}
81
82fn compute_md5(content: &str) -> String {
83 let mut hasher = Md5::new();
84 hasher.update(content.as_bytes());
85 crate::core::agent_identity::hex_encode(&hasher.finalize())
86}
87
88fn normalize_key(path: &str) -> String {
89 crate::core::pathutil::normalize_tool_path(path)
90}
91
92struct CacheTransaction {
99 path: PathBuf,
100 store: CliCacheStore,
101 _lock: crate::core::agents::FileLock,
103 _in_process: std::sync::MutexGuard<'static, ()>,
104}
105
106fn in_process_guard() -> std::sync::MutexGuard<'static, ()> {
107 static STORE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
108 STORE_LOCK
109 .lock()
110 .unwrap_or_else(std::sync::PoisonError::into_inner)
111}
112
113#[cfg(not(test))]
116fn lock_timeout() -> std::time::Duration {
117 crate::core::agents::ACQUIRE_TIMEOUT
118}
119
120#[cfg(test)]
121thread_local! {
122 static LOCK_TIMEOUT_OVERRIDE: std::cell::Cell<Option<std::time::Duration>> =
126 const { std::cell::Cell::new(None) };
127}
128
129#[cfg(test)]
130fn lock_timeout() -> std::time::Duration {
131 LOCK_TIMEOUT_OVERRIDE
132 .with(std::cell::Cell::get)
133 .unwrap_or(crate::core::agents::ACQUIRE_TIMEOUT)
134}
135
136impl CacheTransaction {
137 pub(crate) fn load() -> std::io::Result<Self> {
138 let in_process = in_process_guard();
139 let path =
140 cache_file().ok_or_else(|| std::io::Error::other("CLI cache directory unavailable"))?;
141 let parent = path.parent().ok_or_else(|| {
142 std::io::Error::new(std::io::ErrorKind::InvalidInput, "CLI cache has no parent")
143 })?;
144 std::fs::create_dir_all(parent)?;
145 let lock = crate::core::agents::FileLock::acquire_with_timeout(
147 &path.with_extension("lock"),
148 lock_timeout(),
149 )
150 .map_err(|error| std::io::Error::other(format!("CLI cache lock: {error}")))?;
151 let store = match std::fs::read(&path) {
152 Ok(bytes) => serde_json::from_slice(&bytes)
153 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?,
154 Err(error) if error.kind() == std::io::ErrorKind::NotFound => CliCacheStore::default(),
155 Err(error) => return Err(error),
156 };
157 Ok(Self {
158 path,
159 store,
160 _lock: lock,
161 _in_process: in_process,
162 })
163 }
164
165 fn save(&self) -> std::io::Result<()> {
166 let bytes = serde_json::to_vec(&self.store).map_err(std::io::Error::other)?;
167 #[cfg(unix)]
168 let permissions = {
169 use std::os::unix::fs::PermissionsExt;
170 Some(std::fs::Permissions::from_mode(0o600))
171 };
172 #[cfg(not(unix))]
173 let permissions = None;
174 crate::core::atomic_fs::try_atomic_write(&self.path, &bytes, permissions.as_ref())
176 }
177}
178
179fn file_ref(key: &str, store: &CliCacheStore) -> String {
180 let keys: Vec<&String> = store.entries.keys().collect();
181 let idx = keys
182 .iter()
183 .position(|k| k.as_str() == key)
184 .unwrap_or(store.entries.len());
185 format!("F{}", idx + 1)
186}
187
188pub fn check_admitted(
192 path: &str,
193 source: &crate::core::context_admission::SourceAdmission,
194) -> CacheResult {
195 let content = &source.text;
196 if !source.storable {
197 return CacheResult::Miss {
198 content: content.clone(),
199 };
200 }
201 let key = normalize_key(path);
202 let hash = compute_md5(content);
203 let line_count = content.lines().count();
204 let original_tokens = crate::core::tokens::count_tokens(content);
205 let nonce = process_nonce();
206 let now = now_secs();
207 let is_hit = |entry: &CliCacheEntry| {
208 entry.hash == hash
209 && entry.nonce == nonce
210 && now.saturating_sub(entry.timestamp) < CACHE_TTL_SECS
211 };
212 let mut transaction = match CacheTransaction::load() {
213 Ok(transaction) => transaction,
214 Err(error) => {
215 tracing::warn!("CLI cache unavailable, counters not updated: {error}");
219 let store: Option<CliCacheStore> = cache_file()
220 .and_then(|path| std::fs::read(path).ok())
221 .and_then(|bytes| serde_json::from_slice(&bytes).ok());
222 return match store {
223 Some(store) if store.entries.get(&key).is_some_and(is_hit) => CacheResult::Hit {
224 entry: store.entries[&key].clone(),
225 file_ref: file_ref(&key, &store),
226 },
227 _ => CacheResult::Miss {
228 content: content.clone(),
229 },
230 };
231 }
232 };
233 let store = &mut transaction.store;
234
235 store.total_reads = store.total_reads.saturating_add(1);
236
237 if let Some(entry) = store.entries.get_mut(&key)
238 && is_hit(entry)
239 {
240 entry.read_count = entry.read_count.saturating_add(1);
241 entry.timestamp = now;
242 store.total_hits = store.total_hits.saturating_add(1);
243 let result = CacheResult::Hit {
244 entry: entry.clone(),
245 file_ref: file_ref(&key, store),
246 };
247 if let Err(error) = transaction.save() {
249 tracing::warn!("CLI cache hit counters not saved: {error}");
250 }
251 return result;
252 }
253
254 let entry = CliCacheEntry {
255 path: key.clone(),
256 hash,
257 line_count,
258 original_tokens,
259 timestamp: now,
260 read_count: 1,
261 nonce: nonce.to_string(),
262 };
263 store.entries.insert(key, entry);
264
265 evict_stale(store, now);
266
267 let _ = transaction.save();
269 CacheResult::Miss {
270 content: content.clone(),
271 }
272}
273
274#[cfg(any(test, feature = "test-support"))]
278pub fn check_and_read(path: &str) -> CacheResult {
279 let Ok(source) = crate::core::rooted_read::read_file_lossy_admitted_for_cli(path) else {
280 return CacheResult::Miss {
281 content: String::new(),
282 };
283 };
284 check_admitted(path, &source)
285}
286
287pub fn invalidate(path: &str) -> std::io::Result<()> {
288 let key = normalize_key(path);
289 let mut transaction = CacheTransaction::load()?;
290 transaction.store.entries.remove(&key);
291 transaction.save()
292}
293
294pub fn clear() -> std::io::Result<usize> {
295 let mut transaction = CacheTransaction::load()?;
296 let count = transaction.store.entries.len();
297 transaction.store.entries.clear();
298 transaction.save()?;
299 Ok(count)
300}
301
302pub fn clear_project(project_root: &str) -> std::io::Result<usize> {
303 let mut transaction = CacheTransaction::load()?;
304 let store = &mut transaction.store;
305 let prefix = normalize_key(project_root);
306 let before = store.entries.len();
307 store
308 .entries
309 .retain(|key, entry| !key.starts_with(&prefix) && !entry.path.starts_with(&prefix));
310 let removed = before - store.entries.len();
311 transaction.save()?;
312 Ok(removed)
313}
314
315pub fn stats() -> std::io::Result<(u64, u64, usize)> {
316 let transaction = CacheTransaction::load()?;
317 let store = &transaction.store;
318 Ok((store.total_hits, store.total_reads, store.entries.len()))
319}
320
321fn evict_stale(store: &mut CliCacheStore, now: u64) {
322 store
327 .entries
328 .retain(|_, e| now.saturating_sub(e.timestamp) < CACHE_TTL_SECS);
329
330 if store.entries.len() > MAX_ENTRIES {
331 let mut entries: Vec<(String, u64)> = store
332 .entries
333 .iter()
334 .map(|(k, e)| (k.clone(), e.timestamp))
335 .collect();
336 entries.sort_by_key(|(_, ts)| *ts);
337 let to_remove = store.entries.len() - MAX_ENTRIES;
338 for (key, _) in entries.into_iter().take(to_remove) {
339 store.entries.remove(&key);
340 }
341 }
342}
343
344pub fn format_hit(entry: &CliCacheEntry, file_ref: &str, short_path: &str) -> String {
345 if crate::core::protocol::savings_footer_visible() {
346 format!(
347 "{file_ref} cached {short_path} [{}L {}t] (read #{})",
348 entry.line_count, entry.original_tokens, entry.read_count
349 )
350 } else {
351 format!("cached {short_path} [{}L]", entry.line_count)
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn compute_md5_deterministic() {
361 let h1 = compute_md5("test content");
362 let h2 = compute_md5("test content");
363 assert_eq!(h1, h2);
364 assert_ne!(h1, compute_md5("different"));
365 }
366
367 #[test]
368 fn evict_stale_removes_old_entries() {
369 let mut store = CliCacheStore::default();
370 store.entries.insert(
371 "/old.rs".to_string(),
372 CliCacheEntry {
373 path: "/old.rs".to_string(),
374 hash: "h1".into(),
375 line_count: 10,
376 original_tokens: 50,
377 timestamp: 1000,
378 read_count: 1,
379 nonce: "n1".into(),
380 },
381 );
382 store.entries.insert(
383 "/new.rs".to_string(),
384 CliCacheEntry {
385 path: "/new.rs".to_string(),
386 hash: "h2".into(),
387 line_count: 20,
388 original_tokens: 100,
389 timestamp: now_secs(),
390 read_count: 1,
391 nonce: "n2".into(),
392 },
393 );
394
395 evict_stale(&mut store, now_secs());
396 assert!(!store.entries.contains_key("/old.rs"));
397 assert!(store.entries.contains_key("/new.rs"));
398 }
399
400 #[test]
401 fn evict_respects_max_entries() {
402 let mut store = CliCacheStore::default();
403 let now = now_secs();
404 for i in 0..MAX_ENTRIES + 10 {
405 store.entries.insert(
406 format!("/file_{i}.rs"),
407 CliCacheEntry {
408 path: format!("/file_{i}.rs"),
409 hash: format!("h{i}"),
410 line_count: 1,
411 original_tokens: 10,
412 timestamp: now - i as u64,
413 read_count: 1,
414 nonce: format!("n{i}"),
415 },
416 );
417 }
418 evict_stale(&mut store, now);
419 assert!(store.entries.len() <= MAX_ENTRIES);
420 }
421
422 #[test]
423 fn format_hit_output() {
424 let _lock = crate::core::data_dir::test_env_lock();
425 let entry = CliCacheEntry {
426 path: "/test.rs".into(),
427 hash: "abc".into(),
428 line_count: 42,
429 original_tokens: 500,
430 timestamp: now_secs(),
431 read_count: 3,
432 nonce: "n".into(),
433 };
434 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
435 let output = format_hit(&entry, "F1", "test.rs");
436 assert!(output.contains("cached test.rs"));
437 assert!(output.contains("42L"));
438 assert!(!output.contains("F1"));
439 assert!(!output.contains("500t"));
440 assert!(!output.contains("read #3"));
441 crate::test_env::remove_var("LEAN_CTX_SAVINGS_FOOTER");
442 }
443
444 #[test]
445 fn stats_returns_defaults_on_empty() {
446 let s = CliCacheStore::default();
447 assert_eq!(s.total_hits, 0);
448 assert_eq!(s.total_reads, 0);
449 assert!(s.entries.is_empty());
450 }
451
452 #[test]
453 fn cache_result_integration() {
454 let _lock = crate::core::data_dir::test_env_lock();
455
456 let nanos = std::time::SystemTime::now()
457 .duration_since(std::time::UNIX_EPOCH)
458 .unwrap()
459 .as_nanos();
460 let test_data_dir = std::env::temp_dir().join(format!("lean_ctx_cache_iso_{nanos}"));
461 std::fs::create_dir_all(&test_data_dir).unwrap();
462 crate::test_env::set_var("LEAN_CTX_DATA_DIR", &test_data_dir);
463
464 let tmp = test_data_dir.join("test_file.txt");
465 std::fs::write(&tmp, "fn main() {}\n").unwrap();
466 let path_str = tmp.to_str().unwrap();
467
468 invalidate(path_str).unwrap();
469
470 let result = check_and_read(path_str);
471 assert!(matches!(result, CacheResult::Miss { .. }));
472
473 let result2 = check_and_read(path_str);
474 assert!(matches!(result2, CacheResult::Hit { .. }));
475 if let CacheResult::Hit { entry, .. } = result2 {
476 assert_eq!(entry.line_count, 1);
477 assert!(entry.read_count >= 2);
478 }
479
480 invalidate(path_str).unwrap();
481 let result3 = check_and_read(path_str);
482 assert!(matches!(result3, CacheResult::Miss { .. }));
483
484 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
485 let _ = std::fs::remove_dir_all(&test_data_dir);
486 }
487
488 #[test]
489 fn cross_process_entry_is_not_served_as_hit() {
490 let _lock = crate::core::data_dir::test_env_lock();
494
495 let nanos = std::time::SystemTime::now()
496 .duration_since(std::time::UNIX_EPOCH)
497 .unwrap()
498 .as_nanos();
499 let test_data_dir = std::env::temp_dir().join(format!("lean_ctx_cache_xproc_{nanos}"));
500 std::fs::create_dir_all(&test_data_dir).unwrap();
501 crate::test_env::set_var("LEAN_CTX_DATA_DIR", &test_data_dir);
502
503 let tmp = test_data_dir.join("test_file.txt");
504 let content = "fn main() {}\n";
505 std::fs::write(&tmp, content).unwrap();
506 let path_str = tmp.to_str().unwrap();
507 let key = normalize_key(path_str);
508
509 let mut transaction = CacheTransaction::load().unwrap();
511 transaction.store.entries.insert(
512 key.clone(),
513 CliCacheEntry {
514 path: key,
515 hash: compute_md5(content),
516 line_count: 1,
517 original_tokens: 4,
518 timestamp: now_secs(),
519 read_count: 1,
520 nonce: "a-different-process".into(),
521 },
522 );
523 transaction.save().unwrap();
524 drop(transaction);
525
526 let result = check_and_read(path_str);
528 assert!(
529 matches!(&result, CacheResult::Miss { content } if content == "fn main() {}\n"),
530 "foreign-process cache entry must not be served as a hit"
531 );
532
533 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
534 let _ = std::fs::remove_dir_all(&test_data_dir);
535 }
536
537 #[test]
538 fn invalidate_waits_for_cache_file_lock() {
539 let _data = crate::core::data_dir::isolated_data_dir();
540 let file = tempfile::NamedTempFile::new().unwrap();
541 std::fs::write(file.path(), "cache contention fixture\n").unwrap();
542 let path = file.path().to_string_lossy().into_owned();
543 let _ = check_and_read(&path);
544 assert!(
545 matches!(check_and_read(&path), CacheResult::Hit { .. }),
546 "premise: the entry must be cached before invalidation"
547 );
548
549 let cache_path = cache_file().expect("isolated cache path");
550 let lock =
551 crate::core::agents::FileLock::acquire(&cache_path.with_extension("lock")).unwrap();
552 let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1);
553 let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
554 let worker_path = path.clone();
555 let worker = std::thread::spawn(move || {
556 let _ = started_tx.send(());
557 invalidate(&worker_path).unwrap();
558 let _ = done_tx.send(());
559 });
560
561 let started = started_rx
562 .recv_timeout(std::time::Duration::from_millis(750))
563 .is_ok();
564 let completed_while_locked = started
565 && done_rx
566 .recv_timeout(std::time::Duration::from_millis(100))
567 .is_ok();
568
569 drop(lock);
570 worker.join().expect("invalidate worker");
571
572 assert!(started, "invalidate worker did not start");
573 assert!(
574 !completed_while_locked,
575 "invalidate completed while cache lock was held"
576 );
577 assert!(matches!(check_and_read(&path), CacheResult::Miss { .. }));
578 }
579
580 struct ShortLockTimeout;
583
584 impl Drop for ShortLockTimeout {
585 fn drop(&mut self) {
586 LOCK_TIMEOUT_OVERRIDE.with(|cell| cell.set(None));
587 }
588 }
589
590 fn short_lock_timeout() -> ShortLockTimeout {
591 LOCK_TIMEOUT_OVERRIDE.with(|cell| cell.set(Some(std::time::Duration::from_millis(100))));
592 ShortLockTimeout
593 }
594
595 #[test]
596 fn cache_lock_timeout_preserves_bytes_and_still_serves_the_hit() {
597 let _data = crate::core::data_dir::isolated_data_dir();
598 let _short = short_lock_timeout();
599 let file = tempfile::NamedTempFile::new().unwrap();
600 let content = "cache timeout content\n";
601 std::fs::write(file.path(), content).unwrap();
602 let path = file.path().to_str().unwrap();
603 let _ = check_and_read(path);
604 let cache_path = cache_file().unwrap();
605 let before = std::fs::read(&cache_path).unwrap();
606 let _lock =
607 crate::core::agents::FileLock::acquire(&cache_path.with_extension("lock")).unwrap();
608 assert!(invalidate(path).is_err());
609 assert!(
610 matches!(check_and_read(path), CacheResult::Hit { .. }),
611 "a busy lock must not turn this process's hit into a miss"
612 );
613 assert_eq!(std::fs::read(&cache_path).unwrap(), before);
614 }
615
616 #[test]
617 fn cache_lock_timeout_without_an_entry_returns_full_content() {
618 let _data = crate::core::data_dir::isolated_data_dir();
619 let _short = short_lock_timeout();
620 let file = tempfile::NamedTempFile::new().unwrap();
621 let content = "never cached\n";
622 std::fs::write(file.path(), content).unwrap();
623 let path = file.path().to_str().unwrap();
624 let cache_path = cache_file().unwrap();
625 std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
626 let _lock =
627 crate::core::agents::FileLock::acquire(&cache_path.with_extension("lock")).unwrap();
628 assert!(
629 matches!(check_and_read(path), CacheResult::Miss { content: full } if full == content)
630 );
631 }
632
633 #[test]
634 fn malformed_cache_is_not_overwritten_or_reported_as_cleared() {
635 let _data = crate::core::data_dir::isolated_data_dir();
636 let file = tempfile::NamedTempFile::new().unwrap();
637 std::fs::write(file.path(), "uncached content\n").unwrap();
638 let path = file.path().to_str().unwrap();
639 let _ = check_and_read(path);
640 let cache_path = cache_file().unwrap();
641 std::fs::write(&cache_path, "{broken cache").unwrap();
642 assert_eq!(clear().unwrap_err().kind(), std::io::ErrorKind::InvalidData);
643 assert!(clear_project(path).is_err());
644 assert!(invalidate(path).is_err());
645 assert!(stats().is_err());
646 assert!(
647 matches!(check_and_read(path), CacheResult::Miss { content } if content == "uncached content\n")
648 );
649 assert_eq!(std::fs::read(&cache_path).unwrap(), b"{broken cache");
650 }
651
652 #[test]
653 fn concurrent_writers_never_drop_each_others_entries() {
654 let data = crate::core::data_dir::isolated_data_dir();
659 let files: Vec<String> = (0..64)
660 .map(|i| {
661 let path = data.path().join(format!("f{i}.rs"));
662 std::fs::write(&path, format!("fn f{i}() {{}}\n")).unwrap();
663 path.to_str().unwrap().to_string()
664 })
665 .collect();
666
667 std::thread::scope(|s| {
668 for chunk in files.chunks(8) {
669 s.spawn(move || {
670 for path in chunk {
671 let _ = check_and_read(path);
672 }
673 });
674 }
675 });
676
677 let store = CacheTransaction::load().expect("cache readable").store;
678 for path in &files {
679 assert!(
680 store.entries.contains_key(&normalize_key(path)),
681 "entry for {path} was lost to a concurrent write"
682 );
683 }
684 }
685}