Skip to main content

ssg/core/
cache.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Content fingerprinting for incremental builds.
5//!
6//! This module provides `BuildCache`, which tracks SHA-256-style
7//! fingerprints of content files so that only files modified since the
8//! last build need to be re-processed.
9//!
10//! # Overview
11//!
12//! 1. On startup, call `BuildCache::load` to read the previous
13//!    fingerprint map from `.ssg-cache.json`.
14//! 2. Call `BuildCache::changed_files` with the content directory to
15//!    obtain the list of files whose contents have changed (or are new).
16//! 3. After a successful build, call `BuildCache::update` to record
17//!    the current fingerprints, then `BuildCache::save` to persist
18//!    them to disk.
19//!
20//! # Example
21//!
22//! ```no_run
23//! use std::path::Path;
24//! use ssg::cache::BuildCache;
25//!
26//! let cache_path = Path::new(".ssg-cache.json");
27//! let content_dir = Path::new("content");
28//!
29//! let mut cache = BuildCache::load(cache_path).unwrap();
30//! let changed = cache.changed_files(content_dir).unwrap();
31//!
32//! // … build only `changed` files …
33//!
34//! cache.update(content_dir).unwrap();
35//! cache.save().unwrap();
36//! ```
37
38use std::collections::HashMap;
39use std::fs;
40use std::path::{Path, PathBuf};
41
42use anyhow::{Context, Result};
43use serde::{Deserialize, Serialize};
44
45/// Default name for the on-disk cache file.
46const DEFAULT_CACHE_FILE: &str = ".ssg-cache.json";
47
48/// Persisted fingerprint map used for incremental builds.
49///
50/// Each entry maps a file path (relative to the content directory) to a
51/// hex-encoded hash of that file's contents. Comparing the stored hash
52/// against the current hash tells us whether the file has changed.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct BuildCache {
55    /// Path to the cache file on disk.
56    #[serde(skip)]
57    cache_path: PathBuf,
58
59    /// Map from relative file paths to their content fingerprints.
60    fingerprints: HashMap<PathBuf, String>,
61}
62
63impl BuildCache {
64    // -----------------------------------------------------------------
65    // Construction / persistence
66    // -----------------------------------------------------------------
67
68    /// Load a previously saved cache from `cache_path`.
69    ///
70    /// If the file does not exist a fresh, empty cache is returned.
71    /// Any other I/O or parse error is propagated.
72    ///
73    /// # Examples
74    ///
75    /// ```rust
76    /// use ssg::cache::BuildCache;
77    /// use tempfile::tempdir;
78    ///
79    /// let dir = tempdir().unwrap();
80    /// let cache_path = dir.path().join(".ssg-cache.json");
81    /// // Loading a missing path returns a fresh, empty cache.
82    /// let cache = BuildCache::load(&cache_path).unwrap();
83    /// assert!(cache.is_empty());
84    /// ```
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the file exists but cannot be read or
89    /// contains invalid JSON.
90    pub fn load(cache_path: &Path) -> Result<Self> {
91        if !cache_path.exists() {
92            return Ok(Self {
93                cache_path: cache_path.to_path_buf(),
94                fingerprints: HashMap::new(),
95            });
96        }
97
98        fail_point!("cache::read", |_| {
99            anyhow::bail!("injected: cache::read")
100        });
101        let data = fs::read_to_string(cache_path).with_context(|| {
102            format!("failed to read cache file: {}", cache_path.display())
103        })?;
104
105        fail_point!("cache::parse", |_| {
106            anyhow::bail!("injected: cache::parse")
107        });
108        let mut cache: Self =
109            serde_json::from_str(&data).with_context(|| {
110                format!("failed to parse cache file: {}", cache_path.display())
111            })?;
112
113        cache.cache_path = cache_path.to_path_buf();
114        Ok(cache)
115    }
116
117    /// Create a new empty cache that will be written to `cache_path`.
118    ///
119    /// # Examples
120    ///
121    /// ```rust
122    /// use ssg::cache::BuildCache;
123    /// use std::path::Path;
124    ///
125    /// let cache = BuildCache::new(Path::new(".ssg-cache.json"));
126    /// assert_eq!(cache.len(), 0);
127    /// ```
128    #[must_use]
129    pub fn new(cache_path: &Path) -> Self {
130        Self {
131            cache_path: cache_path.to_path_buf(),
132            fingerprints: HashMap::new(),
133        }
134    }
135
136    /// Persist the current fingerprint map to the cache file.
137    ///
138    /// # Examples
139    ///
140    /// ```rust
141    /// use ssg::cache::BuildCache;
142    /// use tempfile::tempdir;
143    ///
144    /// let dir = tempdir().unwrap();
145    /// let cache = BuildCache::new(&dir.path().join("cache.json"));
146    /// cache.save().unwrap();
147    /// assert!(dir.path().join("cache.json").exists());
148    /// ```
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the file cannot be written.
153    pub fn save(&self) -> Result<()> {
154        let json = serde_json::to_string_pretty(self)
155            .context("failed to serialize cache")?;
156        fail_point!("cache::write", |_| {
157            anyhow::bail!("injected: cache::write")
158        });
159        fs::write(&self.cache_path, json).with_context(|| {
160            format!("failed to write cache file: {}", self.cache_path.display())
161        })?;
162        Ok(())
163    }
164
165    // -----------------------------------------------------------------
166    // Fingerprinting helpers
167    // -----------------------------------------------------------------
168
169    /// Compute a deterministic hex fingerprint of the given file.
170    ///
171    /// Uses streaming I/O via `stream::stream_hash` — reads in 8 KB
172    /// chunks so memory usage is constant regardless of file size.
173    fn fingerprint(path: &Path) -> Result<String> {
174        crate::stream::stream_hash(path)
175    }
176
177    /// Recursively collect all files under `dir`, returning paths
178    /// relative to `dir`.
179    fn collect_files(dir: &Path) -> Result<Vec<PathBuf>> {
180        let mut files = Vec::new();
181        if !dir.exists() {
182            return Ok(files);
183        }
184        Self::walk(dir, dir, &mut files)?;
185        files.sort();
186        Ok(files)
187    }
188
189    /// Recursive directory walker.
190    fn walk(base: &Path, current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
191        let entries = fs::read_dir(current).with_context(|| {
192            format!("cannot read directory: {}", current.display())
193        })?;
194        for entry in entries {
195            let entry = entry?;
196            let path = entry.path();
197            if path.is_dir() {
198                Self::walk(base, &path, out)?;
199            } else {
200                let rel = path
201                    .strip_prefix(base)
202                    .with_context(|| "strip_prefix failed")?;
203                out.push(rel.to_path_buf());
204            }
205        }
206        Ok(())
207    }
208
209    // -----------------------------------------------------------------
210    // Public query / mutation API
211    // -----------------------------------------------------------------
212
213    /// Return the set of files in `content_dir` that have changed since
214    /// the fingerprints were last recorded, plus any newly added files.
215    ///
216    /// Deleted files (present in cache but absent on disk) are *not*
217    /// included in the returned list, but they will be removed from the
218    /// internal map on the next [`update`](Self::update) call.
219    ///
220    /// The returned paths are **absolute**.
221    ///
222    /// # Examples
223    ///
224    /// ```rust
225    /// use ssg::cache::BuildCache;
226    /// use tempfile::tempdir;
227    /// use std::fs;
228    ///
229    /// let dir = tempdir().unwrap();
230    /// let content = dir.path().join("content");
231    /// fs::create_dir(&content).unwrap();
232    /// fs::write(content.join("a.md"), "hello").unwrap();
233    /// let cache = BuildCache::new(&dir.path().join("cache.json"));
234    /// // Every file is "changed" against an empty cache.
235    /// let changed = cache.changed_files(&content).unwrap();
236    /// assert_eq!(changed.len(), 1);
237    /// ```
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if `content_dir` cannot be read or individual
242    /// files cannot be hashed.
243    pub fn changed_files(&self, content_dir: &Path) -> Result<Vec<PathBuf>> {
244        let files = Self::collect_files(content_dir)?;
245        let mut changed = Vec::new();
246
247        for rel in &files {
248            let abs = content_dir.join(rel);
249            let hash = Self::fingerprint(&abs)?;
250
251            match self.fingerprints.get(rel) {
252                Some(cached) if *cached == hash => {
253                    // unchanged -- skip
254                }
255                _ => {
256                    changed.push(abs);
257                }
258            }
259        }
260
261        Ok(changed)
262    }
263
264    /// Re-scan `content_dir` and replace the entire fingerprint map
265    /// with fresh hashes.
266    ///
267    /// Call this after a successful build so the next invocation of
268    /// [`changed_files`](Self::changed_files) reflects the new state.
269    ///
270    /// # Examples
271    ///
272    /// ```rust
273    /// use ssg::cache::BuildCache;
274    /// use tempfile::tempdir;
275    /// use std::fs;
276    ///
277    /// let dir = tempdir().unwrap();
278    /// let content = dir.path().join("content");
279    /// fs::create_dir(&content).unwrap();
280    /// fs::write(content.join("a.md"), "hi").unwrap();
281    /// let mut cache = BuildCache::new(&dir.path().join("cache.json"));
282    /// cache.update(&content).unwrap();
283    /// assert_eq!(cache.len(), 1);
284    /// ```
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if files cannot be read.
289    pub fn update(&mut self, content_dir: &Path) -> Result<()> {
290        let files = Self::collect_files(content_dir)?;
291        let mut map = HashMap::with_capacity(files.len());
292
293        for rel in files {
294            let abs = content_dir.join(&rel);
295            let hash = Self::fingerprint(&abs)?;
296            let _prev = map.insert(rel, hash);
297        }
298
299        self.fingerprints = map;
300        Ok(())
301    }
302
303    /// Return the number of entries currently in the fingerprint map.
304    ///
305    /// # Examples
306    ///
307    /// ```rust
308    /// use ssg::cache::BuildCache;
309    /// use std::path::Path;
310    ///
311    /// let cache = BuildCache::new(Path::new("cache.json"));
312    /// assert_eq!(cache.len(), 0);
313    /// ```
314    #[must_use]
315    pub fn len(&self) -> usize {
316        self.fingerprints.len()
317    }
318
319    /// Return `true` if the fingerprint map is empty.
320    ///
321    /// # Examples
322    ///
323    /// ```rust
324    /// use ssg::cache::BuildCache;
325    /// use std::path::Path;
326    ///
327    /// let cache = BuildCache::new(Path::new("cache.json"));
328    /// assert!(cache.is_empty());
329    /// ```
330    #[must_use]
331    pub fn is_empty(&self) -> bool {
332        self.fingerprints.is_empty()
333    }
334
335    /// Return the path to the default cache file relative to the
336    /// project root.
337    ///
338    /// # Examples
339    ///
340    /// ```rust
341    /// use ssg::cache::BuildCache;
342    ///
343    /// assert_eq!(BuildCache::default_path(), ".ssg-cache.json");
344    /// ```
345    #[must_use]
346    pub const fn default_path() -> &'static str {
347        DEFAULT_CACHE_FILE
348    }
349}
350
351// =====================================================================
352// Tests
353// =====================================================================
354#[cfg(test)]
355#[allow(unused_results, clippy::unwrap_used, clippy::expect_used)]
356mod tests {
357    use super::*;
358    use std::fs;
359    use tempfile::TempDir;
360
361    /// Helper: create a temp dir with a few content files and return
362    /// `(tmp_dir, content_dir, cache_path)`.
363    fn setup() -> (TempDir, PathBuf, PathBuf) {
364        let tmp = TempDir::new().ok().unwrap();
365        let content = tmp.path().join("content");
366        fs::create_dir_all(&content).ok();
367        let cache_path = tmp.path().join(".ssg-cache.json");
368        (tmp, content, cache_path)
369    }
370
371    fn write_file(dir: &Path, name: &str, contents: &str) {
372        let p = dir.join(name);
373        if let Some(parent) = p.parent() {
374            fs::create_dir_all(parent).ok();
375        }
376        fs::write(&p, contents).ok();
377    }
378
379    // 1. Loading a missing cache yields an empty map.
380    #[test]
381    #[serial_test::parallel(cache_failpoints)]
382    fn load_missing_cache() {
383        let tmp = TempDir::new().ok().unwrap();
384        let cache_path = tmp.path().join("nonexistent.json");
385        let cache = BuildCache::load(&cache_path).ok().unwrap();
386        assert!(cache.is_empty());
387    }
388
389    // 2. Loading a valid cache round-trips correctly.
390    #[test]
391    #[serial_test::parallel(cache_failpoints)]
392    fn load_valid_cache() {
393        let (_tmp, content, cache_path) = setup();
394        write_file(&content, "a.md", "hello");
395
396        let mut cache = BuildCache::load(&cache_path).ok().unwrap();
397        cache.update(&content).ok();
398        cache.save().ok();
399
400        let loaded = BuildCache::load(&cache_path).ok().unwrap();
401        assert_eq!(loaded.len(), 1);
402    }
403
404    // 3. Detect changed files.
405    #[test]
406    #[serial_test::parallel(cache_failpoints)]
407    fn detect_changes() {
408        let (_tmp, content, cache_path) = setup();
409        write_file(&content, "a.md", "v1");
410
411        let mut cache = BuildCache::load(&cache_path).ok().unwrap();
412        cache.update(&content).ok();
413        cache.save().ok();
414
415        // Modify the file.
416        write_file(&content, "a.md", "v2");
417
418        let cache2 = BuildCache::load(&cache_path).ok().unwrap();
419        let changed = cache2.changed_files(&content).ok().unwrap();
420        assert_eq!(changed.len(), 1);
421        assert!(changed[0].ends_with("a.md"));
422    }
423
424    // 4. No changes detected when content is identical.
425    #[test]
426    #[serial_test::parallel(cache_failpoints)]
427    fn detect_no_changes() {
428        let (_tmp, content, cache_path) = setup();
429        write_file(&content, "a.md", "same");
430
431        let mut cache = BuildCache::load(&cache_path).ok().unwrap();
432        cache.update(&content).ok();
433        cache.save().ok();
434
435        let cache2 = BuildCache::load(&cache_path).ok().unwrap();
436        let changed = cache2.changed_files(&content).ok().unwrap();
437        assert!(changed.is_empty(), "{:?}", changed);
438    }
439
440    // 5. New files appear as changed.
441    #[test]
442    #[serial_test::parallel(cache_failpoints)]
443    fn new_files_are_changed() {
444        let (_tmp, content, cache_path) = setup();
445        write_file(&content, "a.md", "hello");
446
447        let mut cache = BuildCache::load(&cache_path).ok().unwrap();
448        cache.update(&content).ok();
449        cache.save().ok();
450
451        // Add a new file.
452        write_file(&content, "b.md", "world");
453
454        let cache2 = BuildCache::load(&cache_path).ok().unwrap();
455        let changed = cache2.changed_files(&content).ok().unwrap();
456        assert_eq!(changed.len(), 1);
457        assert!(changed[0].ends_with("b.md"));
458    }
459
460    // 6. Deleted files are pruned from the map on update.
461    #[test]
462    #[serial_test::parallel(cache_failpoints)]
463    fn deleted_files_pruned() {
464        let (_tmp, content, cache_path) = setup();
465        write_file(&content, "a.md", "keep");
466        write_file(&content, "b.md", "delete-me");
467
468        let mut cache = BuildCache::load(&cache_path).ok().unwrap();
469        cache.update(&content).ok();
470        assert_eq!(cache.len(), 2);
471
472        // Delete one file.
473        fs::remove_file(content.join("b.md")).ok();
474
475        cache.update(&content).ok();
476        assert_eq!(cache.len(), 1);
477    }
478
479    // 7. Save / load round-trip preserves all entries.
480    #[test]
481    #[serial_test::parallel(cache_failpoints)]
482    fn save_load_roundtrip() {
483        let (_tmp, content, cache_path) = setup();
484        write_file(&content, "x.md", "data1");
485        write_file(&content, "sub/y.md", "data2");
486
487        let mut cache = BuildCache::new(&cache_path);
488        cache.update(&content).ok();
489        cache.save().ok();
490
491        let loaded = BuildCache::load(&cache_path).ok().unwrap();
492        assert_eq!(loaded.len(), 2);
493    }
494
495    // 8. Empty content directory yields no changed files.
496    #[test]
497    #[serial_test::parallel(cache_failpoints)]
498    fn empty_directory() {
499        let (_tmp, content, cache_path) = setup();
500        let cache = BuildCache::load(&cache_path).ok().unwrap();
501        let changed = cache.changed_files(&content).ok().unwrap();
502        assert!(changed.is_empty(), "{:?}", changed);
503    }
504
505    // 9. Non-existent content directory yields no changed files.
506    #[test]
507    #[serial_test::parallel(cache_failpoints)]
508    fn nonexistent_directory() {
509        let tmp = TempDir::new().ok().unwrap();
510        let cache_path = tmp.path().join(".ssg-cache.json");
511        let cache = BuildCache::load(&cache_path).ok().unwrap();
512        let changed =
513            cache.changed_files(&tmp.path().join("nope")).ok().unwrap();
514        assert!(changed.is_empty(), "{:?}", changed);
515    }
516
517    // 10. Fingerprint is deterministic for the same content.
518    #[test]
519    #[serial_test::parallel(cache_failpoints)]
520    fn fingerprint_deterministic() {
521        let tmp = TempDir::new().ok().unwrap();
522        let path = tmp.path().join("test.txt");
523        fs::write(&path, "deterministic").ok();
524
525        let h1 = BuildCache::fingerprint(&path).ok().unwrap();
526        let h2 = BuildCache::fingerprint(&path).ok().unwrap();
527        assert_eq!(h1, h2);
528    }
529
530    // 11. Different content produces different fingerprints.
531    #[test]
532    #[serial_test::parallel(cache_failpoints)]
533    fn fingerprint_varies_with_content() {
534        let tmp = TempDir::new().ok().unwrap();
535        let p1 = tmp.path().join("a.txt");
536        let p2 = tmp.path().join("b.txt");
537        fs::write(&p1, "alpha").ok();
538        fs::write(&p2, "beta").ok();
539
540        let h1 = BuildCache::fingerprint(&p1).ok().unwrap();
541        let h2 = BuildCache::fingerprint(&p2).ok().unwrap();
542        assert_ne!(h1, h2);
543    }
544
545    // 12. Subdirectory files are tracked correctly.
546    #[test]
547    #[serial_test::parallel(cache_failpoints)]
548    fn subdirectory_tracking() {
549        let (_tmp, content, cache_path) = setup();
550        write_file(&content, "posts/2024/hello.md", "hi");
551        write_file(&content, "pages/about.md", "about");
552
553        let mut cache = BuildCache::new(&cache_path);
554        cache.update(&content).ok();
555        assert_eq!(cache.len(), 2);
556
557        // Modify nested file.
558        write_file(&content, "posts/2024/hello.md", "updated");
559        let changed = cache.changed_files(&content).ok().unwrap();
560        assert_eq!(changed.len(), 1);
561    }
562
563    // 13. Corrupted JSON in cache file returns an error.
564    #[test]
565    #[serial_test::parallel(cache_failpoints)]
566    fn build_cache_load_corrupted_json() {
567        // Arrange
568        let tmp = TempDir::new().ok().unwrap();
569        let cache_path = tmp.path().join(".ssg-cache.json");
570        fs::write(&cache_path, "{ not valid json !!!").ok();
571
572        // Act
573        let result = BuildCache::load(&cache_path);
574
575        // Assert — malformed JSON must produce an error
576        assert!(result.is_err(), "corrupted JSON should fail to load");
577    }
578
579    // 14. Empty directory produces no changes.
580    #[test]
581    #[serial_test::parallel(cache_failpoints)]
582    fn build_cache_empty_directory() {
583        // Arrange
584        let (_tmp, content, cache_path) = setup();
585        let mut cache = BuildCache::new(&cache_path);
586        cache.update(&content).ok();
587
588        // Act
589        let changed = cache.changed_files(&content).ok().unwrap();
590
591        // Assert
592        assert!(changed.is_empty(), "empty directory should have no changes");
593        assert_eq!(cache.len(), 0);
594    }
595
596    // 15. File present in cache but deleted from disk is detected on update.
597    #[test]
598    #[serial_test::parallel(cache_failpoints)]
599    fn build_cache_file_removed_detected() {
600        // Arrange
601        let (_tmp, content, cache_path) = setup();
602        write_file(&content, "a.md", "keep");
603        write_file(&content, "b.md", "remove-me");
604
605        let mut cache = BuildCache::new(&cache_path);
606        cache.update(&content).ok();
607        assert_eq!(cache.len(), 2);
608
609        // Act — delete one file, then update the cache
610        fs::remove_file(content.join("b.md")).ok();
611        cache.update(&content).ok();
612
613        // Assert — removed file is no longer in the fingerprint map
614        assert_eq!(cache.len(), 1, "deleted file should be pruned from cache");
615    }
616
617    // 17. default_path() returns the compile-time constant.
618    #[test]
619    #[serial_test::parallel(cache_failpoints)]
620    fn default_path_returns_compile_time_constant() {
621        // Covers the const fn at lines 250-252. The function is a
622        // trivial static-string accessor but it's part of the
623        // public API so we exercise it explicitly.
624        assert_eq!(BuildCache::default_path(), DEFAULT_CACHE_FILE);
625        assert!(
626            !BuildCache::default_path().is_empty(),
627            "should not be empty"
628        );
629    }
630
631    // 18. walk() propagates read_dir errors via with_context.
632    #[test]
633    #[serial_test::parallel(cache_failpoints)]
634    fn walk_errors_on_nonexistent_directory() {
635        // Covers the with_context format! closure at lines 156-158.
636        // We call the walker directly with a path that doesn't
637        // exist — fs::read_dir returns Err, the closure fires, and
638        // the format! inside it evaluates (closing lines 157-158).
639        let tmp = TempDir::new().ok().unwrap();
640        let missing = tmp.path().join("does-not-exist");
641        let mut out = Vec::new();
642        let result = BuildCache::walk(tmp.path(), &missing, &mut out);
643        assert!(result.is_err(), "walk should Err on missing dir");
644        let msg = format!("{:?}", result.unwrap_err());
645        assert!(
646            msg.contains("cannot read directory"),
647            "error should contain with_context message: {msg}"
648        );
649    }
650
651    // ── load/save error-path closures ───────────────────────────────
652
653    // 19. load() — read_to_string failure triggers the with_context closure.
654    // We make `cache_path` a directory so File::open succeeds existence
655    // check but read_to_string fails ("Is a directory").
656    #[test]
657    #[serial_test::parallel(cache_failpoints)]
658    fn load_read_failure_fires_with_context_closure() {
659        let tmp = TempDir::new().ok().unwrap();
660        let cache_path = tmp.path().join("cache-as-dir");
661        fs::create_dir_all(&cache_path).ok();
662        let err = BuildCache::load(&cache_path).unwrap_err();
663        let msg = format!("{:?}", err);
664        assert!(
665            msg.contains("failed to read cache file"),
666            "error chain should contain load read context: {msg}"
667        );
668    }
669
670    // 20. load() — parse failure triggers the parse with_context closure.
671    // Distinct from `build_cache_load_corrupted_json` — that test only
672    // asserts is_err; here we assert the closure-generated message
673    // (lines 96-98) made it into the error chain.
674    #[test]
675    #[serial_test::parallel(cache_failpoints)]
676    fn load_parse_failure_message_contains_path() {
677        let tmp = TempDir::new().ok().unwrap();
678        let cache_path = tmp.path().join("bad.json");
679        fs::write(&cache_path, b"{ this is not json").ok();
680        let err = BuildCache::load(&cache_path).unwrap_err();
681        let msg = format!("{:?}", err);
682        assert!(
683            msg.contains("failed to parse cache file"),
684            "error chain should contain parse context: {msg}"
685        );
686        assert!(
687            msg.contains("bad.json"),
688            "error chain should contain file path: {msg}"
689        );
690    }
691
692    // 21. save() — write failure triggers the write with_context closure.
693    // We point cache_path at a directory we never create, then create
694    // an intermediate file (not dir) at the parent so fs::write fails
695    // (parent is a file, not a directory).
696    #[test]
697    #[serial_test::parallel(cache_failpoints)]
698    fn save_write_failure_fires_with_context_closure() {
699        let tmp = TempDir::new().ok().unwrap();
700        let parent_as_file = tmp.path().join("not-a-dir");
701        fs::write(&parent_as_file, b"i am a file").ok();
702        let cache_path = parent_as_file.join("cache.json");
703        let cache = BuildCache::new(&cache_path);
704        let err = cache.save().unwrap_err();
705        let msg = format!("{:?}", err);
706        assert!(
707            msg.contains("failed to write cache file"),
708            "error chain should contain save write context: {msg}"
709        );
710    }
711
712    // 25. walk() propagates strip_prefix failures via with_context.
713    // Calling the private walker directly with a `base` unrelated to
714    // `current` makes `path.strip_prefix(base)` fail for every entry
715    // found under `current` — this closure (lines ~200-202) is never
716    // exercised through the public API since `collect_files` always
717    // calls `walk(dir, dir, ...)` with equal base/current.
718    #[test]
719    #[serial_test::parallel(cache_failpoints)]
720    fn walk_errors_when_base_is_unrelated_to_current() {
721        let tmp = TempDir::new().ok().unwrap();
722        let base = tmp.path().join("unrelated-base");
723        fs::create_dir_all(&base).ok();
724        let current = tmp.path().join("current");
725        write_file(&current, "a.md", "hi");
726
727        let mut out = Vec::new();
728        let result = BuildCache::walk(&base, &current, &mut out);
729        assert!(result.is_err(), "strip_prefix should fail and propagate");
730        let msg = format!("{:?}", result.unwrap_err());
731        assert!(
732            msg.contains("strip_prefix failed"),
733            "error should contain strip_prefix context: {msg}"
734        );
735    }
736
737    // 26. save() propagates serde_json serialization failures. A
738    // non-UTF-8 PathBuf key can't be represented as a JSON object key,
739    // so `serde_json::to_string_pretty` errors and the `.context(...)`
740    // closure fires (this is distinct from the write-failure test,
741    // which covers the *later* `fs::write` context closure).
742    #[cfg(unix)]
743    #[test]
744    #[serial_test::parallel(cache_failpoints)]
745    fn save_fails_when_fingerprint_key_is_not_valid_utf8() {
746        use std::ffi::OsStr;
747        use std::os::unix::ffi::OsStrExt;
748
749        let tmp = TempDir::new().ok().unwrap();
750        let cache_path = tmp.path().join("cache.json");
751        let bad_path = PathBuf::from(OsStr::from_bytes(&[0x66, 0xFF, 0xFE]));
752
753        let mut fingerprints = HashMap::new();
754        fingerprints.insert(bad_path, "deadbeef".to_string());
755        let cache = BuildCache {
756            cache_path,
757            fingerprints,
758        };
759
760        let err = cache.save().unwrap_err();
761        let msg = format!("{:?}", err);
762        assert!(
763            msg.contains("failed to serialize cache"),
764            "error should contain serialize context: {msg}"
765        );
766    }
767
768    // 16. Unchanged files do not appear in the changed list.
769    #[test]
770    #[serial_test::parallel(cache_failpoints)]
771    fn build_cache_unchanged_files_not_reported() {
772        // Arrange
773        let (_tmp, content, cache_path) = setup();
774        write_file(&content, "a.md", "stable");
775        write_file(&content, "b.md", "also stable");
776
777        let mut cache = BuildCache::new(&cache_path);
778        cache.update(&content).ok();
779        cache.save().ok();
780
781        // Act — reload without modifying any files
782        let cache2 = BuildCache::load(&cache_path).ok().unwrap();
783        let changed = cache2.changed_files(&content).ok().unwrap();
784
785        // Assert — nothing should be reported as changed
786        assert!(
787            changed.is_empty(),
788            "unchanged files must not be in changed list"
789        );
790    }
791
792    // ── deep error-path coverage (unix permission tricks) ───────────
793
794    // 22. fingerprint failure inside changed_files propagates.
795    #[cfg(unix)]
796    #[test]
797    #[serial_test::parallel(cache_failpoints)]
798    fn changed_files_propagates_unreadable_file_error() {
799        use std::os::unix::fs::PermissionsExt;
800
801        let (_tmp, content, cache_path) = setup();
802        write_file(&content, "locked.md", "secret");
803        let locked = content.join("locked.md");
804        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).ok();
805
806        let cache = BuildCache::new(&cache_path);
807        let result = cache.changed_files(&content);
808
809        fs::set_permissions(&locked, fs::Permissions::from_mode(0o644)).ok();
810        assert!(result.is_err(), "unreadable file must fail hashing");
811    }
812
813    // 23. fingerprint failure inside update propagates.
814    #[cfg(unix)]
815    #[test]
816    #[serial_test::parallel(cache_failpoints)]
817    fn update_propagates_unreadable_file_error() {
818        use std::os::unix::fs::PermissionsExt;
819
820        let (_tmp, content, cache_path) = setup();
821        write_file(&content, "locked.md", "secret");
822        let locked = content.join("locked.md");
823        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).ok();
824
825        let mut cache = BuildCache::new(&cache_path);
826        let result = cache.update(&content);
827
828        fs::set_permissions(&locked, fs::Permissions::from_mode(0o644)).ok();
829        assert!(result.is_err(), "unreadable file must fail hashing");
830    }
831
832    // 24. walk() propagates read_dir errors raised inside recursion.
833    #[cfg(unix)]
834    #[test]
835    #[serial_test::parallel(cache_failpoints)]
836    fn walk_propagates_error_from_nested_unreadable_directory() {
837        use std::os::unix::fs::PermissionsExt;
838
839        let (_tmp, content, cache_path) = setup();
840        write_file(&content, "ok.md", "fine");
841        let nested = content.join("locked-dir");
842        fs::create_dir_all(&nested).ok();
843        write_file(&nested, "hidden.md", "invisible");
844        fs::set_permissions(&nested, fs::Permissions::from_mode(0o000)).ok();
845
846        let cache = BuildCache::new(&cache_path);
847        let result = cache.changed_files(&content);
848
849        fs::set_permissions(&nested, fs::Permissions::from_mode(0o755)).ok();
850        assert!(result.is_err(), "unreadable nested dir must fail the walk");
851    }
852
853    /// Fault-injection tests for the cache failpoints. These mirror
854    /// `tests/fault_injection.rs` but live in the lib test binary so
855    /// unit-test coverage measurement observes the injected bodies.
856    /// Failpoints are process-global: each test is #[serial] on the
857    /// same key the other cache tests join as #[parallel].
858    #[cfg(feature = "test-fault-injection")]
859    mod fault_injection {
860        use super::*;
861        use serial_test::serial;
862
863        /// RAII guard that disables a failpoint on drop.
864        struct FailGuard<'a>(&'a str);
865
866        impl Drop for FailGuard<'_> {
867            fn drop(&mut self) {
868                let _ = fail::cfg(self.0, "off");
869            }
870        }
871
872        #[test]
873        #[serial(cache_failpoints)]
874        fn load_read_failpoint_injects_error() {
875            let (_tmp, _content, cache_path) = setup();
876            fs::write(&cache_path, "{}").ok();
877
878            let _guard = FailGuard("cache::read");
879            fail::cfg("cache::read", "return").expect("activate failpoint");
880            let err = BuildCache::load(&cache_path).unwrap_err();
881            assert!(
882                format!("{err:?}").contains("injected: cache::read"),
883                "got: {err:?}"
884            );
885        }
886
887        #[test]
888        #[serial(cache_failpoints)]
889        fn load_parse_failpoint_injects_error() {
890            let (_tmp, _content, cache_path) = setup();
891            fs::write(&cache_path, "{\"fingerprints\":{}}").ok();
892
893            let _guard = FailGuard("cache::parse");
894            fail::cfg("cache::parse", "return").expect("activate failpoint");
895            let err = BuildCache::load(&cache_path).unwrap_err();
896            assert!(
897                format!("{err:?}").contains("injected: cache::parse"),
898                "got: {err:?}"
899            );
900        }
901
902        #[test]
903        #[serial(cache_failpoints)]
904        fn save_write_failpoint_injects_error() {
905            let (_tmp, _content, cache_path) = setup();
906
907            let _guard = FailGuard("cache::write");
908            fail::cfg("cache::write", "return").expect("activate failpoint");
909            let cache = BuildCache::new(&cache_path);
910            let err = cache.save().unwrap_err();
911            assert!(
912                format!("{err:?}").contains("injected: cache::write"),
913                "got: {err:?}"
914            );
915        }
916    }
917}