Skip to main content

lean_ctx/core/
cli_cache.rs

1use md5::{Digest, Md5};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7const CACHE_TTL_SECS: u64 = 300;
8const MAX_ENTRIES: usize = 200;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct CliCacheEntry {
12    pub path: String,
13    pub hash: String,
14    pub line_count: usize,
15    pub original_tokens: usize,
16    pub timestamp: u64,
17    pub read_count: u32,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct CliCacheStore {
22    pub entries: HashMap<String, CliCacheEntry>,
23    pub total_hits: u64,
24    pub total_reads: u64,
25}
26
27pub enum CacheResult {
28    Hit {
29        entry: CliCacheEntry,
30        file_ref: String,
31    },
32    Miss {
33        content: String,
34    },
35}
36
37fn cache_dir() -> Option<PathBuf> {
38    dirs::home_dir().map(|h| h.join(".lean-ctx").join("cli-cache"))
39}
40
41fn cache_file() -> Option<PathBuf> {
42    cache_dir().map(|d| d.join("cache.json"))
43}
44
45fn now_secs() -> u64 {
46    SystemTime::now()
47        .duration_since(UNIX_EPOCH)
48        .unwrap_or_default()
49        .as_secs()
50}
51
52fn compute_md5(content: &str) -> String {
53    let mut hasher = Md5::new();
54    hasher.update(content.as_bytes());
55    format!("{:x}", hasher.finalize())
56}
57
58fn normalize_key(path: &str) -> String {
59    crate::hooks::normalize_tool_path(path)
60}
61
62fn load_store() -> CliCacheStore {
63    let path = match cache_file() {
64        Some(p) => p,
65        None => return CliCacheStore::default(),
66    };
67    match std::fs::read_to_string(&path) {
68        Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
69        Err(_) => CliCacheStore::default(),
70    }
71}
72
73fn save_store(store: &CliCacheStore) {
74    let dir = match cache_dir() {
75        Some(d) => d,
76        None => return,
77    };
78    let _ = std::fs::create_dir_all(&dir);
79    let path = dir.join("cache.json");
80    if let Ok(data) = serde_json::to_string(store) {
81        let _ = std::fs::write(path, data);
82    }
83}
84
85fn file_ref(key: &str, store: &CliCacheStore) -> String {
86    let keys: Vec<&String> = store.entries.keys().collect();
87    let idx = keys
88        .iter()
89        .position(|k| k.as_str() == key)
90        .unwrap_or(store.entries.len());
91    format!("F{}", idx + 1)
92}
93
94pub fn check_and_read(path: &str) -> CacheResult {
95    let content = match crate::tools::ctx_read::read_file_lossy(path) {
96        Ok(c) => c,
97        Err(_) => {
98            return CacheResult::Miss {
99                content: String::new(),
100            }
101        }
102    };
103
104    let key = normalize_key(path);
105    let hash = compute_md5(&content);
106    let now = now_secs();
107    let mut store = load_store();
108
109    store.total_reads += 1;
110
111    if let Some(entry) = store.entries.get_mut(&key) {
112        if entry.hash == hash && (now - entry.timestamp) < CACHE_TTL_SECS {
113            entry.read_count += 1;
114            entry.timestamp = now;
115            store.total_hits += 1;
116            let result = CacheResult::Hit {
117                entry: entry.clone(),
118                file_ref: file_ref(&key, &store),
119            };
120            save_store(&store);
121            return result;
122        }
123    }
124
125    let line_count = content.lines().count();
126    let original_tokens = crate::core::tokens::count_tokens(&content);
127
128    let entry = CliCacheEntry {
129        path: key.clone(),
130        hash,
131        line_count,
132        original_tokens,
133        timestamp: now,
134        read_count: 1,
135    };
136    store.entries.insert(key, entry);
137
138    evict_stale(&mut store, now);
139
140    save_store(&store);
141    CacheResult::Miss { content }
142}
143
144pub fn invalidate(path: &str) {
145    let key = normalize_key(path);
146    let mut store = load_store();
147    store.entries.remove(&key);
148    save_store(&store);
149}
150
151pub fn clear() -> usize {
152    let mut store = load_store();
153    let count = store.entries.len();
154    store.entries.clear();
155    save_store(&store);
156    count
157}
158
159pub fn stats() -> (u64, u64, usize) {
160    let store = load_store();
161    (store.total_hits, store.total_reads, store.entries.len())
162}
163
164fn evict_stale(store: &mut CliCacheStore, now: u64) {
165    store
166        .entries
167        .retain(|_, e| (now - e.timestamp) < CACHE_TTL_SECS);
168
169    if store.entries.len() > MAX_ENTRIES {
170        let mut entries: Vec<(String, u64)> = store
171            .entries
172            .iter()
173            .map(|(k, e)| (k.clone(), e.timestamp))
174            .collect();
175        entries.sort_by_key(|(_, ts)| *ts);
176        let to_remove = store.entries.len() - MAX_ENTRIES;
177        for (key, _) in entries.into_iter().take(to_remove) {
178            store.entries.remove(&key);
179        }
180    }
181}
182
183pub fn format_hit(entry: &CliCacheEntry, file_ref: &str, short_path: &str) -> String {
184    format!(
185        "{file_ref} cached {short_path} [{}L {}t] (read #{})",
186        entry.line_count, entry.original_tokens, entry.read_count
187    )
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn compute_md5_deterministic() {
196        let h1 = compute_md5("test content");
197        let h2 = compute_md5("test content");
198        assert_eq!(h1, h2);
199        assert_ne!(h1, compute_md5("different"));
200    }
201
202    #[test]
203    fn evict_stale_removes_old_entries() {
204        let mut store = CliCacheStore::default();
205        store.entries.insert(
206            "/old.rs".to_string(),
207            CliCacheEntry {
208                path: "/old.rs".to_string(),
209                hash: "h1".into(),
210                line_count: 10,
211                original_tokens: 50,
212                timestamp: 1000,
213                read_count: 1,
214            },
215        );
216        store.entries.insert(
217            "/new.rs".to_string(),
218            CliCacheEntry {
219                path: "/new.rs".to_string(),
220                hash: "h2".into(),
221                line_count: 20,
222                original_tokens: 100,
223                timestamp: now_secs(),
224                read_count: 1,
225            },
226        );
227
228        evict_stale(&mut store, now_secs());
229        assert!(!store.entries.contains_key("/old.rs"));
230        assert!(store.entries.contains_key("/new.rs"));
231    }
232
233    #[test]
234    fn evict_respects_max_entries() {
235        let mut store = CliCacheStore::default();
236        let now = now_secs();
237        for i in 0..MAX_ENTRIES + 10 {
238            store.entries.insert(
239                format!("/file_{i}.rs"),
240                CliCacheEntry {
241                    path: format!("/file_{i}.rs"),
242                    hash: format!("h{i}"),
243                    line_count: 1,
244                    original_tokens: 10,
245                    timestamp: now - i as u64,
246                    read_count: 1,
247                },
248            );
249        }
250        evict_stale(&mut store, now);
251        assert!(store.entries.len() <= MAX_ENTRIES);
252    }
253
254    #[test]
255    fn format_hit_output() {
256        let entry = CliCacheEntry {
257            path: "/test.rs".into(),
258            hash: "abc".into(),
259            line_count: 42,
260            original_tokens: 500,
261            timestamp: now_secs(),
262            read_count: 3,
263        };
264        let output = format_hit(&entry, "F1", "test.rs");
265        assert!(output.contains("F1 cached"));
266        assert!(output.contains("42L"));
267        assert!(output.contains("500t"));
268        assert!(output.contains("read #3"));
269    }
270
271    #[test]
272    fn stats_returns_defaults_on_empty() {
273        let s = CliCacheStore::default();
274        assert_eq!(s.total_hits, 0);
275        assert_eq!(s.total_reads, 0);
276        assert!(s.entries.is_empty());
277    }
278
279    #[test]
280    fn cache_result_integration() {
281        let unique = format!(
282            "lean_ctx_cli_cache_test_{}.txt",
283            std::time::SystemTime::now()
284                .duration_since(std::time::UNIX_EPOCH)
285                .unwrap()
286                .as_nanos()
287        );
288        let tmp = std::env::temp_dir().join(&unique);
289        std::fs::write(&tmp, "fn main() {}\n").unwrap();
290        let path_str = tmp.to_str().unwrap();
291
292        invalidate(path_str);
293
294        let result = check_and_read(path_str);
295        assert!(matches!(result, CacheResult::Miss { .. }));
296
297        let result2 = check_and_read(path_str);
298        assert!(matches!(result2, CacheResult::Hit { .. }));
299        if let CacheResult::Hit { entry, .. } = result2 {
300            assert_eq!(entry.line_count, 1);
301            assert!(entry.read_count >= 2);
302        }
303
304        invalidate(path_str);
305        let result3 = check_and_read(path_str);
306        assert!(matches!(result3, CacheResult::Miss { .. }));
307
308        invalidate(path_str);
309        let _ = std::fs::remove_file(&tmp);
310    }
311}