1use std::path::Path;
4
5#[cfg(test)]
6use std::cell::Cell;
7
8use fallow_types::cache_rejection::CacheRejection;
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)]
32pub struct CacheStore {
33 version: u32,
34 config_hash: u64,
36 root: String,
41 entries: FxHashMap<String, CachedModule>,
43}
44
45impl CacheStore {
46 #[must_use]
48 pub fn new(root: &Path) -> Self {
49 Self {
50 version: CACHE_VERSION,
51 config_hash: 0,
52 root: normalise_root(root),
53 entries: FxHashMap::default(),
54 }
55 }
56
57 pub fn load(
83 cache_dir: &Path,
84 root: &Path,
85 expected_config_hash: u64,
86 max_size_bytes: usize,
87 ) -> Result<Self, CacheRejection> {
88 let cache_file = cache_dir.join("cache.bin");
89 let data = std::fs::read(&cache_file).map_err(|error| {
90 if error.kind() == std::io::ErrorKind::NotFound {
91 return CacheRejection::Absent;
92 }
93 tracing::warn!("Cache file could not be read; check the path and permissions");
94 CacheRejection::Unreadable
95 })?;
96 let safety_ceiling = max_size_bytes.max(DEFAULT_CACHE_MAX_SIZE);
97 if data.len() > safety_ceiling {
98 tracing::warn!(
99 size_mb = data.len() / (1024 * 1024),
100 ceiling_mb = safety_ceiling / (1024 * 1024),
101 "Cache file exceeds safety ceiling, ignoring"
102 );
103 return Err(CacheRejection::Oversize {
104 size_bytes: data.len() as u64,
105 ceiling_bytes: safety_ceiling as u64,
106 });
107 }
108 let payload = read_header(&data)?;
109 let mut store: Self = match bitcode::decode(payload) {
110 Ok(s) => s,
111 Err(_) => {
112 tracing::warn!(
113 "Cache file carries the current format version but its payload could not be \
114 decoded, rebuilding"
115 );
116 return Err(CacheRejection::Undecodable);
117 }
118 };
119 if store.version != CACHE_VERSION {
122 tracing::warn!(
123 cached_version = store.version,
124 expected_version = CACHE_VERSION,
125 "Cache header and payload declare different format versions, rebuilding"
126 );
127 return Err(CacheRejection::VersionMismatch);
128 }
129 if store.config_hash != expected_config_hash {
130 tracing::warn!(
131 "Cache was built under different extraction config, rebuilding from cold"
132 );
133 return Err(CacheRejection::ConfigHashMismatch);
134 }
135 let current_root = normalise_root(root);
136 if store.root != current_root {
137 tracing::debug!(
138 cached_root = %store.root,
139 "Reusing a cache written under a different project root"
140 );
141 store.root = current_root;
142 }
143 Ok(store)
144 }
145
146 pub fn save(
148 &mut self,
149 cache_dir: &Path,
150 config_hash: u64,
151 max_size_bytes: usize,
152 ) -> Result<(), String> {
153 std::fs::create_dir_all(cache_dir)
154 .map_err(|e| format!("Failed to create cache dir: {e}"))?;
155 write_cache_gitignore(cache_dir)?;
156
157 self.config_hash = config_hash;
158 let initial_entries = self.entries.len();
159 let mut encoded = self.encode();
160
161 let trigger = (max_size_bytes / 10_000).saturating_mul(EVICTION_TRIGGER_BPS);
162 if encoded.len().saturating_add(CACHE_HEADER_LEN) > trigger {
163 let target = (max_size_bytes / 10_000)
166 .saturating_mul(EVICTION_TARGET_BPS)
167 .saturating_sub(CACHE_HEADER_LEN);
168 encoded = self.evict_lru_to_target(target, encoded);
169 let evicted = initial_entries.saturating_sub(self.entries.len());
170 let final_size = encoded.len();
171 let significant_evicted =
172 initial_entries.saturating_mul(EVICTION_SIGNIFICANT_BPS) / 10_000;
173 if evicted >= significant_evicted && initial_entries > 0 {
174 tracing::info!(
175 evicted_entries = evicted,
176 remaining_entries = self.entries.len(),
177 final_size_kb = final_size / 1024,
178 max_size_kb = max_size_bytes / 1024,
179 "Cache eviction: removed oldest entries to stay under cap"
180 );
181 } else {
182 tracing::debug!(
183 evicted_entries = evicted,
184 remaining_entries = self.entries.len(),
185 final_size_kb = final_size / 1024,
186 max_size_kb = max_size_bytes / 1024,
187 "Cache eviction"
188 );
189 }
190 }
191
192 let cache_file = cache_dir.join("cache.bin");
193 atomic_write(&cache_file, &framed(self.version, &encoded))?;
194 Ok(())
195 }
196
197 fn evict_lru_to_target(&mut self, target_bytes: usize, mut encoded: Vec<u8>) -> Vec<u8> {
200 let mut order: Vec<(u64, String, usize)> = self
201 .entries
202 .iter()
203 .map(|(key, entry)| {
204 (
205 entry.last_access_secs,
206 key.clone(),
207 bitcode::encode(entry)
208 .len()
209 .saturating_add(key.len())
210 .max(1),
211 )
212 })
213 .collect();
214 order.sort();
215
216 const MAX_REFINEMENT_PASSES: usize = 2;
217 const ESTIMATE_SAFETY_BPS: usize = 9_800;
218 let mut idx = 0;
219 let mut estimated_remaining: usize = order
220 .iter()
221 .map(|(_, _, estimated_bytes)| estimated_bytes)
222 .sum();
223 for _ in 0..MAX_REFINEMENT_PASSES {
224 if encoded.len() <= target_bytes || self.entries.len() <= 1 {
225 break;
226 }
227
228 let estimated_budget = estimated_eviction_budget(
229 estimated_remaining,
230 target_bytes,
231 encoded.len(),
232 ESTIMATE_SAFETY_BPS,
233 );
234 let start_idx = idx;
235 while idx + 1 < order.len() && estimated_remaining > estimated_budget {
236 let (_, key, estimated_bytes) = &order[idx];
237 self.entries.remove(key);
238 estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
239 idx += 1;
240 }
241 if idx == start_idx && idx + 1 < order.len() {
242 let (_, key, estimated_bytes) = &order[idx];
243 self.entries.remove(key);
244 estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
245 idx += 1;
246 }
247 encoded = self.encode();
248 }
249
250 if encoded.len() > target_bytes && self.entries.len() > 1 {
251 let conservative_budget = target_bytes / 2;
252 while idx + 1 < order.len() && estimated_remaining > conservative_budget {
253 let (_, key, estimated_bytes) = &order[idx];
254 self.entries.remove(key);
255 estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
256 idx += 1;
257 }
258 encoded = self.encode();
259 }
260
261 if encoded.len() > target_bytes && self.entries.len() > 1 {
266 let keep_newest_from = order.len().saturating_sub(1);
267 for (_, key, _) in &order[idx..keep_newest_from] {
268 self.entries.remove(key);
269 }
270 encoded = self.encode();
271 }
272
273 if encoded.len() > target_bytes && self.entries.len() == 1 {
274 tracing::warn!(
275 encoded_kb = encoded.len() / 1024,
276 target_kb = target_bytes / 1024,
277 "Single cache entry exceeds configured max; cache will overshoot the cap"
278 );
279 }
280 encoded
281 }
282
283 fn encode(&self) -> Vec<u8> {
284 #[cfg(test)]
285 FULL_STORE_ENCODE_COUNT.with(|count| count.set(count.get() + 1));
286 bitcode::encode(self)
287 }
288
289 #[cfg(test)]
290 pub(super) fn reset_full_store_encode_count() {
291 FULL_STORE_ENCODE_COUNT.with(|count| count.set(0));
292 }
293
294 #[cfg(test)]
295 pub(super) fn full_store_encode_count() -> usize {
296 FULL_STORE_ENCODE_COUNT.with(Cell::get)
297 }
298
299 fn key_for(&self, path: &Path) -> String {
306 let text = path.to_string_lossy().replace('\\', "/");
307 if self.root.is_empty() {
308 return text;
309 }
310 match text
311 .strip_prefix(&self.root)
312 .and_then(|rest| rest.strip_prefix('/'))
313 {
314 Some(relative) => relative.to_owned(),
315 None => text,
316 }
317 }
318
319 fn path_for_key(&self, key: &str) -> std::path::PathBuf {
321 if self.root.is_empty() {
322 return std::path::PathBuf::from(key);
323 }
324 let candidate = Path::new(key);
325 if candidate.is_absolute() {
326 return candidate.to_path_buf();
327 }
328 Path::new(&self.root).join(key)
329 }
330
331 #[must_use]
334 pub fn get(&self, path: &Path, content_hash: u64) -> Option<&CachedModule> {
335 let entry = self.entries.get(&self.key_for(path))?;
336 if entry.content_hash == content_hash {
337 Some(entry)
338 } else {
339 None
340 }
341 }
342
343 pub fn insert(&mut self, path: &Path, module: CachedModule) {
345 let key = self.key_for(path);
346 self.entries.insert(key, module);
347 }
348
349 #[must_use]
351 pub fn get_by_path_only(&self, path: &Path) -> Option<&CachedModule> {
352 self.entries.get(&self.key_for(path))
353 }
354
355 pub fn retain_paths(&mut self, files: &[fallow_types::discover::DiscoveredFile]) -> bool {
371 use rustc_hash::FxHashSet;
372 let current_keys: FxHashSet<String> = files.iter().map(|f| self.key_for(&f.path)).collect();
373 let before = self.entries.len();
374 let retained: FxHashSet<String> = self
375 .entries
376 .keys()
377 .filter(|key| {
378 current_keys.contains(*key)
379 || std::fs::symlink_metadata(self.path_for_key(key)).is_ok()
380 })
381 .cloned()
382 .collect();
383 self.entries.retain(|key, _| retained.contains(key));
384 self.entries.len() != before
385 }
386
387 #[must_use]
389 pub fn len(&self) -> usize {
390 self.entries.len()
391 }
392
393 #[must_use]
395 pub fn is_empty(&self) -> bool {
396 self.entries.is_empty()
397 }
398}
399
400pub(super) const CACHE_MAGIC: [u8; 4] = *b"FLWX";
407
408pub(super) const CACHE_HEADER_LEN: usize = CACHE_MAGIC.len() + 4;
411
412pub(super) fn framed(version: u32, payload: &[u8]) -> Vec<u8> {
417 let mut framed = Vec::with_capacity(CACHE_HEADER_LEN + payload.len());
418 framed.extend_from_slice(&CACHE_MAGIC);
419 framed.extend_from_slice(&version.to_le_bytes());
420 framed.extend_from_slice(payload);
421 framed
422}
423
424fn read_header(data: &[u8]) -> Result<&[u8], CacheRejection> {
437 let Some((header, payload)) = data.split_at_checked(CACHE_HEADER_LEN) else {
438 tracing::warn!("Cache file is too short to carry a format header, rebuilding");
439 return Err(CacheRejection::Undecodable);
440 };
441 let (declared_magic, declared_version) = header.split_at(CACHE_MAGIC.len());
442 if declared_magic != CACHE_MAGIC {
443 tracing::warn!("Cache file does not carry fallow's cache framing, rebuilding");
444 return Err(CacheRejection::Undecodable);
445 }
446 let declared = declared_version.try_into().map_or(0, u32::from_le_bytes);
450 if declared != CACHE_VERSION {
451 tracing::warn!(
452 cached_version = declared,
453 expected_version = CACHE_VERSION,
454 "Cache format upgraded, rebuilding (one-time cost after version bump)"
455 );
456 return Err(CacheRejection::VersionMismatch);
457 }
458 Ok(payload)
459}
460
461pub(super) fn estimated_eviction_budget(
462 estimated_remaining: usize,
463 target_bytes: usize,
464 encoded_bytes: usize,
465 safety_bps: usize,
466) -> usize {
467 if encoded_bytes == 0 {
468 return 0;
469 }
470
471 const BASIS_POINTS: u128 = 10_000;
472 let scaled = estimated_remaining as u128 * target_bytes as u128 / encoded_bytes as u128;
473 let safety = (safety_bps as u128).min(BASIS_POINTS);
474 let budget = scaled / BASIS_POINTS * safety + scaled % BASIS_POINTS * safety / BASIS_POINTS;
475 budget.min(usize::MAX as u128) as usize
476}
477
478fn normalise_root(root: &Path) -> String {
482 let text = root.to_string_lossy().replace('\\', "/");
483 match text.strip_suffix('/') {
484 Some(trimmed) => trimmed.to_owned(),
485 None => text,
486 }
487}
488
489fn write_cache_gitignore(cache_dir: &Path) -> Result<(), String> {
490 std::fs::write(cache_dir.join(".gitignore"), "*\n")
491 .map_err(|e| format!("Failed to write cache .gitignore: {e}"))
492}
493
494fn atomic_write(cache_file: &Path, data: &[u8]) -> Result<(), String> {
496 let tmp_file = match cache_file.file_name() {
497 Some(name) => cache_file.with_file_name({
498 let mut s = name.to_os_string();
499 s.push(".tmp");
500 s
501 }),
502 None => return Err("Cache file path has no filename component".to_owned()),
503 };
504
505 {
506 use std::io::Write as _;
507 let mut f = std::fs::File::create(&tmp_file)
508 .map_err(|e| format!("Failed to create cache tmp: {e}"))?;
509 f.write_all(data)
510 .map_err(|e| format!("Failed to write cache tmp: {e}"))?;
511 let _ = f.sync_all();
512 }
513
514 std::fs::rename(&tmp_file, cache_file)
515 .map_err(|e| format!("Failed to rename cache tmp into place: {e}"))?;
516 Ok(())
517}
518
519impl Default for CacheStore {
520 fn default() -> Self {
521 Self::new(Path::new(""))
522 }
523}