1use std::path::Path;
4
5#[cfg(test)]
6use std::cell::Cell;
7
8use fallow_types::source_fingerprint::SourceFingerprint;
9use rustc_hash::FxHashMap;
10
11use bitcode::{Decode, Encode};
12
13use super::types::{
14 CACHE_VERSION, CachedModule, DEFAULT_CACHE_MAX_SIZE, EVICTION_SIGNIFICANT_BPS,
15 EVICTION_TARGET_BPS, EVICTION_TRIGGER_BPS,
16};
17
18#[cfg(test)]
19thread_local! {
20 static FULL_STORE_ENCODE_COUNT: Cell<usize> = const { Cell::new(0) };
21}
22
23#[derive(Debug, Encode, Decode)]
25pub struct CacheStore {
26 version: u32,
27 config_hash: u64,
29 entries: FxHashMap<String, CachedModule>,
31}
32
33impl CacheStore {
34 #[must_use]
36 pub fn new() -> Self {
37 Self {
38 version: CACHE_VERSION,
39 config_hash: 0,
40 entries: FxHashMap::default(),
41 }
42 }
43
44 #[must_use]
49 pub fn load(
50 cache_dir: &Path,
51 expected_config_hash: u64,
52 max_size_bytes: usize,
53 ) -> Option<Self> {
54 let cache_file = cache_dir.join("cache.bin");
55 let data = std::fs::read(&cache_file).ok()?;
56 let safety_ceiling = max_size_bytes.max(DEFAULT_CACHE_MAX_SIZE);
57 if data.len() > safety_ceiling {
58 tracing::warn!(
59 size_mb = data.len() / (1024 * 1024),
60 ceiling_mb = safety_ceiling / (1024 * 1024),
61 "Cache file exceeds safety ceiling, ignoring"
62 );
63 return None;
64 }
65 let store: Self = match bitcode::decode(&data) {
66 Ok(s) => s,
67 Err(_) => {
68 tracing::info!(
69 "Cache format upgraded, rebuilding (one-time cost after version bump)"
70 );
71 return None;
72 }
73 };
74 if store.version != CACHE_VERSION {
75 tracing::info!("Cache format upgraded, rebuilding (one-time cost after version bump)");
76 return None;
77 }
78 if store.config_hash != expected_config_hash {
79 return None;
80 }
81 Some(store)
82 }
83
84 pub fn save(
86 &mut self,
87 cache_dir: &Path,
88 config_hash: u64,
89 max_size_bytes: usize,
90 ) -> Result<(), String> {
91 std::fs::create_dir_all(cache_dir)
92 .map_err(|e| format!("Failed to create cache dir: {e}"))?;
93 write_cache_gitignore(cache_dir)?;
94
95 self.config_hash = config_hash;
96 let initial_entries = self.entries.len();
97 let mut encoded = self.encode();
98
99 let trigger = (max_size_bytes / 10_000).saturating_mul(EVICTION_TRIGGER_BPS);
100 if encoded.len() > trigger {
101 let target = (max_size_bytes / 10_000).saturating_mul(EVICTION_TARGET_BPS);
102 encoded = self.evict_lru_to_target(target, encoded);
103 let evicted = initial_entries.saturating_sub(self.entries.len());
104 let final_size = encoded.len();
105 let significant_evicted =
106 initial_entries.saturating_mul(EVICTION_SIGNIFICANT_BPS) / 10_000;
107 if evicted >= significant_evicted && initial_entries > 0 {
108 tracing::info!(
109 evicted_entries = evicted,
110 remaining_entries = self.entries.len(),
111 final_size_kb = final_size / 1024,
112 max_size_kb = max_size_bytes / 1024,
113 "Cache eviction: removed oldest entries to stay under cap"
114 );
115 } else {
116 tracing::debug!(
117 evicted_entries = evicted,
118 remaining_entries = self.entries.len(),
119 final_size_kb = final_size / 1024,
120 max_size_kb = max_size_bytes / 1024,
121 "Cache eviction"
122 );
123 }
124 }
125
126 let cache_file = cache_dir.join("cache.bin");
127 atomic_write(&cache_file, &encoded)?;
128 Ok(())
129 }
130
131 fn evict_lru_to_target(&mut self, target_bytes: usize, mut encoded: Vec<u8>) -> Vec<u8> {
134 let mut order: Vec<(u64, String, usize)> = self
135 .entries
136 .iter()
137 .map(|(key, entry)| {
138 (
139 entry.last_access_secs,
140 key.clone(),
141 bitcode::encode(entry)
142 .len()
143 .saturating_add(key.len())
144 .max(1),
145 )
146 })
147 .collect();
148 order.sort();
149
150 const MAX_REFINEMENT_PASSES: usize = 2;
151 const ESTIMATE_SAFETY_BPS: usize = 9_800;
152 let mut idx = 0;
153 let mut estimated_remaining: usize = order
154 .iter()
155 .map(|(_, _, estimated_bytes)| estimated_bytes)
156 .sum();
157 for _ in 0..MAX_REFINEMENT_PASSES {
158 if encoded.len() <= target_bytes || self.entries.len() <= 1 {
159 break;
160 }
161
162 let estimated_budget = estimated_eviction_budget(
163 estimated_remaining,
164 target_bytes,
165 encoded.len(),
166 ESTIMATE_SAFETY_BPS,
167 );
168 let start_idx = idx;
169 while idx + 1 < order.len() && estimated_remaining > estimated_budget {
170 let (_, key, estimated_bytes) = &order[idx];
171 self.entries.remove(key);
172 estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
173 idx += 1;
174 }
175 if idx == start_idx && idx + 1 < order.len() {
176 let (_, key, estimated_bytes) = &order[idx];
177 self.entries.remove(key);
178 estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
179 idx += 1;
180 }
181 encoded = self.encode();
182 }
183
184 if encoded.len() > target_bytes && self.entries.len() > 1 {
185 let conservative_budget = target_bytes / 2;
186 while idx + 1 < order.len() && estimated_remaining > conservative_budget {
187 let (_, key, estimated_bytes) = &order[idx];
188 self.entries.remove(key);
189 estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
190 idx += 1;
191 }
192 encoded = self.encode();
193 }
194
195 if encoded.len() > target_bytes && self.entries.len() > 1 {
200 let keep_newest_from = order.len().saturating_sub(1);
201 for (_, key, _) in &order[idx..keep_newest_from] {
202 self.entries.remove(key);
203 }
204 encoded = self.encode();
205 }
206
207 if encoded.len() > target_bytes && self.entries.len() == 1 {
208 tracing::warn!(
209 encoded_kb = encoded.len() / 1024,
210 target_kb = target_bytes / 1024,
211 "Single cache entry exceeds configured max; cache will overshoot the cap"
212 );
213 }
214 encoded
215 }
216
217 fn encode(&self) -> Vec<u8> {
218 #[cfg(test)]
219 FULL_STORE_ENCODE_COUNT.with(|count| count.set(count.get() + 1));
220 bitcode::encode(self)
221 }
222
223 #[cfg(test)]
224 pub(super) fn reset_full_store_encode_count() {
225 FULL_STORE_ENCODE_COUNT.with(|count| count.set(0));
226 }
227
228 #[cfg(test)]
229 pub(super) fn full_store_encode_count() -> usize {
230 FULL_STORE_ENCODE_COUNT.with(Cell::get)
231 }
232
233 #[must_use]
236 pub fn get(&self, path: &Path, content_hash: u64) -> Option<&CachedModule> {
237 let key = path.to_string_lossy();
238 let entry = self.entries.get(key.as_ref())?;
239 if entry.content_hash == content_hash {
240 Some(entry)
241 } else {
242 None
243 }
244 }
245
246 pub fn insert(&mut self, path: &Path, module: CachedModule) {
248 let key = path.to_string_lossy().into_owned();
249 self.entries.insert(key, module);
250 }
251
252 #[must_use]
254 pub fn get_by_metadata(
255 &self,
256 path: &Path,
257 fingerprint: SourceFingerprint,
258 ) -> Option<&CachedModule> {
259 let key = path.to_string_lossy();
260 let entry = self.entries.get(key.as_ref())?;
261 if entry.source_fingerprint() == fingerprint && fingerprint.has_known_mtime() {
262 Some(entry)
263 } else {
264 None
265 }
266 }
267
268 #[must_use]
270 pub fn get_by_path_only(&self, path: &Path) -> Option<&CachedModule> {
271 let key = path.to_string_lossy();
272 self.entries.get(key.as_ref())
273 }
274
275 pub fn retain_paths(&mut self, files: &[fallow_types::discover::DiscoveredFile]) -> bool {
279 use rustc_hash::FxHashSet;
280 let current_paths: FxHashSet<String> = files
281 .iter()
282 .map(|f| f.path.to_string_lossy().to_string())
283 .collect();
284 let before = self.entries.len();
285 self.entries.retain(|key, _| current_paths.contains(key));
286 self.entries.len() != before
287 }
288
289 #[must_use]
291 pub fn len(&self) -> usize {
292 self.entries.len()
293 }
294
295 #[must_use]
297 pub fn is_empty(&self) -> bool {
298 self.entries.is_empty()
299 }
300}
301
302pub(super) fn estimated_eviction_budget(
303 estimated_remaining: usize,
304 target_bytes: usize,
305 encoded_bytes: usize,
306 safety_bps: usize,
307) -> usize {
308 if encoded_bytes == 0 {
309 return 0;
310 }
311
312 const BASIS_POINTS: u128 = 10_000;
313 let scaled = estimated_remaining as u128 * target_bytes as u128 / encoded_bytes as u128;
314 let safety = (safety_bps as u128).min(BASIS_POINTS);
315 let budget = scaled / BASIS_POINTS * safety + scaled % BASIS_POINTS * safety / BASIS_POINTS;
316 budget.min(usize::MAX as u128) as usize
317}
318
319fn write_cache_gitignore(cache_dir: &Path) -> Result<(), String> {
320 std::fs::write(cache_dir.join(".gitignore"), "*\n")
321 .map_err(|e| format!("Failed to write cache .gitignore: {e}"))
322}
323
324fn atomic_write(cache_file: &Path, data: &[u8]) -> Result<(), String> {
326 let tmp_file = match cache_file.file_name() {
327 Some(name) => cache_file.with_file_name({
328 let mut s = name.to_os_string();
329 s.push(".tmp");
330 s
331 }),
332 None => return Err("Cache file path has no filename component".to_owned()),
333 };
334
335 {
336 use std::io::Write as _;
337 let mut f = std::fs::File::create(&tmp_file)
338 .map_err(|e| format!("Failed to create cache tmp: {e}"))?;
339 f.write_all(data)
340 .map_err(|e| format!("Failed to write cache tmp: {e}"))?;
341 let _ = f.sync_all();
342 }
343
344 std::fs::rename(&tmp_file, cache_file)
345 .map_err(|e| format!("Failed to rename cache tmp into place: {e}"))?;
346 Ok(())
347}
348
349impl Default for CacheStore {
350 fn default() -> Self {
351 Self::new()
352 }
353}