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_seen_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 pub fn refresh_fingerprint(
386 &mut self,
387 path: &Path,
388 fingerprint: fallow_types::source_fingerprint::SourceFingerprint,
389 ) -> bool {
390 let key = self.key_for(path);
391 let Some(entry) = self.entries.get_mut(&key) else {
392 return false;
393 };
394 entry.mtime_ns = fingerprint.mtime_ns;
395 entry.ctime_ns = fingerprint.ctime_ns;
396 entry.file_size = fingerprint.file_size;
397 true
398 }
399
400 #[must_use]
402 pub fn get_by_path_only(&self, path: &Path) -> Option<&CachedModule> {
403 self.entries.get(&self.key_for(path))
404 }
405
406 pub fn retain_paths(&mut self, files: &[fallow_types::discover::DiscoveredFile]) -> bool {
431 use rustc_hash::FxHashSet;
432 let current_keys: FxHashSet<String> = files.iter().map(|f| self.key_for(&f.path)).collect();
433 let before = self.entries.len();
434 let retained: FxHashSet<String> = self
435 .entries
436 .keys()
437 .filter(|key| {
438 current_keys.contains(*key)
439 || std::fs::symlink_metadata(self.path_for_key(key)).is_ok()
440 })
441 .cloned()
442 .collect();
443 self.entries.retain(|key, _| retained.contains(key));
444 let now = super::conversion::current_unix_seconds();
445 for key in ¤t_keys {
446 if let Some(entry) = self.entries.get_mut(key) {
447 entry.last_seen_secs = now;
448 }
449 }
450 self.entries.len() != before
451 }
452
453 #[must_use]
455 pub fn len(&self) -> usize {
456 self.entries.len()
457 }
458
459 #[must_use]
461 pub fn is_empty(&self) -> bool {
462 self.entries.is_empty()
463 }
464}
465
466pub(super) const CACHE_MAGIC: [u8; 4] = *b"FLWX";
473
474pub(super) const CACHE_HEADER_LEN: usize = CACHE_MAGIC.len() + 4;
477
478pub(super) fn framed(version: u32, payload: &[u8]) -> Vec<u8> {
483 let mut framed = Vec::with_capacity(CACHE_HEADER_LEN + payload.len());
484 framed.extend_from_slice(&CACHE_MAGIC);
485 framed.extend_from_slice(&version.to_le_bytes());
486 framed.extend_from_slice(payload);
487 framed
488}
489
490fn read_header(data: &[u8]) -> Result<&[u8], CacheRejection> {
503 let Some((header, payload)) = data.split_at_checked(CACHE_HEADER_LEN) else {
504 tracing::warn!("Cache file is too short to carry a format header, rebuilding");
505 return Err(CacheRejection::Undecodable);
506 };
507 let (declared_magic, declared_version) = header.split_at(CACHE_MAGIC.len());
508 if declared_magic != CACHE_MAGIC {
509 tracing::warn!("Cache file does not carry fallow's cache framing, rebuilding");
510 return Err(CacheRejection::Undecodable);
511 }
512 let declared = declared_version.try_into().map_or(0, u32::from_le_bytes);
516 if declared != CACHE_VERSION {
517 tracing::warn!(
518 cached_version = declared,
519 expected_version = CACHE_VERSION,
520 "Cache format upgraded, rebuilding (one-time cost after version bump)"
521 );
522 return Err(CacheRejection::VersionMismatch);
523 }
524 Ok(payload)
525}
526
527pub(super) fn estimated_eviction_budget(
528 estimated_remaining: usize,
529 target_bytes: usize,
530 encoded_bytes: usize,
531 safety_bps: usize,
532) -> usize {
533 if encoded_bytes == 0 {
534 return 0;
535 }
536
537 const BASIS_POINTS: u128 = 10_000;
538 let scaled = estimated_remaining as u128 * target_bytes as u128 / encoded_bytes as u128;
539 let safety = (safety_bps as u128).min(BASIS_POINTS);
540 let budget = scaled / BASIS_POINTS * safety + scaled % BASIS_POINTS * safety / BASIS_POINTS;
541 budget.min(usize::MAX as u128) as usize
542}
543
544fn normalise_root(root: &Path) -> String {
548 let text = root.to_string_lossy().replace('\\', "/");
549 match text.strip_suffix('/') {
550 Some(trimmed) => trimmed.to_owned(),
551 None => text,
552 }
553}
554
555fn write_cache_gitignore(cache_dir: &Path) -> Result<(), String> {
556 std::fs::write(cache_dir.join(".gitignore"), "*\n")
557 .map_err(|e| format!("Failed to write cache .gitignore: {e}"))
558}
559
560fn atomic_write(cache_file: &Path, data: &[u8]) -> Result<(), String> {
563 fallow_types::atomic_file::write_atomic(cache_file, data)
564 .map_err(|e| format!("Failed to write cache file: {e}"))
565}
566
567impl Default for CacheStore {
568 fn default() -> Self {
569 Self::new(Path::new(""))
570 }
571}