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 Self::load_counting_bytes(cache_dir, root, expected_config_hash, max_size_bytes).0
89 }
90
91 pub fn load_counting_bytes(
96 cache_dir: &Path,
97 root: &Path,
98 expected_config_hash: u64,
99 max_size_bytes: usize,
100 ) -> (Result<Self, CacheRejection>, u64) {
101 let cache_file = cache_dir.join("cache.bin");
102 let data = match std::fs::read(&cache_file) {
103 Ok(data) => data,
104 Err(error) => {
105 if error.kind() != std::io::ErrorKind::NotFound {
106 tracing::warn!("Cache file could not be read; check the path and permissions");
107 return (Err(CacheRejection::Unreadable), 0);
108 }
109 return (Err(CacheRejection::Absent), 0);
110 }
111 };
112 let bytes_read = data.len() as u64;
113 (
114 Self::decode_loaded(&data, root, expected_config_hash, max_size_bytes),
115 bytes_read,
116 )
117 }
118
119 fn decode_loaded(
120 data: &[u8],
121 root: &Path,
122 expected_config_hash: u64,
123 max_size_bytes: usize,
124 ) -> Result<Self, CacheRejection> {
125 let safety_ceiling = max_size_bytes.max(DEFAULT_CACHE_MAX_SIZE);
126 if data.len() > safety_ceiling {
127 tracing::warn!(
128 size_mb = data.len() / (1024 * 1024),
129 ceiling_mb = safety_ceiling / (1024 * 1024),
130 "Cache file exceeds safety ceiling, ignoring"
131 );
132 return Err(CacheRejection::Oversize {
133 size_bytes: data.len() as u64,
134 ceiling_bytes: safety_ceiling as u64,
135 });
136 }
137 let payload = read_header(data)?;
138 let mut store: Self = match bitcode::decode(payload) {
139 Ok(s) => s,
140 Err(_) => {
141 tracing::warn!(
142 "Cache file carries the current format version but its payload could not be \
143 decoded, rebuilding"
144 );
145 return Err(CacheRejection::Undecodable);
146 }
147 };
148 if store.version != CACHE_VERSION {
151 tracing::warn!(
152 cached_version = store.version,
153 expected_version = CACHE_VERSION,
154 "Cache header and payload declare different format versions, rebuilding"
155 );
156 return Err(CacheRejection::VersionMismatch);
157 }
158 if store.config_hash != expected_config_hash {
159 tracing::warn!(
160 "Cache was built under different extraction config, rebuilding from cold"
161 );
162 return Err(CacheRejection::ConfigHashMismatch);
163 }
164 let current_root = normalise_root(root);
165 if store.root != current_root {
166 tracing::debug!(
167 cached_root = %store.root,
168 "Reusing a cache written under a different project root"
169 );
170 store.root = current_root;
171 }
172 Ok(store)
173 }
174
175 pub fn save(
177 &mut self,
178 cache_dir: &Path,
179 config_hash: u64,
180 max_size_bytes: usize,
181 ) -> Result<(), String> {
182 std::fs::create_dir_all(cache_dir)
183 .map_err(|e| format!("Failed to create cache dir: {e}"))?;
184 write_cache_gitignore(cache_dir)?;
185
186 self.config_hash = config_hash;
187 let initial_entries = self.entries.len();
188 let mut encoded = self.encode();
189
190 let trigger = (max_size_bytes / 10_000).saturating_mul(EVICTION_TRIGGER_BPS);
191 if encoded.len().saturating_add(CACHE_HEADER_LEN) > trigger {
192 let target = (max_size_bytes / 10_000)
195 .saturating_mul(EVICTION_TARGET_BPS)
196 .saturating_sub(CACHE_HEADER_LEN);
197 encoded = self.evict_lru_to_target(target, encoded);
198 let evicted = initial_entries.saturating_sub(self.entries.len());
199 let final_size = encoded.len();
200 let significant_evicted =
201 initial_entries.saturating_mul(EVICTION_SIGNIFICANT_BPS) / 10_000;
202 if evicted >= significant_evicted && initial_entries > 0 {
203 tracing::info!(
204 evicted_entries = evicted,
205 remaining_entries = self.entries.len(),
206 final_size_kb = final_size / 1024,
207 max_size_kb = max_size_bytes / 1024,
208 "Cache eviction: removed oldest entries to stay under cap"
209 );
210 } else {
211 tracing::debug!(
212 evicted_entries = evicted,
213 remaining_entries = self.entries.len(),
214 final_size_kb = final_size / 1024,
215 max_size_kb = max_size_bytes / 1024,
216 "Cache eviction"
217 );
218 }
219 }
220
221 let cache_file = cache_dir.join("cache.bin");
222 atomic_write(&cache_file, &framed(self.version, &encoded))?;
223 Ok(())
224 }
225
226 fn evict_lru_to_target(&mut self, target_bytes: usize, mut encoded: Vec<u8>) -> Vec<u8> {
229 let mut order: Vec<(u64, String, usize)> = self
230 .entries
231 .iter()
232 .map(|(key, entry)| {
233 (
234 entry.last_access_secs,
235 key.clone(),
236 bitcode::encode(entry)
237 .len()
238 .saturating_add(key.len())
239 .max(1),
240 )
241 })
242 .collect();
243 order.sort();
244
245 const MAX_REFINEMENT_PASSES: usize = 2;
246 const ESTIMATE_SAFETY_BPS: usize = 9_800;
247 let mut idx = 0;
248 let mut estimated_remaining: usize = order
249 .iter()
250 .map(|(_, _, estimated_bytes)| estimated_bytes)
251 .sum();
252 for _ in 0..MAX_REFINEMENT_PASSES {
253 if encoded.len() <= target_bytes || self.entries.len() <= 1 {
254 break;
255 }
256
257 let estimated_budget = estimated_eviction_budget(
258 estimated_remaining,
259 target_bytes,
260 encoded.len(),
261 ESTIMATE_SAFETY_BPS,
262 );
263 let start_idx = idx;
264 while idx + 1 < order.len() && estimated_remaining > estimated_budget {
265 let (_, key, estimated_bytes) = &order[idx];
266 self.entries.remove(key);
267 estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
268 idx += 1;
269 }
270 if idx == start_idx && idx + 1 < order.len() {
271 let (_, key, estimated_bytes) = &order[idx];
272 self.entries.remove(key);
273 estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
274 idx += 1;
275 }
276 encoded = self.encode();
277 }
278
279 if encoded.len() > target_bytes && self.entries.len() > 1 {
280 let conservative_budget = target_bytes / 2;
281 while idx + 1 < order.len() && estimated_remaining > conservative_budget {
282 let (_, key, estimated_bytes) = &order[idx];
283 self.entries.remove(key);
284 estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
285 idx += 1;
286 }
287 encoded = self.encode();
288 }
289
290 if encoded.len() > target_bytes && self.entries.len() > 1 {
295 let keep_newest_from = order.len().saturating_sub(1);
296 for (_, key, _) in &order[idx..keep_newest_from] {
297 self.entries.remove(key);
298 }
299 encoded = self.encode();
300 }
301
302 if encoded.len() > target_bytes && self.entries.len() == 1 {
303 tracing::warn!(
304 encoded_kb = encoded.len() / 1024,
305 target_kb = target_bytes / 1024,
306 "Single cache entry exceeds configured max; cache will overshoot the cap"
307 );
308 }
309 encoded
310 }
311
312 fn encode(&self) -> Vec<u8> {
313 #[cfg(test)]
314 FULL_STORE_ENCODE_COUNT.with(|count| count.set(count.get() + 1));
315 bitcode::encode(self)
316 }
317
318 #[cfg(test)]
319 pub(super) fn reset_full_store_encode_count() {
320 FULL_STORE_ENCODE_COUNT.with(|count| count.set(0));
321 }
322
323 #[cfg(test)]
324 pub(super) fn full_store_encode_count() -> usize {
325 FULL_STORE_ENCODE_COUNT.with(Cell::get)
326 }
327
328 fn key_for(&self, path: &Path) -> String {
335 let text = path.to_string_lossy().replace('\\', "/");
336 if self.root.is_empty() {
337 return text;
338 }
339 match text
340 .strip_prefix(&self.root)
341 .and_then(|rest| rest.strip_prefix('/'))
342 {
343 Some(relative) => relative.to_owned(),
344 None => text,
345 }
346 }
347
348 fn path_for_key(&self, key: &str) -> std::path::PathBuf {
350 if self.root.is_empty() {
351 return std::path::PathBuf::from(key);
352 }
353 let candidate = Path::new(key);
354 if candidate.is_absolute() {
355 return candidate.to_path_buf();
356 }
357 Path::new(&self.root).join(key)
358 }
359
360 #[must_use]
363 pub fn get(&self, path: &Path, content_hash: u64) -> Option<&CachedModule> {
364 let entry = self.entries.get(&self.key_for(path))?;
365 if entry.content_hash == content_hash {
366 Some(entry)
367 } else {
368 None
369 }
370 }
371
372 pub fn insert(&mut self, path: &Path, module: CachedModule) {
374 let key = self.key_for(path);
375 self.entries.insert(key, module);
376 }
377
378 #[must_use]
380 pub fn get_by_path_only(&self, path: &Path) -> Option<&CachedModule> {
381 self.entries.get(&self.key_for(path))
382 }
383
384 pub fn retain_paths(&mut self, files: &[fallow_types::discover::DiscoveredFile]) -> bool {
400 use rustc_hash::FxHashSet;
401 let current_keys: FxHashSet<String> = files.iter().map(|f| self.key_for(&f.path)).collect();
402 let before = self.entries.len();
403 let retained: FxHashSet<String> = self
404 .entries
405 .keys()
406 .filter(|key| {
407 current_keys.contains(*key)
408 || std::fs::symlink_metadata(self.path_for_key(key)).is_ok()
409 })
410 .cloned()
411 .collect();
412 self.entries.retain(|key, _| retained.contains(key));
413 self.entries.len() != before
414 }
415
416 #[must_use]
418 pub fn len(&self) -> usize {
419 self.entries.len()
420 }
421
422 #[must_use]
424 pub fn is_empty(&self) -> bool {
425 self.entries.is_empty()
426 }
427}
428
429pub(super) const CACHE_MAGIC: [u8; 4] = *b"FLWX";
436
437pub(super) const CACHE_HEADER_LEN: usize = CACHE_MAGIC.len() + 4;
440
441pub(super) fn framed(version: u32, payload: &[u8]) -> Vec<u8> {
446 let mut framed = Vec::with_capacity(CACHE_HEADER_LEN + payload.len());
447 framed.extend_from_slice(&CACHE_MAGIC);
448 framed.extend_from_slice(&version.to_le_bytes());
449 framed.extend_from_slice(payload);
450 framed
451}
452
453fn read_header(data: &[u8]) -> Result<&[u8], CacheRejection> {
466 let Some((header, payload)) = data.split_at_checked(CACHE_HEADER_LEN) else {
467 tracing::warn!("Cache file is too short to carry a format header, rebuilding");
468 return Err(CacheRejection::Undecodable);
469 };
470 let (declared_magic, declared_version) = header.split_at(CACHE_MAGIC.len());
471 if declared_magic != CACHE_MAGIC {
472 tracing::warn!("Cache file does not carry fallow's cache framing, rebuilding");
473 return Err(CacheRejection::Undecodable);
474 }
475 let declared = declared_version.try_into().map_or(0, u32::from_le_bytes);
479 if declared != CACHE_VERSION {
480 tracing::warn!(
481 cached_version = declared,
482 expected_version = CACHE_VERSION,
483 "Cache format upgraded, rebuilding (one-time cost after version bump)"
484 );
485 return Err(CacheRejection::VersionMismatch);
486 }
487 Ok(payload)
488}
489
490pub(super) fn estimated_eviction_budget(
491 estimated_remaining: usize,
492 target_bytes: usize,
493 encoded_bytes: usize,
494 safety_bps: usize,
495) -> usize {
496 if encoded_bytes == 0 {
497 return 0;
498 }
499
500 const BASIS_POINTS: u128 = 10_000;
501 let scaled = estimated_remaining as u128 * target_bytes as u128 / encoded_bytes as u128;
502 let safety = (safety_bps as u128).min(BASIS_POINTS);
503 let budget = scaled / BASIS_POINTS * safety + scaled % BASIS_POINTS * safety / BASIS_POINTS;
504 budget.min(usize::MAX as u128) as usize
505}
506
507fn normalise_root(root: &Path) -> String {
511 let text = root.to_string_lossy().replace('\\', "/");
512 match text.strip_suffix('/') {
513 Some(trimmed) => trimmed.to_owned(),
514 None => text,
515 }
516}
517
518fn write_cache_gitignore(cache_dir: &Path) -> Result<(), String> {
519 std::fs::write(cache_dir.join(".gitignore"), "*\n")
520 .map_err(|e| format!("Failed to write cache .gitignore: {e}"))
521}
522
523fn atomic_write(cache_file: &Path, data: &[u8]) -> Result<(), String> {
525 let tmp_file = match cache_file.file_name() {
526 Some(name) => cache_file.with_file_name({
527 let mut s = name.to_os_string();
528 s.push(".tmp");
529 s
530 }),
531 None => return Err("Cache file path has no filename component".to_owned()),
532 };
533
534 {
535 use std::io::Write as _;
536 let mut f = std::fs::File::create(&tmp_file)
537 .map_err(|e| format!("Failed to create cache tmp: {e}"))?;
538 f.write_all(data)
539 .map_err(|e| format!("Failed to write cache tmp: {e}"))?;
540 let _ = f.sync_all();
541 }
542
543 std::fs::rename(&tmp_file, cache_file)
544 .map_err(|e| format!("Failed to rename cache tmp into place: {e}"))?;
545 Ok(())
546}
547
548impl Default for CacheStore {
549 fn default() -> Self {
550 Self::new(Path::new(""))
551 }
552}