use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const DEFAULT_CACHE_FILE: &str = ".ssg-cache.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildCache {
#[serde(skip)]
cache_path: PathBuf,
fingerprints: HashMap<PathBuf, String>,
}
impl BuildCache {
pub fn load(cache_path: &Path) -> Result<Self> {
if !cache_path.exists() {
return Ok(Self {
cache_path: cache_path.to_path_buf(),
fingerprints: HashMap::new(),
});
}
let data = fs::read_to_string(cache_path)
.with_context(|| format!("failed to read cache file: {}", cache_path.display()))?;
let mut cache: Self = serde_json::from_str(&data)
.with_context(|| format!("failed to parse cache file: {}", cache_path.display()))?;
cache.cache_path = cache_path.to_path_buf();
Ok(cache)
}
pub fn new(cache_path: &Path) -> Self {
Self {
cache_path: cache_path.to_path_buf(),
fingerprints: HashMap::new(),
}
}
pub fn save(&self) -> Result<()> {
let json = serde_json::to_string_pretty(self)
.context("failed to serialize cache")?;
fs::write(&self.cache_path, json)
.with_context(|| {
format!(
"failed to write cache file: {}",
self.cache_path.display()
)
})?;
Ok(())
}
fn fingerprint(path: &Path) -> Result<String> {
crate::stream::stream_hash(path)
}
fn collect_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if !dir.exists() {
return Ok(files);
}
Self::walk(dir, dir, &mut files)?;
files.sort();
Ok(files)
}
fn walk(base: &Path, current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries = fs::read_dir(current)
.with_context(|| format!("cannot read directory: {}", current.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
Self::walk(base, &path, out)?;
} else {
let rel = path.strip_prefix(base)
.with_context(|| "strip_prefix failed")?;
out.push(rel.to_path_buf());
}
}
Ok(())
}
pub fn changed_files(&self, content_dir: &Path) -> Result<Vec<PathBuf>> {
let files = Self::collect_files(content_dir)?;
let mut changed = Vec::new();
for rel in &files {
let abs = content_dir.join(rel);
let hash = Self::fingerprint(&abs)?;
match self.fingerprints.get(rel) {
Some(cached) if *cached == hash => {
}
_ => {
changed.push(abs);
}
}
}
Ok(changed)
}
pub fn update(&mut self, content_dir: &Path) -> Result<()> {
let files = Self::collect_files(content_dir)?;
let mut map = HashMap::with_capacity(files.len());
for rel in files {
let abs = content_dir.join(&rel);
let hash = Self::fingerprint(&abs)?;
let _prev = map.insert(rel, hash);
}
self.fingerprints = map;
Ok(())
}
pub fn len(&self) -> usize {
self.fingerprints.len()
}
pub fn is_empty(&self) -> bool {
self.fingerprints.is_empty()
}
pub fn default_path() -> &'static str {
DEFAULT_CACHE_FILE
}
}
#[cfg(test)]
#[allow(unused_results)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup() -> (TempDir, PathBuf, PathBuf) {
let tmp = TempDir::new().ok().unwrap();
let content = tmp.path().join("content");
fs::create_dir_all(&content).ok();
let cache_path = tmp.path().join(".ssg-cache.json");
(tmp, content, cache_path)
}
fn write_file(dir: &Path, name: &str, contents: &str) {
let p = dir.join(name);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).ok();
}
fs::write(&p, contents).ok();
}
#[test]
fn load_missing_cache() {
let tmp = TempDir::new().ok().unwrap();
let cache_path = tmp.path().join("nonexistent.json");
let cache = BuildCache::load(&cache_path).ok().unwrap();
assert!(cache.is_empty());
}
#[test]
fn load_valid_cache() {
let (_tmp, content, cache_path) = setup();
write_file(&content, "a.md", "hello");
let mut cache = BuildCache::load(&cache_path).ok().unwrap();
cache.update(&content).ok();
cache.save().ok();
let loaded = BuildCache::load(&cache_path).ok().unwrap();
assert_eq!(loaded.len(), 1);
}
#[test]
fn detect_changes() {
let (_tmp, content, cache_path) = setup();
write_file(&content, "a.md", "v1");
let mut cache = BuildCache::load(&cache_path).ok().unwrap();
cache.update(&content).ok();
cache.save().ok();
write_file(&content, "a.md", "v2");
let cache2 = BuildCache::load(&cache_path).ok().unwrap();
let changed = cache2.changed_files(&content).ok().unwrap();
assert_eq!(changed.len(), 1);
assert!(changed[0].ends_with("a.md"));
}
#[test]
fn detect_no_changes() {
let (_tmp, content, cache_path) = setup();
write_file(&content, "a.md", "same");
let mut cache = BuildCache::load(&cache_path).ok().unwrap();
cache.update(&content).ok();
cache.save().ok();
let cache2 = BuildCache::load(&cache_path).ok().unwrap();
let changed = cache2.changed_files(&content).ok().unwrap();
assert!(changed.is_empty());
}
#[test]
fn new_files_are_changed() {
let (_tmp, content, cache_path) = setup();
write_file(&content, "a.md", "hello");
let mut cache = BuildCache::load(&cache_path).ok().unwrap();
cache.update(&content).ok();
cache.save().ok();
write_file(&content, "b.md", "world");
let cache2 = BuildCache::load(&cache_path).ok().unwrap();
let changed = cache2.changed_files(&content).ok().unwrap();
assert_eq!(changed.len(), 1);
assert!(changed[0].ends_with("b.md"));
}
#[test]
fn deleted_files_pruned() {
let (_tmp, content, cache_path) = setup();
write_file(&content, "a.md", "keep");
write_file(&content, "b.md", "delete-me");
let mut cache = BuildCache::load(&cache_path).ok().unwrap();
cache.update(&content).ok();
assert_eq!(cache.len(), 2);
fs::remove_file(content.join("b.md")).ok();
cache.update(&content).ok();
assert_eq!(cache.len(), 1);
}
#[test]
fn save_load_roundtrip() {
let (_tmp, content, cache_path) = setup();
write_file(&content, "x.md", "data1");
write_file(&content, "sub/y.md", "data2");
let mut cache = BuildCache::new(&cache_path);
cache.update(&content).ok();
cache.save().ok();
let loaded = BuildCache::load(&cache_path).ok().unwrap();
assert_eq!(loaded.len(), 2);
}
#[test]
fn empty_directory() {
let (_tmp, content, cache_path) = setup();
let cache = BuildCache::load(&cache_path).ok().unwrap();
let changed = cache.changed_files(&content).ok().unwrap();
assert!(changed.is_empty());
}
#[test]
fn nonexistent_directory() {
let tmp = TempDir::new().ok().unwrap();
let cache_path = tmp.path().join(".ssg-cache.json");
let cache = BuildCache::load(&cache_path).ok().unwrap();
let changed = cache
.changed_files(&tmp.path().join("nope"))
.ok()
.unwrap();
assert!(changed.is_empty());
}
#[test]
fn fingerprint_deterministic() {
let tmp = TempDir::new().ok().unwrap();
let path = tmp.path().join("test.txt");
fs::write(&path, "deterministic").ok();
let h1 = BuildCache::fingerprint(&path).ok().unwrap();
let h2 = BuildCache::fingerprint(&path).ok().unwrap();
assert_eq!(h1, h2);
}
#[test]
fn fingerprint_varies_with_content() {
let tmp = TempDir::new().ok().unwrap();
let p1 = tmp.path().join("a.txt");
let p2 = tmp.path().join("b.txt");
fs::write(&p1, "alpha").ok();
fs::write(&p2, "beta").ok();
let h1 = BuildCache::fingerprint(&p1).ok().unwrap();
let h2 = BuildCache::fingerprint(&p2).ok().unwrap();
assert_ne!(h1, h2);
}
#[test]
fn subdirectory_tracking() {
let (_tmp, content, cache_path) = setup();
write_file(&content, "posts/2024/hello.md", "hi");
write_file(&content, "pages/about.md", "about");
let mut cache = BuildCache::new(&cache_path);
cache.update(&content).ok();
assert_eq!(cache.len(), 2);
write_file(&content, "posts/2024/hello.md", "updated");
let changed = cache.changed_files(&content).ok().unwrap();
assert_eq!(changed.len(), 1);
}
#[test]
fn build_cache_load_corrupted_json() {
let tmp = TempDir::new().ok().unwrap();
let cache_path = tmp.path().join(".ssg-cache.json");
fs::write(&cache_path, "{ not valid json !!!").ok();
let result = BuildCache::load(&cache_path);
assert!(result.is_err(), "corrupted JSON should fail to load");
}
#[test]
fn build_cache_empty_directory() {
let (_tmp, content, cache_path) = setup();
let mut cache = BuildCache::new(&cache_path);
cache.update(&content).ok();
let changed = cache.changed_files(&content).ok().unwrap();
assert!(changed.is_empty(), "empty directory should have no changes");
assert_eq!(cache.len(), 0);
}
#[test]
fn build_cache_file_removed_detected() {
let (_tmp, content, cache_path) = setup();
write_file(&content, "a.md", "keep");
write_file(&content, "b.md", "remove-me");
let mut cache = BuildCache::new(&cache_path);
cache.update(&content).ok();
assert_eq!(cache.len(), 2);
fs::remove_file(content.join("b.md")).ok();
cache.update(&content).ok();
assert_eq!(cache.len(), 1, "deleted file should be pruned from cache");
}
#[test]
fn build_cache_unchanged_files_not_reported() {
let (_tmp, content, cache_path) = setup();
write_file(&content, "a.md", "stable");
write_file(&content, "b.md", "also stable");
let mut cache = BuildCache::new(&cache_path);
cache.update(&content).ok();
cache.save().ok();
let cache2 = BuildCache::load(&cache_path).ok().unwrap();
let changed = cache2.changed_files(&content).ok().unwrap();
assert!(changed.is_empty(), "unchanged files must not be in changed list");
}
}