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