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());
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());
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());
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!(!BuildCache::default_path().is_empty());
626    }
627
628    // 18. walk() propagates read_dir errors via with_context.
629    #[test]
630    #[serial_test::parallel(cache_failpoints)]
631    fn walk_errors_on_nonexistent_directory() {
632        // Covers the with_context format! closure at lines 156-158.
633        // We call the walker directly with a path that doesn't
634        // exist — fs::read_dir returns Err, the closure fires, and
635        // the format! inside it evaluates (closing lines 157-158).
636        let tmp = TempDir::new().ok().unwrap();
637        let missing = tmp.path().join("does-not-exist");
638        let mut out = Vec::new();
639        let result = BuildCache::walk(tmp.path(), &missing, &mut out);
640        assert!(result.is_err(), "walk should Err on missing dir");
641        let msg = format!("{:?}", result.unwrap_err());
642        assert!(
643            msg.contains("cannot read directory"),
644            "error should contain with_context message: {msg}"
645        );
646    }
647
648    // ── load/save error-path closures ───────────────────────────────
649
650    // 19. load() — read_to_string failure triggers the with_context closure.
651    // We make `cache_path` a directory so File::open succeeds existence
652    // check but read_to_string fails ("Is a directory").
653    #[test]
654    #[serial_test::parallel(cache_failpoints)]
655    fn load_read_failure_fires_with_context_closure() {
656        let tmp = TempDir::new().ok().unwrap();
657        let cache_path = tmp.path().join("cache-as-dir");
658        fs::create_dir_all(&cache_path).ok();
659        let err = BuildCache::load(&cache_path).unwrap_err();
660        let msg = format!("{:?}", err);
661        assert!(
662            msg.contains("failed to read cache file"),
663            "error chain should contain load read context: {msg}"
664        );
665    }
666
667    // 20. load() — parse failure triggers the parse with_context closure.
668    // Distinct from `build_cache_load_corrupted_json` — that test only
669    // asserts is_err; here we assert the closure-generated message
670    // (lines 96-98) made it into the error chain.
671    #[test]
672    #[serial_test::parallel(cache_failpoints)]
673    fn load_parse_failure_message_contains_path() {
674        let tmp = TempDir::new().ok().unwrap();
675        let cache_path = tmp.path().join("bad.json");
676        fs::write(&cache_path, b"{ this is not json").ok();
677        let err = BuildCache::load(&cache_path).unwrap_err();
678        let msg = format!("{:?}", err);
679        assert!(
680            msg.contains("failed to parse cache file"),
681            "error chain should contain parse context: {msg}"
682        );
683        assert!(
684            msg.contains("bad.json"),
685            "error chain should contain file path: {msg}"
686        );
687    }
688
689    // 21. save() — write failure triggers the write with_context closure.
690    // We point cache_path at a directory we never create, then create
691    // an intermediate file (not dir) at the parent so fs::write fails
692    // (parent is a file, not a directory).
693    #[test]
694    #[serial_test::parallel(cache_failpoints)]
695    fn save_write_failure_fires_with_context_closure() {
696        let tmp = TempDir::new().ok().unwrap();
697        let parent_as_file = tmp.path().join("not-a-dir");
698        fs::write(&parent_as_file, b"i am a file").ok();
699        let cache_path = parent_as_file.join("cache.json");
700        let cache = BuildCache::new(&cache_path);
701        let err = cache.save().unwrap_err();
702        let msg = format!("{:?}", err);
703        assert!(
704            msg.contains("failed to write cache file"),
705            "error chain should contain save write context: {msg}"
706        );
707    }
708
709    // 25. walk() propagates strip_prefix failures via with_context.
710    // Calling the private walker directly with a `base` unrelated to
711    // `current` makes `path.strip_prefix(base)` fail for every entry
712    // found under `current` — this closure (lines ~200-202) is never
713    // exercised through the public API since `collect_files` always
714    // calls `walk(dir, dir, ...)` with equal base/current.
715    #[test]
716    #[serial_test::parallel(cache_failpoints)]
717    fn walk_errors_when_base_is_unrelated_to_current() {
718        let tmp = TempDir::new().ok().unwrap();
719        let base = tmp.path().join("unrelated-base");
720        fs::create_dir_all(&base).ok();
721        let current = tmp.path().join("current");
722        write_file(&current, "a.md", "hi");
723
724        let mut out = Vec::new();
725        let result = BuildCache::walk(&base, &current, &mut out);
726        assert!(result.is_err(), "strip_prefix should fail and propagate");
727        let msg = format!("{:?}", result.unwrap_err());
728        assert!(
729            msg.contains("strip_prefix failed"),
730            "error should contain strip_prefix context: {msg}"
731        );
732    }
733
734    // 26. save() propagates serde_json serialization failures. A
735    // non-UTF-8 PathBuf key can't be represented as a JSON object key,
736    // so `serde_json::to_string_pretty` errors and the `.context(...)`
737    // closure fires (this is distinct from the write-failure test,
738    // which covers the *later* `fs::write` context closure).
739    #[cfg(unix)]
740    #[test]
741    #[serial_test::parallel(cache_failpoints)]
742    fn save_fails_when_fingerprint_key_is_not_valid_utf8() {
743        use std::ffi::OsStr;
744        use std::os::unix::ffi::OsStrExt;
745
746        let tmp = TempDir::new().ok().unwrap();
747        let cache_path = tmp.path().join("cache.json");
748        let bad_path = PathBuf::from(OsStr::from_bytes(&[0x66, 0xFF, 0xFE]));
749
750        let mut fingerprints = HashMap::new();
751        fingerprints.insert(bad_path, "deadbeef".to_string());
752        let cache = BuildCache {
753            cache_path,
754            fingerprints,
755        };
756
757        let err = cache.save().unwrap_err();
758        let msg = format!("{:?}", err);
759        assert!(
760            msg.contains("failed to serialize cache"),
761            "error should contain serialize context: {msg}"
762        );
763    }
764
765    // 16. Unchanged files do not appear in the changed list.
766    #[test]
767    #[serial_test::parallel(cache_failpoints)]
768    fn build_cache_unchanged_files_not_reported() {
769        // Arrange
770        let (_tmp, content, cache_path) = setup();
771        write_file(&content, "a.md", "stable");
772        write_file(&content, "b.md", "also stable");
773
774        let mut cache = BuildCache::new(&cache_path);
775        cache.update(&content).ok();
776        cache.save().ok();
777
778        // Act — reload without modifying any files
779        let cache2 = BuildCache::load(&cache_path).ok().unwrap();
780        let changed = cache2.changed_files(&content).ok().unwrap();
781
782        // Assert — nothing should be reported as changed
783        assert!(
784            changed.is_empty(),
785            "unchanged files must not be in changed list"
786        );
787    }
788
789    // ── deep error-path coverage (unix permission tricks) ───────────
790
791    // 22. fingerprint failure inside changed_files propagates.
792    #[cfg(unix)]
793    #[test]
794    #[serial_test::parallel(cache_failpoints)]
795    fn changed_files_propagates_unreadable_file_error() {
796        use std::os::unix::fs::PermissionsExt;
797
798        let (_tmp, content, cache_path) = setup();
799        write_file(&content, "locked.md", "secret");
800        let locked = content.join("locked.md");
801        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).ok();
802
803        let cache = BuildCache::new(&cache_path);
804        let result = cache.changed_files(&content);
805
806        fs::set_permissions(&locked, fs::Permissions::from_mode(0o644)).ok();
807        assert!(result.is_err(), "unreadable file must fail hashing");
808    }
809
810    // 23. fingerprint failure inside update propagates.
811    #[cfg(unix)]
812    #[test]
813    #[serial_test::parallel(cache_failpoints)]
814    fn update_propagates_unreadable_file_error() {
815        use std::os::unix::fs::PermissionsExt;
816
817        let (_tmp, content, cache_path) = setup();
818        write_file(&content, "locked.md", "secret");
819        let locked = content.join("locked.md");
820        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).ok();
821
822        let mut cache = BuildCache::new(&cache_path);
823        let result = cache.update(&content);
824
825        fs::set_permissions(&locked, fs::Permissions::from_mode(0o644)).ok();
826        assert!(result.is_err(), "unreadable file must fail hashing");
827    }
828
829    // 24. walk() propagates read_dir errors raised inside recursion.
830    #[cfg(unix)]
831    #[test]
832    #[serial_test::parallel(cache_failpoints)]
833    fn walk_propagates_error_from_nested_unreadable_directory() {
834        use std::os::unix::fs::PermissionsExt;
835
836        let (_tmp, content, cache_path) = setup();
837        write_file(&content, "ok.md", "fine");
838        let nested = content.join("locked-dir");
839        fs::create_dir_all(&nested).ok();
840        write_file(&nested, "hidden.md", "invisible");
841        fs::set_permissions(&nested, fs::Permissions::from_mode(0o000)).ok();
842
843        let cache = BuildCache::new(&cache_path);
844        let result = cache.changed_files(&content);
845
846        fs::set_permissions(&nested, fs::Permissions::from_mode(0o755)).ok();
847        assert!(result.is_err(), "unreadable nested dir must fail the walk");
848    }
849
850    /// Fault-injection tests for the cache failpoints. These mirror
851    /// `tests/fault_injection.rs` but live in the lib test binary so
852    /// unit-test coverage measurement observes the injected bodies.
853    /// Failpoints are process-global: each test is #[serial] on the
854    /// same key the other cache tests join as #[parallel].
855    #[cfg(feature = "test-fault-injection")]
856    mod fault_injection {
857        use super::*;
858        use serial_test::serial;
859
860        /// RAII guard that disables a failpoint on drop.
861        struct FailGuard<'a>(&'a str);
862
863        impl Drop for FailGuard<'_> {
864            fn drop(&mut self) {
865                let _ = fail::cfg(self.0, "off");
866            }
867        }
868
869        #[test]
870        #[serial(cache_failpoints)]
871        fn load_read_failpoint_injects_error() {
872            let (_tmp, _content, cache_path) = setup();
873            fs::write(&cache_path, "{}").ok();
874
875            let _guard = FailGuard("cache::read");
876            fail::cfg("cache::read", "return").expect("activate failpoint");
877            let err = BuildCache::load(&cache_path).unwrap_err();
878            assert!(
879                format!("{err:?}").contains("injected: cache::read"),
880                "got: {err:?}"
881            );
882        }
883
884        #[test]
885        #[serial(cache_failpoints)]
886        fn load_parse_failpoint_injects_error() {
887            let (_tmp, _content, cache_path) = setup();
888            fs::write(&cache_path, "{\"fingerprints\":{}}").ok();
889
890            let _guard = FailGuard("cache::parse");
891            fail::cfg("cache::parse", "return").expect("activate failpoint");
892            let err = BuildCache::load(&cache_path).unwrap_err();
893            assert!(
894                format!("{err:?}").contains("injected: cache::parse"),
895                "got: {err:?}"
896            );
897        }
898
899        #[test]
900        #[serial(cache_failpoints)]
901        fn save_write_failpoint_injects_error() {
902            let (_tmp, _content, cache_path) = setup();
903
904            let _guard = FailGuard("cache::write");
905            fail::cfg("cache::write", "return").expect("activate failpoint");
906            let cache = BuildCache::new(&cache_path);
907            let err = cache.save().unwrap_err();
908            assert!(
909                format!("{err:?}").contains("injected: cache::write"),
910                "got: {err:?}"
911            );
912        }
913    }
914}