lean_ctx/core/
path_mode_memory.rs1use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::{Mutex, OnceLock};
17
18use serde::{Deserialize, Serialize};
19
20const STORE_FILE: &str = "path_mode_memory.json";
21const DECAY_SECS: u64 = 30 * 24 * 3600;
24const MAX_PATHS: usize = 500;
26const FLUSH_EVERY: usize = 25;
27
28static STORE: OnceLock<Mutex<PathModeMemory>> = OnceLock::new();
29static RECORD_CALLS: AtomicUsize = AtomicUsize::new(0);
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct PathModeStats {
33 pub bounce_count: u32,
34 pub read_count: u32,
36 pub last_bounce_unix: u64,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct PathModeMemory {
41 pub paths: HashMap<String, PathModeStats>,
42 #[serde(skip)]
43 dirty: bool,
44}
45
46impl PathModeMemory {
47 fn load_from_disk() -> Self {
48 let Ok(raw) = std::fs::read_to_string(store_path()) else {
49 return Self::default();
50 };
51 let mut store: Self = serde_json::from_str(&raw).unwrap_or_default();
52 store.decay(now_unix());
53 store
54 }
55
56 fn decay(&mut self, now: u64) {
58 let before = self.paths.len();
59 self.paths
60 .retain(|_, s| now.saturating_sub(s.last_bounce_unix) <= DECAY_SECS);
61 if self.paths.len() != before {
62 self.dirty = true;
63 }
64 }
65
66 fn evict_to_cap(&mut self) {
67 if self.paths.len() <= MAX_PATHS {
68 return;
69 }
70 let mut items: Vec<(String, u64)> = self
71 .paths
72 .iter()
73 .map(|(p, s)| (p.clone(), s.last_bounce_unix))
74 .collect();
75 items.sort_by_key(|(_, ts)| *ts);
76 let drop_n = self.paths.len() - MAX_PATHS;
77 for (path, _) in items.into_iter().take(drop_n) {
78 self.paths.remove(&path);
79 }
80 self.dirty = true;
81 }
82
83 pub fn record_bounce(&mut self, norm_path: &str, now: u64) {
84 let entry = self.paths.entry(norm_path.to_string()).or_default();
85 entry.bounce_count = entry.bounce_count.saturating_add(1);
86 entry.read_count = entry.read_count.max(entry.bounce_count);
89 entry.last_bounce_unix = now;
90 self.dirty = true;
91 self.evict_to_cap();
92 }
93
94 pub fn record_read_if_tracked(&mut self, norm_path: &str) {
98 if let Some(entry) = self.paths.get_mut(norm_path) {
99 entry.read_count = entry.read_count.saturating_add(1);
100 self.dirty = true;
101 }
102 }
103
104 pub fn should_force_full(&self, norm_path: &str) -> bool {
107 self.paths.get(norm_path).is_some_and(|s| {
108 s.bounce_count >= 2 && u64::from(s.bounce_count) * 2 >= u64::from(s.read_count)
109 })
110 }
111
112 pub fn save(&self) -> std::io::Result<()> {
113 let path = store_path();
114 if let Some(parent) = path.parent() {
115 std::fs::create_dir_all(parent)?;
116 }
117 let json = serde_json::to_string(self)?;
118 let tmp = path.with_extension("tmp");
119 std::fs::write(&tmp, json)?;
120 std::fs::rename(&tmp, &path)
121 }
122}
123
124fn store_path() -> PathBuf {
125 crate::core::paths::cache_dir()
126 .unwrap_or_else(|_| PathBuf::from("."))
127 .join(STORE_FILE)
128}
129
130fn now_unix() -> u64 {
131 std::time::SystemTime::now()
132 .duration_since(std::time::UNIX_EPOCH)
133 .map_or(0, |d| d.as_secs())
134}
135
136fn global() -> &'static Mutex<PathModeMemory> {
137 STORE.get_or_init(|| Mutex::new(PathModeMemory::load_from_disk()))
138}
139
140pub fn record_bounce(norm_path: &str) {
143 let Ok(mut store) = global().lock() else {
144 return;
145 };
146 store.record_bounce(norm_path, now_unix());
147 maybe_flush(&mut store);
148}
149
150pub fn record_read_if_tracked(norm_path: &str) {
152 let Ok(mut store) = global().lock() else {
153 return;
154 };
155 store.record_read_if_tracked(norm_path);
156 maybe_flush(&mut store);
157}
158
159pub fn should_force_full(path: &str) -> bool {
161 let norm = crate::core::pathutil::normalize_tool_path(path);
162 global().lock().is_ok_and(|s| s.should_force_full(&norm))
163}
164
165pub fn flush() {
166 if let Ok(store) = global().lock() {
167 if store.dirty {
168 let _ = store.save();
169 }
170 }
171}
172
173pub fn disk_summary() -> (usize, usize) {
177 let store = PathModeMemory::load_from_disk();
178 let forced = store
179 .paths
180 .keys()
181 .filter(|p| store.should_force_full(p))
182 .count();
183 (store.paths.len(), forced)
184}
185
186fn maybe_flush(store: &mut PathModeMemory) {
187 let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
188 if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
189 store.dirty = false;
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn force_full_after_two_majority_bounces() {
199 let mut m = PathModeMemory::default();
200 m.record_bounce("a.yml", 1000);
201 assert!(!m.should_force_full("a.yml"), "one bounce is not a pattern");
202 m.record_bounce("a.yml", 1001);
203 assert!(m.should_force_full("a.yml"));
204 }
205
206 #[test]
207 fn many_clean_reads_outweigh_old_bounces() {
208 let mut m = PathModeMemory::default();
209 m.record_bounce("b.rs", 1000);
210 m.record_bounce("b.rs", 1001);
211 assert!(m.should_force_full("b.rs"));
212 for _ in 0..5 {
214 m.record_read_if_tracked("b.rs");
215 }
216 assert!(!m.should_force_full("b.rs"));
217 }
218
219 #[test]
220 fn decay_drops_stale_entries() {
221 let mut m = PathModeMemory::default();
222 m.record_bounce("old.ts", 1000);
223 m.record_bounce("old.ts", 1001);
224 m.record_bounce("fresh.ts", 5000);
225 m.record_bounce("fresh.ts", 5001);
226 m.decay(5001 + DECAY_SECS - 10);
227 assert!(!m.paths.contains_key("old.ts"));
228 assert!(m.paths.contains_key("fresh.ts"));
229 }
230
231 #[test]
232 fn eviction_keeps_newest_bounces() {
233 let mut m = PathModeMemory::default();
234 for i in 0..(MAX_PATHS + 20) {
235 m.record_bounce(&format!("f{i}.rs"), 1000 + i as u64);
236 }
237 assert_eq!(m.paths.len(), MAX_PATHS);
238 assert!(!m.paths.contains_key("f0.rs"), "oldest evicted");
239 let newest = format!("f{}.rs", MAX_PATHS + 19);
240 assert!(m.paths.contains_key(&newest));
241 }
242
243 #[test]
244 fn untracked_reads_are_ignored() {
245 let mut m = PathModeMemory::default();
246 m.record_read_if_tracked("never_bounced.rs");
247 assert!(m.paths.is_empty());
248 }
249
250 #[test]
251 fn roundtrip_serialization() {
252 let mut m = PathModeMemory::default();
253 m.record_bounce("x.rs", 42);
254 let json = serde_json::to_string(&m).unwrap();
255 let back: PathModeMemory = serde_json::from_str(&json).unwrap();
256 assert_eq!(back.paths.get("x.rs").unwrap().bounce_count, 1);
257 assert_eq!(back.paths.get("x.rs").unwrap().last_bounce_unix, 42);
258 }
259}