1use anyhow::Result;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15
16const DEP_GRAPH_FILE: &str = ".ssg-deps.json";
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct DepGraph {
21 deps: HashMap<PathBuf, HashSet<PathBuf>>,
23}
24
25impl DepGraph {
26 #[must_use]
28 pub fn new() -> Self {
29 Self::default()
30 }
31
32 #[must_use]
35 pub fn load(site_dir: &Path) -> Self {
36 let path = site_dir.join(DEP_GRAPH_FILE);
37 match fs::read_to_string(&path) {
38 Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
39 Err(_) => Self::default(),
40 }
41 }
42
43 pub fn save(&self, site_dir: &Path) -> Result<()> {
45 let path = site_dir.join(DEP_GRAPH_FILE);
46 let json = serde_json::to_string_pretty(self)?;
47 fs::write(&path, json)?;
48 Ok(())
49 }
50
51 pub fn add_dep(&mut self, page: &Path, dep: &Path) {
53 let _ = self
54 .deps
55 .entry(page.to_path_buf())
56 .or_default()
57 .insert(dep.to_path_buf());
58 }
59
60 #[must_use]
62 pub fn deps_for(&self, page: &Path) -> Option<&HashSet<PathBuf>> {
63 self.deps.get(page)
64 }
65
66 #[must_use]
70 pub fn invalidated_pages(&self, changed: &[PathBuf]) -> Vec<PathBuf> {
71 let changed_set: HashSet<&PathBuf> = changed.iter().collect();
72 let mut invalidated: HashSet<PathBuf> = HashSet::new();
73
74 for path in changed {
76 let _ = invalidated.insert(path.clone());
77 }
78
79 for (page, deps) in &self.deps {
81 if deps.iter().any(|d| changed_set.contains(d)) {
82 let _ = invalidated.insert(page.clone());
83 }
84 }
85
86 let mut result: Vec<PathBuf> = invalidated.into_iter().collect();
87 result.sort();
88 result
89 }
90
91 #[must_use]
93 pub fn page_count(&self) -> usize {
94 self.deps.len()
95 }
96
97 pub fn clear(&mut self) {
99 self.deps.clear();
100 }
101}
102
103#[cfg(test)]
104#[allow(clippy::expect_used)]
105mod tests {
106 use super::*;
107 use tempfile::tempdir;
108
109 #[test]
110 fn empty_graph() {
111 let graph = DepGraph::new();
112 let changed = vec![PathBuf::from("content/index.md")];
113 let result = graph.invalidated_pages(&changed);
114 assert_eq!(result, vec![PathBuf::from("content/index.md")]);
115 }
116
117 #[test]
118 fn direct_change() {
119 let mut graph = DepGraph::new();
120 let page = PathBuf::from("content/about.md");
121 let tmpl = PathBuf::from("templates/base.html");
122 graph.add_dep(&page, &tmpl);
123
124 let changed = vec![page.clone()];
125 let result = graph.invalidated_pages(&changed);
126 assert_eq!(result, vec![page]);
127 }
128
129 #[test]
130 fn dependency_change() {
131 let mut graph = DepGraph::new();
132 let page_a = PathBuf::from("content/index.md");
133 let page_b = PathBuf::from("content/about.md");
134 let tmpl = PathBuf::from("templates/base.html");
135 graph.add_dep(&page_a, &tmpl);
136 graph.add_dep(&page_b, &tmpl);
137
138 let changed = vec![tmpl];
139 let result = graph.invalidated_pages(&changed);
140 assert!(result.contains(&page_a));
142 assert!(result.contains(&page_b));
143 assert_eq!(result.len(), 3); }
145
146 #[test]
147 fn transitive_not_tracked() {
148 let mut graph = DepGraph::new();
150 let page = PathBuf::from("content/index.md");
151 let partial = PathBuf::from("templates/partial.html");
152 let base = PathBuf::from("templates/base.html");
153 graph.add_dep(&page, &partial);
155 graph.add_dep(&partial, &base);
156
157 let changed = vec![base.clone()];
159 let result = graph.invalidated_pages(&changed);
160 assert!(result.contains(&base));
161 assert!(result.contains(&partial)); assert!(!result.contains(&page)); }
164
165 #[test]
166 fn save_and_load_round_trip() {
167 let dir = tempdir().expect("test invariant");
168 let mut graph = DepGraph::new();
169 let page = PathBuf::from("content/index.md");
170 let tmpl = PathBuf::from("templates/base.html");
171 graph.add_dep(&page, &tmpl);
172
173 graph.save(dir.path()).expect("test invariant");
174 let loaded = DepGraph::load(dir.path());
175
176 assert_eq!(loaded.page_count(), 1);
177 let deps = loaded.deps_for(&page).expect("test invariant");
178 assert!(deps.contains(&tmpl));
179 }
180
181 #[test]
182 fn load_missing_file() {
183 let dir = tempdir().expect("test invariant");
184 let graph = DepGraph::load(dir.path());
185 assert_eq!(graph.page_count(), 0);
186 }
187
188 #[test]
189 fn load_corrupt_json() {
190 let dir = tempdir().expect("test invariant");
191 let path = dir.path().join(".ssg-deps.json");
192 fs::write(&path, "not valid json {{{{").expect("test invariant");
193 let graph = DepGraph::load(dir.path());
194 assert_eq!(graph.page_count(), 0);
195 }
196
197 #[test]
198 fn add_multiple_deps() {
199 let mut graph = DepGraph::new();
200 let page = PathBuf::from("content/post.md");
201 let dep_a = PathBuf::from("templates/post.html");
202 let dep_b = PathBuf::from("shortcodes/gallery.html");
203 let dep_c = PathBuf::from("data/authors.json");
204 graph.add_dep(&page, &dep_a);
205 graph.add_dep(&page, &dep_b);
206 graph.add_dep(&page, &dep_c);
207
208 let changed = vec![dep_b.clone()];
210 let result = graph.invalidated_pages(&changed);
211 assert!(result.contains(&page));
212 assert!(result.contains(&dep_b));
213 assert_eq!(result.len(), 2);
214 }
215
216 #[test]
217 fn no_false_positives() {
218 let mut graph = DepGraph::new();
219 let page = PathBuf::from("content/index.md");
220 let tmpl = PathBuf::from("templates/base.html");
221 graph.add_dep(&page, &tmpl);
222
223 let unrelated = PathBuf::from("static/logo.png");
225 let changed = vec![unrelated.clone()];
226 let result = graph.invalidated_pages(&changed);
227 assert_eq!(result, vec![unrelated]);
229 }
230}