use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
const DEP_GRAPH_FILE: &str = ".ssg-deps.json";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DepGraph {
deps: HashMap<PathBuf, HashSet<PathBuf>>,
}
impl DepGraph {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn load(site_dir: &Path) -> Self {
let path = site_dir.join(DEP_GRAPH_FILE);
match fs::read_to_string(&path) {
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self, site_dir: &Path) -> Result<()> {
let path = site_dir.join(DEP_GRAPH_FILE);
let json = serde_json::to_string_pretty(self)?;
fs::write(&path, json)?;
Ok(())
}
pub fn add_dep(&mut self, page: &Path, dep: &Path) {
let _ = self
.deps
.entry(page.to_path_buf())
.or_default()
.insert(dep.to_path_buf());
}
#[must_use]
pub fn deps_for(&self, page: &Path) -> Option<&HashSet<PathBuf>> {
self.deps.get(page)
}
#[must_use]
pub fn invalidated_pages(&self, changed: &[PathBuf]) -> Vec<PathBuf> {
let changed_set: HashSet<&PathBuf> = changed.iter().collect();
let mut invalidated: HashSet<PathBuf> = HashSet::new();
for path in changed {
let _ = invalidated.insert(path.clone());
}
for (page, deps) in &self.deps {
if deps.iter().any(|d| changed_set.contains(d)) {
let _ = invalidated.insert(page.clone());
}
}
let mut result: Vec<PathBuf> = invalidated.into_iter().collect();
result.sort();
result
}
#[must_use]
pub fn page_count(&self) -> usize {
self.deps.len()
}
pub fn clear(&mut self) {
self.deps.clear();
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn empty_graph() {
let graph = DepGraph::new();
let changed = vec![PathBuf::from("content/index.md")];
let result = graph.invalidated_pages(&changed);
assert_eq!(result, vec![PathBuf::from("content/index.md")]);
}
#[test]
fn direct_change() {
let mut graph = DepGraph::new();
let page = PathBuf::from("content/about.md");
let tmpl = PathBuf::from("templates/base.html");
graph.add_dep(&page, &tmpl);
let changed = vec![page.clone()];
let result = graph.invalidated_pages(&changed);
assert_eq!(result, vec![page]);
}
#[test]
fn dependency_change() {
let mut graph = DepGraph::new();
let page_a = PathBuf::from("content/index.md");
let page_b = PathBuf::from("content/about.md");
let tmpl = PathBuf::from("templates/base.html");
graph.add_dep(&page_a, &tmpl);
graph.add_dep(&page_b, &tmpl);
let changed = vec![tmpl];
let result = graph.invalidated_pages(&changed);
assert!(result.contains(&page_a));
assert!(result.contains(&page_b));
assert_eq!(result.len(), 3); }
#[test]
fn transitive_not_tracked() {
let mut graph = DepGraph::new();
let page = PathBuf::from("content/index.md");
let partial = PathBuf::from("templates/partial.html");
let base = PathBuf::from("templates/base.html");
graph.add_dep(&page, &partial);
graph.add_dep(&partial, &base);
let changed = vec![base.clone()];
let result = graph.invalidated_pages(&changed);
assert!(result.contains(&base));
assert!(result.contains(&partial)); assert!(!result.contains(&page)); }
#[test]
fn save_and_load_round_trip() {
let dir = tempdir().expect("test invariant");
let mut graph = DepGraph::new();
let page = PathBuf::from("content/index.md");
let tmpl = PathBuf::from("templates/base.html");
graph.add_dep(&page, &tmpl);
graph.save(dir.path()).expect("test invariant");
let loaded = DepGraph::load(dir.path());
assert_eq!(loaded.page_count(), 1);
let deps = loaded.deps_for(&page).expect("test invariant");
assert!(deps.contains(&tmpl));
}
#[test]
fn load_missing_file() {
let dir = tempdir().expect("test invariant");
let graph = DepGraph::load(dir.path());
assert_eq!(graph.page_count(), 0);
}
#[test]
fn load_corrupt_json() {
let dir = tempdir().expect("test invariant");
let path = dir.path().join(".ssg-deps.json");
fs::write(&path, "not valid json {{{{").expect("test invariant");
let graph = DepGraph::load(dir.path());
assert_eq!(graph.page_count(), 0);
}
#[test]
fn add_multiple_deps() {
let mut graph = DepGraph::new();
let page = PathBuf::from("content/post.md");
let dep_a = PathBuf::from("templates/post.html");
let dep_b = PathBuf::from("shortcodes/gallery.html");
let dep_c = PathBuf::from("data/authors.json");
graph.add_dep(&page, &dep_a);
graph.add_dep(&page, &dep_b);
graph.add_dep(&page, &dep_c);
let changed = vec![dep_b.clone()];
let result = graph.invalidated_pages(&changed);
assert!(result.contains(&page));
assert!(result.contains(&dep_b));
assert_eq!(result.len(), 2);
}
#[test]
fn no_false_positives() {
let mut graph = DepGraph::new();
let page = PathBuf::from("content/index.md");
let tmpl = PathBuf::from("templates/base.html");
graph.add_dep(&page, &tmpl);
let unrelated = PathBuf::from("static/logo.png");
let changed = vec![unrelated.clone()];
let result = graph.invalidated_pages(&changed);
assert_eq!(result, vec![unrelated]);
}
}