1use anyhow::Result;
2use blake3::Hasher;
3use chrono::{DateTime, Utc};
4use dashmap::DashMap;
5use log::{debug, warn};
6use parking_lot::RwLock;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::{Duration, UNIX_EPOCH};
12
13use crate::document::Document;
14use crate::error::BuildError;
15
16pub struct BuildCache {
17 cache_dir: PathBuf,
18 config_fingerprint: String,
19 config_changed: bool,
20 documents: Arc<DashMap<PathBuf, CachedDocument>>,
21 file_hashes: Arc<RwLock<HashMap<PathBuf, String>>>,
22 hit_count: Arc<RwLock<usize>>,
23 miss_count: Arc<RwLock<usize>>,
24 max_size_mb: usize,
25 expiration_duration: Duration,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29struct CachedDocument {
30 document: Document,
31 hash: String,
32 cached_at: DateTime<Utc>,
33 access_count: usize,
34 size_bytes: usize,
35}
36
37impl BuildCache {
38 pub fn new(
39 cache_dir: PathBuf,
40 max_size_mb: usize,
41 expiration_hours: u64,
42 config_fingerprint: &str,
43 ) -> Result<Self> {
44 std::fs::create_dir_all(&cache_dir)?;
45
46 let fingerprint_file = cache_dir.join(".config-fingerprint");
49 let stored = std::fs::read_to_string(&fingerprint_file).unwrap_or_default();
50 let config_changed = stored.trim() != config_fingerprint;
51 if config_changed {
52 if !stored.is_empty() {
53 debug!("Configuration changed; discarding cache");
54 }
55 std::fs::remove_dir_all(&cache_dir)?;
56 std::fs::create_dir_all(&cache_dir)?;
57 std::fs::write(&fingerprint_file, config_fingerprint)?;
58 }
59
60 let cache = Self {
61 cache_dir,
62 config_fingerprint: config_fingerprint.to_string(),
63 config_changed,
64 documents: Arc::new(DashMap::new()),
65 file_hashes: Arc::new(RwLock::new(HashMap::new())),
66 hit_count: Arc::new(RwLock::new(0)),
67 miss_count: Arc::new(RwLock::new(0)),
68 max_size_mb,
69 expiration_duration: Duration::from_secs(expiration_hours * 60 * 60),
70 };
71
72 cache.load_from_disk()?;
74
75 Ok(cache)
76 }
77
78 pub fn cache_dir(&self) -> &Path {
84 &self.cache_dir
85 }
86
87 pub fn config_changed(&self) -> bool {
106 self.config_changed
107 }
108
109 pub fn get_document(&self, file_path: &Path) -> Result<Document> {
110 self.get_document_with(file_path, |_| Some(()))
111 .map(|(document, ())| document)
112 .ok_or_else(|| BuildError::Cache("Document not found in cache".to_string()).into())
113 }
114
115 pub fn get_document_with<T>(
126 &self,
127 file_path: &Path,
128 accept: impl FnOnce(&Document) -> Option<T>,
129 ) -> Option<(Document, T)> {
130 let hash = match self.calculate_file_hash(file_path) {
131 Ok(hash) => hash,
132 Err(_) => {
133 *self.miss_count.write() += 1;
134 return None;
135 }
136 };
137
138 let cached = self
142 .documents
143 .get(file_path)
144 .map(|c| (c.hash.clone(), c.cached_at, c.document.clone()));
145
146 if let Some((cached_hash, cached_at, document)) = cached {
147 if cached_hash == hash && !self.is_expired(&cached_at) {
148 if let Some(extra) = accept(&document) {
149 self.documents.alter(file_path, |_, mut cached| {
151 cached.access_count += 1;
152 cached
153 });
154
155 *self.hit_count.write() += 1;
156 debug!("Cache hit for {}", file_path.display());
157 return Some((document, extra));
158 }
159 } else {
160 self.documents.remove(file_path);
164 }
165 }
166
167 *self.miss_count.write() += 1;
168 debug!("Cache miss for {}", file_path.display());
169 None
170 }
171
172 pub fn store_document(&self, file_path: &Path, document: &Document) -> Result<()> {
173 let hash = self.calculate_file_hash(file_path)?;
174 let size_bytes = self.estimate_document_size(document);
175
176 let cached_doc = CachedDocument {
177 document: document.clone(),
178 hash: hash.clone(),
179 cached_at: Utc::now(),
180 access_count: 1,
181 size_bytes,
182 };
183
184 self.evict_if_needed(size_bytes)?;
186
187 self.documents.insert(file_path.to_path_buf(), cached_doc);
188 self.file_hashes
189 .write()
190 .insert(file_path.to_path_buf(), hash.clone());
191
192 debug!(
193 "Cached document: {} ({} bytes)",
194 file_path.display(),
195 size_bytes
196 );
197
198 self.persist_to_disk(file_path, document)?;
200
201 Ok(())
202 }
203
204 #[allow(dead_code)]
205 pub fn invalidate(&self, file_path: &Path) {
206 self.documents.remove(file_path);
207 self.file_hashes.write().remove(file_path);
208
209 let cache_file = self.get_cache_file_path(file_path);
211 if cache_file.exists() {
212 if let Err(e) = std::fs::remove_file(&cache_file) {
213 warn!(
214 "Failed to remove cache file {}: {}",
215 cache_file.display(),
216 e
217 );
218 }
219 }
220
221 debug!("Invalidated cache for {}", file_path.display());
222 }
223
224 #[allow(dead_code)]
232 pub fn clear(&self) -> Result<()> {
233 self.documents.clear();
234 self.file_hashes.write().clear();
235 *self.hit_count.write() = 0;
236 *self.miss_count.write() = 0;
237
238 if self.cache_dir.exists() {
239 std::fs::remove_dir_all(&self.cache_dir)?;
240 }
241 std::fs::create_dir_all(&self.cache_dir)?;
242 std::fs::write(
243 self.cache_dir.join(".config-fingerprint"),
244 &self.config_fingerprint,
245 )?;
246
247 debug!("Cleared all cache");
248 Ok(())
249 }
250
251 pub fn hit_count(&self) -> usize {
252 *self.hit_count.read()
253 }
254
255 #[allow(dead_code)]
256 pub fn miss_count(&self) -> usize {
257 *self.miss_count.read()
258 }
259
260 #[allow(dead_code)]
261 pub fn hit_ratio(&self) -> f64 {
262 let hits = *self.hit_count.read() as f64;
263 let misses = *self.miss_count.read() as f64;
264 if hits + misses > 0.0 {
265 hits / (hits + misses)
266 } else {
267 0.0
268 }
269 }
270
271 pub fn size_mb(&self) -> f64 {
272 let total_bytes: usize = self
273 .documents
274 .iter()
275 .map(|entry| entry.value().size_bytes)
276 .sum();
277 total_bytes as f64 / 1024.0 / 1024.0
278 }
279
280 fn calculate_file_hash(&self, file_path: &Path) -> Result<String> {
281 let content = std::fs::read(file_path)?;
282 let metadata = std::fs::metadata(file_path)?;
283
284 let mut hasher = Hasher::new();
285 hasher.update(&content);
286
287 if let Ok(modified) = metadata.modified() {
289 if let Ok(duration) = modified.duration_since(UNIX_EPOCH) {
290 hasher.update(&duration.as_secs().to_le_bytes());
291 }
292 }
293
294 Ok(hasher.finalize().to_hex().to_string())
295 }
296
297 fn is_expired(&self, cached_at: &DateTime<Utc>) -> bool {
298 let now = Utc::now();
299 let elapsed = now.signed_duration_since(*cached_at);
300 elapsed.num_seconds() > self.expiration_duration.as_secs() as i64
301 }
302
303 fn estimate_document_size(&self, document: &Document) -> usize {
304 document.html.len()
306 + document.title.len()
307 + document.source_path.to_string_lossy().len()
308 + document.output_path.to_string_lossy().len()
309 + 1024 }
311
312 fn evict_if_needed(&self, new_size: usize) -> Result<()> {
313 let current_size_mb = self.size_mb();
314 let new_size_mb = (new_size as f64) / 1024.0 / 1024.0;
315
316 if current_size_mb + new_size_mb > self.max_size_mb as f64 {
317 self.evict_least_accessed_entries(new_size_mb)?;
318 }
319
320 Ok(())
321 }
322
323 fn evict_least_accessed_entries(&self, space_needed_mb: f64) -> Result<()> {
326 let mut entries: Vec<_> = self
327 .documents
328 .iter()
329 .map(|entry| {
330 (
331 entry.key().clone(),
332 entry.value().access_count,
333 entry.value().size_bytes,
334 )
335 })
336 .collect();
337
338 entries.sort_by_key(|(_, access_count, _)| *access_count);
340
341 let mut space_freed_mb = 0.0;
342 for (path, _, size_bytes) in entries {
343 if space_freed_mb >= space_needed_mb {
344 break;
345 }
346
347 self.documents.remove(&path);
348 self.file_hashes.write().remove(&path);
349 space_freed_mb += (size_bytes as f64) / 1024.0 / 1024.0;
350
351 debug!(
352 "Evicted {} from cache ({} MB)",
353 path.display(),
354 size_bytes as f64 / 1024.0 / 1024.0
355 );
356 }
357
358 Ok(())
359 }
360
361 fn load_from_disk(&self) -> Result<()> {
362 if !self.cache_dir.exists() {
363 return Ok(());
364 }
365
366 for entry in std::fs::read_dir(&self.cache_dir)? {
367 let entry = entry?;
368 if entry.file_type()?.is_file()
369 && entry.path().extension().is_some_and(|ext| ext == "json")
370 {
371 if let Err(e) = self.load_cache_file(&entry.path()) {
372 warn!(
373 "Failed to load cache file {}: {}",
374 entry.path().display(),
375 e
376 );
377 }
378 }
379 }
380
381 debug!("Loaded {} documents from disk cache", self.documents.len());
382 Ok(())
383 }
384
385 fn load_cache_file(&self, cache_file: &Path) -> Result<()> {
386 let content = std::fs::read_to_string(cache_file)?;
387 let cached_doc: CachedDocument = serde_json::from_str(&content)?;
388
389 if !self.is_expired(&cached_doc.cached_at) {
391 let source_path = &cached_doc.document.source_path;
392 if source_path.exists() {
393 let current_hash = self.calculate_file_hash(source_path)?;
394 if current_hash == cached_doc.hash {
395 self.documents.insert(source_path.clone(), cached_doc);
396 }
397 }
398 }
399
400 Ok(())
401 }
402
403 fn persist_to_disk(&self, file_path: &Path, _document: &Document) -> Result<()> {
404 let cache_file = self.get_cache_file_path(file_path);
405 if let Some(parent) = cache_file.parent() {
406 std::fs::create_dir_all(parent)?;
407 }
408
409 if let Some(cached_doc) = self.documents.get(file_path) {
410 let content = serde_json::to_string_pretty(&*cached_doc)?;
411 std::fs::write(&cache_file, content)?;
412 }
413
414 Ok(())
415 }
416
417 fn get_cache_file_path(&self, file_path: &Path) -> PathBuf {
418 let hash = blake3::hash(file_path.to_string_lossy().as_bytes());
419 let filename = format!("{}.json", hash.to_hex());
420 self.cache_dir.join(filename)
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use tempfile::TempDir;
428
429 fn make_document(source: &Path) -> Document {
430 let mut doc = Document::new(source.to_path_buf(), source.with_extension("html"));
431 doc.html = "<html><body>cached</body></html>".to_string();
432 doc.source_mtime = Utc::now();
433 doc
434 }
435
436 #[test]
437 fn roundtrip_preserves_rendered_html() {
438 let tmp = TempDir::new().unwrap();
439 let source = tmp.path().join("page.rst");
440 std::fs::write(&source, "Page\n----\n").unwrap();
441
442 let cache = BuildCache::new(tmp.path().join("cache"), 500, 24, "fp-1").unwrap();
443 cache
444 .store_document(&source, &make_document(&source))
445 .unwrap();
446
447 let restored = cache.get_document(&source).unwrap();
448 assert_eq!(restored.html, "<html><body>cached</body></html>");
449 assert_eq!(cache.hit_count(), 1);
450 }
451
452 #[test]
453 fn warm_hit_does_not_deadlock() {
454 let tmp = TempDir::new().unwrap();
457 let source = tmp.path().join("page.rst");
458 std::fs::write(&source, "Page\n----\n").unwrap();
459
460 let cache = BuildCache::new(tmp.path().join("cache"), 500, 24, "fp-1").unwrap();
461 cache
462 .store_document(&source, &make_document(&source))
463 .unwrap();
464 for _ in 0..3 {
465 cache.get_document(&source).unwrap();
466 }
467 assert_eq!(cache.hit_count(), 3);
468 }
469
470 #[test]
471 fn caller_rejected_entry_counts_as_a_miss() {
472 let tmp = TempDir::new().unwrap();
473 let source = tmp.path().join("page.rst");
474 std::fs::write(&source, "Page\n----\n").unwrap();
475
476 let cache = BuildCache::new(tmp.path().join("cache"), 500, 24, "fp-1").unwrap();
477 cache
478 .store_document(&source, &make_document(&source))
479 .unwrap();
480
481 let rejected = cache.get_document_with(&source, |_| None::<()>);
482 assert!(rejected.is_none());
483 assert_eq!(cache.hit_count(), 0, "a rejected entry is not a hit");
484 assert_eq!(cache.miss_count(), 1);
485
486 let accepted = cache.get_document_with(&source, |doc| Some(doc.html.clone()));
488 assert_eq!(
489 accepted.map(|(_, html)| html).as_deref(),
490 Some("<html><body>cached</body></html>")
491 );
492 assert_eq!(cache.hit_count(), 1);
493 }
494
495 #[test]
496 fn changed_fingerprint_discards_persisted_cache() {
497 let tmp = TempDir::new().unwrap();
498 let source = tmp.path().join("page.rst");
499 std::fs::write(&source, "Page\n----\n").unwrap();
500 let cache_dir = tmp.path().join("cache");
501
502 {
503 let cache = BuildCache::new(cache_dir.clone(), 500, 24, "fp-1").unwrap();
504 cache
505 .store_document(&source, &make_document(&source))
506 .unwrap();
507 }
508
509 {
511 let cache = BuildCache::new(cache_dir.clone(), 500, 24, "fp-1").unwrap();
512 assert!(cache.get_document(&source).is_ok());
513 }
514
515 {
517 let cache = BuildCache::new(cache_dir, 500, 24, "fp-2").unwrap();
518 assert!(cache.get_document(&source).is_err());
519 }
520 }
521
522 #[test]
523 fn clearing_keeps_the_directory_claimed_by_this_configuration() {
524 let tmp = TempDir::new().unwrap();
525 let source = tmp.path().join("page.rst");
526 std::fs::write(&source, "Page\n----\n").unwrap();
527 let cache_dir = tmp.path().join("cache");
528
529 {
530 let cache = BuildCache::new(cache_dir.clone(), 500, 24, "fp-1").unwrap();
531 cache.clear().unwrap();
532 cache
534 .store_document(&source, &make_document(&source))
535 .unwrap();
536 }
537
538 let cache = BuildCache::new(cache_dir, 500, 24, "fp-1").unwrap();
539 assert!(
540 !cache.config_changed(),
541 "an emptied cache still belongs to the configuration that emptied it"
542 );
543 assert!(
544 cache.get_document(&source).is_ok(),
545 "a cleared cache that was refilled must survive to the next build"
546 );
547 }
548
549 #[test]
550 fn config_changed_reports_the_wipe() {
551 let tmp = TempDir::new().unwrap();
552 let cache_dir = tmp.path().join("cache");
553
554 assert!(BuildCache::new(cache_dir.clone(), 500, 24, "fp-1")
556 .unwrap()
557 .config_changed());
558 assert!(!BuildCache::new(cache_dir.clone(), 500, 24, "fp-1")
559 .unwrap()
560 .config_changed());
561 assert!(BuildCache::new(cache_dir, 500, 24, "fp-2")
562 .unwrap()
563 .config_changed());
564 }
565
566 #[test]
567 fn expiration_hours_are_plumbed() {
568 let tmp = TempDir::new().unwrap();
569 let source = tmp.path().join("page.rst");
570 std::fs::write(&source, "Page\n----\n").unwrap();
571
572 let cache = BuildCache::new(tmp.path().join("cache"), 500, 0, "fp-1").unwrap();
574 cache
575 .store_document(&source, &make_document(&source))
576 .unwrap();
577 std::thread::sleep(std::time::Duration::from_millis(1100));
578 assert!(
579 cache.get_document(&source).is_err(),
580 "entries must expire per the configured horizon"
581 );
582 }
583}