Skip to main content

ssg/
depgraph.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Page dependency graph for incremental rebuilds.
5//!
6//! Tracks which pages depend on which templates, shortcodes, and data
7//! files. When a dependency changes, only the pages that use it are
8//! invalidated and recompiled.
9
10use 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/// Dependency graph mapping pages to their dependencies.
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct DepGraph {
21    /// page relative path → set of dependency relative paths
22    deps: HashMap<PathBuf, HashSet<PathBuf>>,
23}
24
25impl DepGraph {
26    /// Creates an empty dependency graph.
27    #[must_use]
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Loads from `.ssg-deps.json` in the site directory.
33    /// Returns an empty graph if the file is missing or corrupt.
34    #[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    /// Persists the graph to `.ssg-deps.json`.
44    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    /// Records that `page` depends on `dep`.
52    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    /// Returns the dependencies for a given page.
61    #[must_use]
62    pub fn deps_for(&self, page: &Path) -> Option<&HashSet<PathBuf>> {
63        self.deps.get(page)
64    }
65
66    /// Given a list of changed files, returns all pages that need
67    /// rebuilding — either because they changed directly, or because
68    /// one of their dependencies changed.
69    #[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        // Pages whose own content changed
75        for path in changed {
76            let _ = invalidated.insert(path.clone());
77        }
78
79        // Pages whose dependencies changed
80        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    /// Returns the total number of tracked pages.
92    #[must_use]
93    pub fn page_count(&self) -> usize {
94        self.deps.len()
95    }
96
97    /// Clears all dependency entries.
98    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        // Both pages depend on the template, plus the template itself
141        assert!(result.contains(&page_a));
142        assert!(result.contains(&page_b));
143        assert_eq!(result.len(), 3); // page_a, page_b, tmpl
144    }
145
146    #[test]
147    fn transitive_not_tracked() {
148        // Only direct dependencies matter; transitive closure is not computed.
149        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        // page → partial, partial → base (but we only track direct deps)
154        graph.add_dep(&page, &partial);
155        graph.add_dep(&partial, &base);
156
157        // Changing base should NOT invalidate page (no direct dep)
158        let changed = vec![base.clone()];
159        let result = graph.invalidated_pages(&changed);
160        assert!(result.contains(&base));
161        assert!(result.contains(&partial)); // partial depends on base
162        assert!(!result.contains(&page)); // page does NOT depend on base
163    }
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        // Change only one dependency
209        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        // Change an unrelated file
224        let unrelated = PathBuf::from("static/logo.png");
225        let changed = vec![unrelated.clone()];
226        let result = graph.invalidated_pages(&changed);
227        // Only the changed file itself, not the page
228        assert_eq!(result, vec![unrelated]);
229    }
230}