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 documents: Arc<DashMap<PathBuf, CachedDocument>>,
19 file_hashes: Arc<RwLock<HashMap<PathBuf, String>>>,
20 hit_count: Arc<RwLock<usize>>,
21 miss_count: Arc<RwLock<usize>>,
22 max_size_mb: usize,
23 expiration_duration: Duration,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27struct CachedDocument {
28 document: Document,
29 hash: String,
30 cached_at: DateTime<Utc>,
31 access_count: usize,
32 size_bytes: usize,
33}
34
35impl BuildCache {
36 pub fn new(
37 cache_dir: PathBuf,
38 max_size_mb: usize,
39 expiration_hours: u64,
40 config_fingerprint: &str,
41 ) -> Result<Self> {
42 std::fs::create_dir_all(&cache_dir)?;
43
44 let fingerprint_file = cache_dir.join(".config-fingerprint");
47 let stored = std::fs::read_to_string(&fingerprint_file).unwrap_or_default();
48 if stored.trim() != config_fingerprint {
49 if !stored.is_empty() {
50 debug!("Configuration changed; discarding cache");
51 }
52 std::fs::remove_dir_all(&cache_dir)?;
53 std::fs::create_dir_all(&cache_dir)?;
54 std::fs::write(&fingerprint_file, config_fingerprint)?;
55 }
56
57 let cache = Self {
58 cache_dir,
59 documents: Arc::new(DashMap::new()),
60 file_hashes: Arc::new(RwLock::new(HashMap::new())),
61 hit_count: Arc::new(RwLock::new(0)),
62 miss_count: Arc::new(RwLock::new(0)),
63 max_size_mb,
64 expiration_duration: Duration::from_secs(expiration_hours * 60 * 60),
65 };
66
67 cache.load_from_disk()?;
69
70 Ok(cache)
71 }
72
73 pub fn get_document(&self, file_path: &Path) -> Result<Document> {
74 let hash = self.calculate_file_hash(file_path)?;
75
76 let cached = self
80 .documents
81 .get(file_path)
82 .map(|c| (c.hash.clone(), c.cached_at, c.document.clone()));
83
84 if let Some((cached_hash, cached_at, document)) = cached {
85 if cached_hash == hash && !self.is_expired(&cached_at) {
86 self.documents.alter(file_path, |_, mut cached| {
88 cached.access_count += 1;
89 cached
90 });
91
92 *self.hit_count.write() += 1;
93 debug!("Cache hit for {}", file_path.display());
94 return Ok(document);
95 }
96 self.documents.remove(file_path);
98 }
99
100 *self.miss_count.write() += 1;
101 debug!("Cache miss for {}", file_path.display());
102 Err(BuildError::Cache("Document not found in cache".to_string()).into())
103 }
104
105 pub fn store_document(&self, file_path: &Path, document: &Document) -> Result<()> {
106 let hash = self.calculate_file_hash(file_path)?;
107 let size_bytes = self.estimate_document_size(document);
108
109 let cached_doc = CachedDocument {
110 document: document.clone(),
111 hash: hash.clone(),
112 cached_at: Utc::now(),
113 access_count: 1,
114 size_bytes,
115 };
116
117 self.evict_if_needed(size_bytes)?;
119
120 self.documents.insert(file_path.to_path_buf(), cached_doc);
121 self.file_hashes
122 .write()
123 .insert(file_path.to_path_buf(), hash.clone());
124
125 debug!(
126 "Cached document: {} ({} bytes)",
127 file_path.display(),
128 size_bytes
129 );
130
131 self.persist_to_disk(file_path, document)?;
133
134 Ok(())
135 }
136
137 #[allow(dead_code)]
138 pub fn invalidate(&self, file_path: &Path) {
139 self.documents.remove(file_path);
140 self.file_hashes.write().remove(file_path);
141
142 let cache_file = self.get_cache_file_path(file_path);
144 if cache_file.exists() {
145 if let Err(e) = std::fs::remove_file(&cache_file) {
146 warn!(
147 "Failed to remove cache file {}: {}",
148 cache_file.display(),
149 e
150 );
151 }
152 }
153
154 debug!("Invalidated cache for {}", file_path.display());
155 }
156
157 #[allow(dead_code)]
158 pub fn clear(&self) -> Result<()> {
159 self.documents.clear();
160 self.file_hashes.write().clear();
161 *self.hit_count.write() = 0;
162 *self.miss_count.write() = 0;
163
164 if self.cache_dir.exists() {
165 std::fs::remove_dir_all(&self.cache_dir)?;
166 std::fs::create_dir_all(&self.cache_dir)?;
167 }
168
169 debug!("Cleared all cache");
170 Ok(())
171 }
172
173 pub fn hit_count(&self) -> usize {
174 *self.hit_count.read()
175 }
176
177 #[allow(dead_code)]
178 pub fn miss_count(&self) -> usize {
179 *self.miss_count.read()
180 }
181
182 #[allow(dead_code)]
183 pub fn hit_ratio(&self) -> f64 {
184 let hits = *self.hit_count.read() as f64;
185 let misses = *self.miss_count.read() as f64;
186 if hits + misses > 0.0 {
187 hits / (hits + misses)
188 } else {
189 0.0
190 }
191 }
192
193 pub fn size_mb(&self) -> f64 {
194 let total_bytes: usize = self
195 .documents
196 .iter()
197 .map(|entry| entry.value().size_bytes)
198 .sum();
199 total_bytes as f64 / 1024.0 / 1024.0
200 }
201
202 fn calculate_file_hash(&self, file_path: &Path) -> Result<String> {
203 let content = std::fs::read(file_path)?;
204 let metadata = std::fs::metadata(file_path)?;
205
206 let mut hasher = Hasher::new();
207 hasher.update(&content);
208
209 if let Ok(modified) = metadata.modified() {
211 if let Ok(duration) = modified.duration_since(UNIX_EPOCH) {
212 hasher.update(&duration.as_secs().to_le_bytes());
213 }
214 }
215
216 Ok(hasher.finalize().to_hex().to_string())
217 }
218
219 fn is_expired(&self, cached_at: &DateTime<Utc>) -> bool {
220 let now = Utc::now();
221 let elapsed = now.signed_duration_since(*cached_at);
222 elapsed.num_seconds() > self.expiration_duration.as_secs() as i64
223 }
224
225 fn estimate_document_size(&self, document: &Document) -> usize {
226 document.html.len()
228 + document.title.len()
229 + document.source_path.to_string_lossy().len()
230 + document.output_path.to_string_lossy().len()
231 + 1024 }
233
234 fn evict_if_needed(&self, new_size: usize) -> Result<()> {
235 let current_size_mb = self.size_mb();
236 let new_size_mb = (new_size as f64) / 1024.0 / 1024.0;
237
238 if current_size_mb + new_size_mb > self.max_size_mb as f64 {
239 self.evict_least_accessed_entries(new_size_mb)?;
240 }
241
242 Ok(())
243 }
244
245 fn evict_least_accessed_entries(&self, space_needed_mb: f64) -> Result<()> {
248 let mut entries: Vec<_> = self
249 .documents
250 .iter()
251 .map(|entry| {
252 (
253 entry.key().clone(),
254 entry.value().access_count,
255 entry.value().size_bytes,
256 )
257 })
258 .collect();
259
260 entries.sort_by_key(|(_, access_count, _)| *access_count);
262
263 let mut space_freed_mb = 0.0;
264 for (path, _, size_bytes) in entries {
265 if space_freed_mb >= space_needed_mb {
266 break;
267 }
268
269 self.documents.remove(&path);
270 self.file_hashes.write().remove(&path);
271 space_freed_mb += (size_bytes as f64) / 1024.0 / 1024.0;
272
273 debug!(
274 "Evicted {} from cache ({} MB)",
275 path.display(),
276 size_bytes as f64 / 1024.0 / 1024.0
277 );
278 }
279
280 Ok(())
281 }
282
283 fn load_from_disk(&self) -> Result<()> {
284 if !self.cache_dir.exists() {
285 return Ok(());
286 }
287
288 for entry in std::fs::read_dir(&self.cache_dir)? {
289 let entry = entry?;
290 if entry.file_type()?.is_file()
291 && entry.path().extension().is_some_and(|ext| ext == "json")
292 {
293 if let Err(e) = self.load_cache_file(&entry.path()) {
294 warn!(
295 "Failed to load cache file {}: {}",
296 entry.path().display(),
297 e
298 );
299 }
300 }
301 }
302
303 debug!("Loaded {} documents from disk cache", self.documents.len());
304 Ok(())
305 }
306
307 fn load_cache_file(&self, cache_file: &Path) -> Result<()> {
308 let content = std::fs::read_to_string(cache_file)?;
309 let cached_doc: CachedDocument = serde_json::from_str(&content)?;
310
311 if !self.is_expired(&cached_doc.cached_at) {
313 let source_path = &cached_doc.document.source_path;
314 if source_path.exists() {
315 let current_hash = self.calculate_file_hash(source_path)?;
316 if current_hash == cached_doc.hash {
317 self.documents.insert(source_path.clone(), cached_doc);
318 }
319 }
320 }
321
322 Ok(())
323 }
324
325 fn persist_to_disk(&self, file_path: &Path, _document: &Document) -> Result<()> {
326 let cache_file = self.get_cache_file_path(file_path);
327 if let Some(parent) = cache_file.parent() {
328 std::fs::create_dir_all(parent)?;
329 }
330
331 if let Some(cached_doc) = self.documents.get(file_path) {
332 let content = serde_json::to_string_pretty(&*cached_doc)?;
333 std::fs::write(&cache_file, content)?;
334 }
335
336 Ok(())
337 }
338
339 fn get_cache_file_path(&self, file_path: &Path) -> PathBuf {
340 let hash = blake3::hash(file_path.to_string_lossy().as_bytes());
341 let filename = format!("{}.json", hash.to_hex());
342 self.cache_dir.join(filename)
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use tempfile::TempDir;
350
351 fn make_document(source: &Path) -> Document {
352 let mut doc = Document::new(source.to_path_buf(), source.with_extension("html"));
353 doc.html = "<html><body>cached</body></html>".to_string();
354 doc.source_mtime = Utc::now();
355 doc
356 }
357
358 #[test]
359 fn roundtrip_preserves_rendered_html() {
360 let tmp = TempDir::new().unwrap();
361 let source = tmp.path().join("page.rst");
362 std::fs::write(&source, "Page\n----\n").unwrap();
363
364 let cache = BuildCache::new(tmp.path().join("cache"), 500, 24, "fp-1").unwrap();
365 cache
366 .store_document(&source, &make_document(&source))
367 .unwrap();
368
369 let restored = cache.get_document(&source).unwrap();
370 assert_eq!(restored.html, "<html><body>cached</body></html>");
371 assert_eq!(cache.hit_count(), 1);
372 }
373
374 #[test]
375 fn warm_hit_does_not_deadlock() {
376 let tmp = TempDir::new().unwrap();
379 let source = tmp.path().join("page.rst");
380 std::fs::write(&source, "Page\n----\n").unwrap();
381
382 let cache = BuildCache::new(tmp.path().join("cache"), 500, 24, "fp-1").unwrap();
383 cache
384 .store_document(&source, &make_document(&source))
385 .unwrap();
386 for _ in 0..3 {
387 cache.get_document(&source).unwrap();
388 }
389 assert_eq!(cache.hit_count(), 3);
390 }
391
392 #[test]
393 fn changed_fingerprint_discards_persisted_cache() {
394 let tmp = TempDir::new().unwrap();
395 let source = tmp.path().join("page.rst");
396 std::fs::write(&source, "Page\n----\n").unwrap();
397 let cache_dir = tmp.path().join("cache");
398
399 {
400 let cache = BuildCache::new(cache_dir.clone(), 500, 24, "fp-1").unwrap();
401 cache
402 .store_document(&source, &make_document(&source))
403 .unwrap();
404 }
405
406 {
408 let cache = BuildCache::new(cache_dir.clone(), 500, 24, "fp-1").unwrap();
409 assert!(cache.get_document(&source).is_ok());
410 }
411
412 {
414 let cache = BuildCache::new(cache_dir, 500, 24, "fp-2").unwrap();
415 assert!(cache.get_document(&source).is_err());
416 }
417 }
418
419 #[test]
420 fn expiration_hours_are_plumbed() {
421 let tmp = TempDir::new().unwrap();
422 let source = tmp.path().join("page.rst");
423 std::fs::write(&source, "Page\n----\n").unwrap();
424
425 let cache = BuildCache::new(tmp.path().join("cache"), 500, 0, "fp-1").unwrap();
427 cache
428 .store_document(&source, &make_document(&source))
429 .unwrap();
430 std::thread::sleep(std::time::Duration::from_millis(1100));
431 assert!(
432 cache.get_document(&source).is_err(),
433 "entries must expire per the configured horizon"
434 );
435 }
436}