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(mut store) = global().lock()
173 && store.dirty
174 && store.save().is_ok()
175 {
176 store.dirty = false;
177 }
178}
179
180pub fn disk_summary() -> (usize, usize) {
184 let store = PathModeMemory::load_from_disk();
185 let forced = store
186 .paths
187 .keys()
188 .filter(|p| store.should_force_full(p))
189 .count();
190 (store.paths.len(), forced)
191}
192
193fn maybe_flush(store: &mut PathModeMemory) {
194 let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
195 if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
196 store.dirty = false;
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn force_full_after_two_majority_bounces() {
206 let mut m = PathModeMemory::default();
207 m.record_bounce("a.yml", 1000);
208 assert!(!m.should_force_full("a.yml"), "one bounce is not a pattern");
209 m.record_bounce("a.yml", 1001);
210 assert!(m.should_force_full("a.yml"));
211 }
212
213 #[test]
214 fn many_clean_reads_outweigh_old_bounces() {
215 let mut m = PathModeMemory::default();
216 m.record_bounce("b.rs", 1000);
217 m.record_bounce("b.rs", 1001);
218 assert!(m.should_force_full("b.rs"));
219 for _ in 0..5 {
221 m.record_compressed_read_if_tracked("b.rs");
222 }
223 assert!(!m.should_force_full("b.rs"));
224 }
225
226 #[test]
227 fn bounce_on_every_compressed_read_forces_full() {
228 let mut m = PathModeMemory::default();
231 m.record_bounce("c.rs", 1000);
232 for ts in 1001..1005 {
233 m.record_compressed_read_if_tracked("c.rs");
234 m.record_bounce("c.rs", ts);
235 }
236 let s = m.paths.get("c.rs").unwrap();
237 assert_eq!(s.bounce_count, 5);
238 assert_eq!(s.read_count, 5, "only compressed reads are counted");
239 assert!(m.should_force_full("c.rs"));
240 }
241
242 #[test]
243 fn decay_drops_stale_entries() {
244 let mut m = PathModeMemory::default();
245 m.record_bounce("old.ts", 1000);
246 m.record_bounce("old.ts", 1001);
247 m.record_bounce("fresh.ts", 5000);
248 m.record_bounce("fresh.ts", 5001);
249 m.decay(5001 + DECAY_SECS - 10);
250 assert!(!m.paths.contains_key("old.ts"));
251 assert!(m.paths.contains_key("fresh.ts"));
252 }
253
254 #[test]
255 fn eviction_keeps_newest_bounces() {
256 let mut m = PathModeMemory::default();
257 for i in 0..(MAX_PATHS + 20) {
258 m.record_bounce(&format!("f{i}.rs"), 1000 + i as u64);
259 }
260 assert_eq!(m.paths.len(), MAX_PATHS);
261 assert!(!m.paths.contains_key("f0.rs"), "oldest evicted");
262 let newest = format!("f{}.rs", MAX_PATHS + 19);
263 assert!(m.paths.contains_key(&newest));
264 }
265
266 #[test]
267 fn untracked_reads_are_ignored() {
268 let mut m = PathModeMemory::default();
269 m.record_compressed_read_if_tracked("never_bounced.rs");
270 assert!(m.paths.is_empty());
271 }
272
273 #[test]
274 fn roundtrip_serialization() {
275 let mut m = PathModeMemory::default();
276 m.record_bounce("x.rs", 42);
277 let json = serde_json::to_string(&m).unwrap();
278 let back: PathModeMemory = serde_json::from_str(&json).unwrap();
279 assert_eq!(back.paths.get("x.rs").unwrap().bounce_count, 1);
280 assert_eq!(back.paths.get("x.rs").unwrap().last_bounce_unix, 42);
281 }
282}