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(), "{:?}", changed);
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(), "{:?}", changed);
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(), "{:?}", changed);
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!(
626 !BuildCache::default_path().is_empty(),
627 "should not be empty"
628 );
629 }
630
631 #[test]
633 #[serial_test::parallel(cache_failpoints)]
634 fn walk_errors_on_nonexistent_directory() {
635 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 #[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 #[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 #[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 #[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(¤t, "a.md", "hi");
726
727 let mut out = Vec::new();
728 let result = BuildCache::walk(&base, ¤t, &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 #[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 #[test]
770 #[serial_test::parallel(cache_failpoints)]
771 fn build_cache_unchanged_files_not_reported() {
772 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 let cache2 = BuildCache::load(&cache_path).ok().unwrap();
783 let changed = cache2.changed_files(&content).ok().unwrap();
784
785 assert!(
787 changed.is_empty(),
788 "unchanged files must not be in changed list"
789 );
790 }
791
792 #[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 #[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 #[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 #[cfg(feature = "test-fault-injection")]
859 mod fault_injection {
860 use super::*;
861 use serial_test::serial;
862
863 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}