1use std::collections::HashMap;
39use std::fs;
40use std::path::{Path, PathBuf};
41
42use anyhow::{Context, Result};
43use serde::{Deserialize, Serialize};
44
45const DEFAULT_CACHE_FILE: &str = ".ssg-cache.json";
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct BuildCache {
55 #[serde(skip)]
57 cache_path: PathBuf,
58
59 fingerprints: HashMap<PathBuf, String>,
61}
62
63impl BuildCache {
64 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 #[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 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 fn fingerprint(path: &Path) -> Result<String> {
174 crate::stream::stream_hash(path)
175 }
176
177 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 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 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 }
255 _ => {
256 changed.push(abs);
257 }
258 }
259 }
260
261 Ok(changed)
262 }
263
264 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 #[must_use]
315 pub fn len(&self) -> usize {
316 self.fingerprints.len()
317 }
318
319 #[must_use]
331 pub fn is_empty(&self) -> bool {
332 self.fingerprints.is_empty()
333 }
334
335 #[must_use]
346 pub const fn default_path() -> &'static str {
347 DEFAULT_CACHE_FILE
348 }
349}
350
351#[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 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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 fs::remove_file(content.join("b.md")).ok();
474
475 cache.update(&content).ok();
476 assert_eq!(cache.len(), 1);
477 }
478
479 #[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 #[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 #[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 #[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 #[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 #[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 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 #[test]
565 #[serial_test::parallel(cache_failpoints)]
566 fn build_cache_load_corrupted_json() {
567 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 let result = BuildCache::load(&cache_path);
574
575 assert!(result.is_err(), "corrupted JSON should fail to load");
577 }
578
579 #[test]
581 #[serial_test::parallel(cache_failpoints)]
582 fn build_cache_empty_directory() {
583 let (_tmp, content, cache_path) = setup();
585 let mut cache = BuildCache::new(&cache_path);
586 cache.update(&content).ok();
587
588 let changed = cache.changed_files(&content).ok().unwrap();
590
591 assert!(changed.is_empty(), "empty directory should have no changes");
593 assert_eq!(cache.len(), 0);
594 }
595
596 #[test]
598 #[serial_test::parallel(cache_failpoints)]
599 fn build_cache_file_removed_detected() {
600 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 fs::remove_file(content.join("b.md")).ok();
611 cache.update(&content).ok();
612
613 assert_eq!(cache.len(), 1, "deleted file should be pruned from cache");
615 }
616
617 #[test]
619 #[serial_test::parallel(cache_failpoints)]
620 fn default_path_returns_compile_time_constant() {
621 assert_eq!(BuildCache::default_path(), DEFAULT_CACHE_FILE);
625 assert!(!BuildCache::default_path().is_empty());
626 }
627
628 #[test]
630 #[serial_test::parallel(cache_failpoints)]
631 fn walk_errors_on_nonexistent_directory() {
632 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 #[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 #[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 #[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 #[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(¤t, "a.md", "hi");
723
724 let mut out = Vec::new();
725 let result = BuildCache::walk(&base, ¤t, &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 #[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 #[test]
767 #[serial_test::parallel(cache_failpoints)]
768 fn build_cache_unchanged_files_not_reported() {
769 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 let cache2 = BuildCache::load(&cache_path).ok().unwrap();
780 let changed = cache2.changed_files(&content).ok().unwrap();
781
782 assert!(
784 changed.is_empty(),
785 "unchanged files must not be in changed list"
786 );
787 }
788
789 #[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 #[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 #[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 #[cfg(feature = "test-fault-injection")]
856 mod fault_injection {
857 use super::*;
858 use serial_test::serial;
859
860 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}