Skip to main content

ssg/core/
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//! `DepGraph` tracks four things:
7//!
8//! 1. **Edges** — `consumer → set<dependency>`. A page declares a
9//!    dependency on every template, partial, and data file the
10//!    compiler will read while rendering it. Edges are reflexive (a
11//!    page depends on itself).
12//! 2. **Outputs** — `source → set<output>`. The compiler may emit
13//!    several artefacts per source (`foo.md → public/foo/index.html`,
14//!    plus a sitemap entry, an RSS entry, an SBOM entry). The output
15//!    set drives the AC5 delete sweep.
16//! 3. **Hashes** — `path → sha256`. The freshness key. A source is
17//!    "changed" iff its current SHA-256 differs from the cached value
18//!    (or no cached value exists, i.e. it's new).
19//! 4. **Schema version** — bumped when the on-disk JSON layout
20//!    changes. Loading a graph with a stale version triggers a
21//!    poisoning-resistant fallback to a full rebuild (AC6).
22//!
23//! Transitive edges are resolved on demand by [`DepGraph::invalidated`]
24//! via BFS over the reverse edge map. Persistence is atomic — the
25//! graph is written to `.tmp` then renamed (POSIX guarantee).
26
27use crate::error::SsgError;
28use serde::{Deserialize, Serialize};
29use sha2::{Digest, Sha256};
30use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
31use std::fs;
32use std::path::{Path, PathBuf};
33
34/// Filename used for the persisted graph under
35/// `target/ssg-cache/`. Issue #524 spec.
36pub const DEP_GRAPH_FILE: &str = "depgraph.json";
37
38/// Subdirectory of the cache root where the graph lives.
39///
40/// The cache root itself defaults to `target/ssg-cache/` (issue
41/// #524 spec) but the helpers accept any path so tests stay
42/// hermetic.
43pub const CACHE_DIRNAME: &str = "ssg-cache";
44
45/// Bumped whenever the persisted JSON layout changes in a way that
46/// can't be loaded by the prior parser. A version mismatch is treated
47/// exactly like a parse error: warn + full rebuild (AC6).
48const SCHEMA_VERSION: u32 = 2;
49
50/// Dependency graph mapping consumers to their dependencies.
51///
52/// Persisted to `target/ssg-cache/depgraph.json`. Loaders are
53/// poisoning-resistant: a missing, truncated, or version-mismatched
54/// file yields an empty graph — caller falls back to a full rebuild
55/// without crashing or producing stale output (AC6).
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct DepGraph {
58    /// On-disk layout version. Mismatches force a full rebuild.
59    #[serde(default = "default_version")]
60    version: u32,
61    /// `consumer → set<dependency>` (forward edges).
62    deps: BTreeMap<PathBuf, BTreeSet<PathBuf>>,
63    /// `source → set<output>`. Used by [`DepGraph::stale_outputs`] to
64    /// sweep orphaned output files when a source is deleted (AC5).
65    #[serde(default)]
66    outputs: BTreeMap<PathBuf, BTreeSet<PathBuf>>,
67    /// `path → sha256(content)`. The freshness key.
68    #[serde(default)]
69    hashes: BTreeMap<PathBuf, String>,
70}
71
72const fn default_version() -> u32 {
73    // A graph without a version field is from before v2 — treated as
74    // unusable and replaced by the empty default on load.
75    0
76}
77
78impl DepGraph {
79    /// Creates an empty dependency graph at the current schema version.
80    ///
81    /// # Examples
82    ///
83    /// ```rust
84    /// use ssg::depgraph::DepGraph;
85    ///
86    /// let g = DepGraph::new();
87    /// assert_eq!(g.page_count(), 0);
88    /// ```
89    #[must_use]
90    pub const fn new() -> Self {
91        Self {
92            version: SCHEMA_VERSION,
93            deps: BTreeMap::new(),
94            outputs: BTreeMap::new(),
95            hashes: BTreeMap::new(),
96        }
97    }
98
99    /// Loads the graph from `<cache_root>/depgraph.json`.
100    ///
101    /// Returns an empty graph if the file is missing, unreadable,
102    /// malformed, or written by an incompatible schema version. The
103    /// poisoning-resistant return matches AC6.
104    ///
105    /// # Examples
106    ///
107    /// ```rust
108    /// use ssg::depgraph::DepGraph;
109    /// use tempfile::tempdir;
110    ///
111    /// let dir = tempdir().unwrap();
112    /// // Missing cache file ⇒ empty graph (no panic, no error).
113    /// let g = DepGraph::load(dir.path());
114    /// assert_eq!(g.page_count(), 0);
115    /// ```
116    #[must_use]
117    pub fn load(cache_root: &Path) -> Self {
118        let path = cache_root.join(DEP_GRAPH_FILE);
119        let Ok(json) = fs::read_to_string(&path) else {
120            return Self::new();
121        };
122        match serde_json::from_str::<Self>(&json) {
123            Ok(g) if g.version == SCHEMA_VERSION => g,
124            Ok(_) => {
125                log::warn!(
126                    "depgraph at {} has incompatible schema; falling back to full rebuild",
127                    path.display()
128                );
129                Self::new()
130            }
131            Err(e) => {
132                log::warn!(
133                    "depgraph at {} is corrupt ({e}); falling back to full rebuild",
134                    path.display()
135                );
136                Self::new()
137            }
138        }
139    }
140
141    /// Persists the graph atomically: writes `<file>.tmp` then renames.
142    /// POSIX rename is atomic on the same filesystem.
143    ///
144    /// # Examples
145    ///
146    /// ```rust
147    /// use ssg::depgraph::DepGraph;
148    /// use tempfile::tempdir;
149    ///
150    /// let dir = tempdir().unwrap();
151    /// let g = DepGraph::new();
152    /// g.save(dir.path()).unwrap();
153    /// assert!(dir.path().join("depgraph.json").exists());
154    /// ```
155    ///
156    /// # Errors
157    /// Returns the underlying I/O failure if the cache root can't be
158    /// created or the temp file can't be written / renamed.
159    pub fn save(&self, cache_root: &Path) -> Result<(), SsgError> {
160        fs::create_dir_all(cache_root).map_err(|e| SsgError::Io {
161            path: cache_root.to_path_buf(),
162            source: e,
163        })?;
164        let final_path = cache_root.join(DEP_GRAPH_FILE);
165        let tmp_path = cache_root.join(format!("{DEP_GRAPH_FILE}.tmp"));
166        let json = serde_json::to_string(self).map_err(|e| SsgError::Io {
167            path: final_path.clone(),
168            source: std::io::Error::other(e),
169        })?;
170        fs::write(&tmp_path, json).map_err(|e| SsgError::Io {
171            path: tmp_path.clone(),
172            source: e,
173        })?;
174        fs::rename(&tmp_path, &final_path).map_err(|e| SsgError::Io {
175            path: final_path,
176            source: e,
177        })?;
178        Ok(())
179    }
180
181    /// Records that `consumer` depends on `dep`.
182    ///
183    /// # Examples
184    ///
185    /// ```rust
186    /// use ssg::depgraph::DepGraph;
187    /// use std::path::Path;
188    ///
189    /// let mut g = DepGraph::new();
190    /// g.add_dep(Path::new("page.md"), Path::new("layout.html"));
191    /// assert!(g.deps_for(Path::new("page.md")).is_some());
192    /// ```
193    pub fn add_dep(&mut self, consumer: &Path, dep: &Path) {
194        let _ = self
195            .deps
196            .entry(consumer.to_path_buf())
197            .or_default()
198            .insert(dep.to_path_buf());
199    }
200
201    /// Records that `source` produces `output`. Used by the AC5
202    /// delete-sweep to remove orphaned outputs when a source is
203    /// removed.
204    ///
205    /// # Examples
206    ///
207    /// ```rust
208    /// use ssg::depgraph::DepGraph;
209    /// use std::path::Path;
210    ///
211    /// let mut g = DepGraph::new();
212    /// g.add_output(Path::new("a.md"), Path::new("a.html"));
213    /// assert!(g.outputs_for(Path::new("a.md")).is_some());
214    /// ```
215    pub fn add_output(&mut self, source: &Path, output: &Path) {
216        let _ = self
217            .outputs
218            .entry(source.to_path_buf())
219            .or_default()
220            .insert(output.to_path_buf());
221    }
222
223    /// Records the SHA-256 freshness key for `path` from a byte slice.
224    ///
225    /// # Examples
226    ///
227    /// ```rust
228    /// use ssg::depgraph::DepGraph;
229    /// use std::path::Path;
230    /// use std::collections::HashMap;
231    ///
232    /// let mut g = DepGraph::new();
233    /// g.record_hash(Path::new("a.md"), b"hello");
234    /// // Same content ⇒ no diff.
235    /// let mut current = HashMap::new();
236    /// current.insert(Path::new("a.md").to_path_buf(), DepGraph::sha256_hex(b"hello"));
237    /// assert!(g.diff(&current).is_empty());
238    /// ```
239    pub fn record_hash(&mut self, path: &Path, content: &[u8]) {
240        let _ = self
241            .hashes
242            .insert(path.to_path_buf(), Self::sha256_hex(content));
243    }
244
245    /// Records the SHA-256 of `path` by reading it from disk.
246    /// Silently ignores missing files (the caller will catch the
247    /// absence elsewhere — typically a delete that we want to record).
248    ///
249    /// # Examples
250    ///
251    /// ```rust
252    /// use ssg::depgraph::DepGraph;
253    /// use tempfile::tempdir;
254    /// use std::fs;
255    ///
256    /// let dir = tempdir().unwrap();
257    /// let p = dir.path().join("a.md");
258    /// fs::write(&p, "hi").unwrap();
259    /// let mut g = DepGraph::new();
260    /// g.record_hash_from_disk(&p);
261    /// // Missing files are silently ignored.
262    /// g.record_hash_from_disk(&dir.path().join("missing.md"));
263    /// ```
264    pub fn record_hash_from_disk(&mut self, path: &Path) {
265        if let Ok(bytes) = fs::read(path) {
266            self.record_hash(path, &bytes);
267        }
268    }
269
270    /// Returns the SHA-256 hex string for `bytes`. Exposed so the
271    /// `populate` helpers can hash files exactly once.
272    ///
273    /// # Examples
274    ///
275    /// ```rust
276    /// use ssg::depgraph::DepGraph;
277    ///
278    /// let hex = DepGraph::sha256_hex(b"");
279    /// // Empty string has a well-known SHA-256.
280    /// assert_eq!(hex, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
281    /// ```
282    #[must_use]
283    pub fn sha256_hex(bytes: &[u8]) -> String {
284        let mut hasher = Sha256::new();
285        hasher.update(bytes);
286        let digest = hasher.finalize();
287        let mut out = String::with_capacity(64);
288        for b in digest {
289            use std::fmt::Write as _;
290            let _ = write!(out, "{b:02x}");
291        }
292        out
293    }
294
295    /// Returns the direct dependencies recorded for `consumer`.
296    ///
297    /// # Examples
298    ///
299    /// ```rust
300    /// use ssg::depgraph::DepGraph;
301    /// use std::path::Path;
302    ///
303    /// let mut g = DepGraph::new();
304    /// g.add_dep(Path::new("p.md"), Path::new("layout.html"));
305    /// assert_eq!(g.deps_for(Path::new("p.md")).map(|s| s.len()), Some(1));
306    /// assert!(g.deps_for(Path::new("none")).is_none());
307    /// ```
308    #[must_use]
309    pub fn deps_for(&self, consumer: &Path) -> Option<&BTreeSet<PathBuf>> {
310        self.deps.get(consumer)
311    }
312
313    /// Returns the recorded outputs for `source`.
314    ///
315    /// # Examples
316    ///
317    /// ```rust
318    /// use ssg::depgraph::DepGraph;
319    /// use std::path::Path;
320    ///
321    /// let mut g = DepGraph::new();
322    /// g.add_output(Path::new("a.md"), Path::new("a.html"));
323    /// assert_eq!(g.outputs_for(Path::new("a.md")).map(|s| s.len()), Some(1));
324    /// ```
325    #[must_use]
326    pub fn outputs_for(&self, source: &Path) -> Option<&BTreeSet<PathBuf>> {
327        self.outputs.get(source)
328    }
329
330    /// Returns every tracked source path (the keys of the output map).
331    ///
332    /// # Examples
333    ///
334    /// ```rust
335    /// use ssg::depgraph::DepGraph;
336    /// use std::path::Path;
337    ///
338    /// let mut g = DepGraph::new();
339    /// g.add_output(Path::new("a.md"), Path::new("a.html"));
340    /// assert_eq!(g.tracked_sources(), vec![Path::new("a.md").to_path_buf()]);
341    /// ```
342    #[must_use]
343    pub fn tracked_sources(&self) -> Vec<PathBuf> {
344        let mut v: Vec<PathBuf> = self.outputs.keys().cloned().collect();
345        v.sort();
346        v
347    }
348
349    /// Returns the count of edge consumers (pages + intermediate deps).
350    ///
351    /// # Examples
352    ///
353    /// ```rust
354    /// use ssg::depgraph::DepGraph;
355    /// use std::path::Path;
356    ///
357    /// let mut g = DepGraph::new();
358    /// assert_eq!(g.page_count(), 0);
359    /// g.add_dep(Path::new("p.md"), Path::new("l.html"));
360    /// assert_eq!(g.page_count(), 1);
361    /// ```
362    #[must_use]
363    pub fn page_count(&self) -> usize {
364        self.deps.len()
365    }
366
367    /// Removes every entry that references `path` as either a consumer
368    /// or a dependency. Called by [`Self::diff`] when a source is
369    /// deleted.
370    ///
371    /// # Examples
372    ///
373    /// ```rust
374    /// use ssg::depgraph::DepGraph;
375    /// use std::path::Path;
376    ///
377    /// let mut g = DepGraph::new();
378    /// g.add_dep(Path::new("p.md"), Path::new("l.html"));
379    /// g.forget(Path::new("p.md"));
380    /// assert!(g.deps_for(Path::new("p.md")).is_none());
381    /// ```
382    pub fn forget(&mut self, path: &Path) {
383        let _ = self.deps.remove(path);
384        let _ = self.outputs.remove(path);
385        let _ = self.hashes.remove(path);
386        for set in self.deps.values_mut() {
387            let _ = set.remove(path);
388        }
389    }
390
391    /// Clears the entire graph.
392    ///
393    /// # Examples
394    ///
395    /// ```rust
396    /// use ssg::depgraph::DepGraph;
397    /// use std::path::Path;
398    ///
399    /// let mut g = DepGraph::new();
400    /// g.add_dep(Path::new("p.md"), Path::new("l.html"));
401    /// g.clear();
402    /// assert_eq!(g.page_count(), 0);
403    /// ```
404    pub fn clear(&mut self) {
405        self.deps.clear();
406        self.outputs.clear();
407        self.hashes.clear();
408    }
409
410    /// Returns every consumer reachable from any of `changed` via the
411    /// reverse edge map (transitive closure, AC3). Sources whose own
412    /// content changed are always included.
413    ///
414    /// # Examples
415    ///
416    /// ```rust
417    /// use ssg::depgraph::DepGraph;
418    /// use std::path::{Path, PathBuf};
419    ///
420    /// let mut g = DepGraph::new();
421    /// g.add_dep(Path::new("p.md"), Path::new("l.html"));
422    /// let changed = vec![PathBuf::from("l.html")];
423    /// // Changing the layout invalidates the page that consumes it.
424    /// assert!(g.invalidated(&changed).contains(&PathBuf::from("p.md")));
425    /// ```
426    #[must_use]
427    pub fn invalidated(&self, changed: &[PathBuf]) -> Vec<PathBuf> {
428        let reverse = self.reverse_edges();
429        let mut seen: HashSet<PathBuf> = HashSet::new();
430        let mut queue: VecDeque<PathBuf> = changed.iter().cloned().collect();
431        while let Some(p) = queue.pop_front() {
432            if !seen.insert(p.clone()) {
433                continue;
434            }
435            if let Some(parents) = reverse.get(&p) {
436                for parent in parents {
437                    if !seen.contains(parent) {
438                        queue.push_back(parent.clone());
439                    }
440                }
441            }
442        }
443        let mut result: Vec<PathBuf> = seen.into_iter().collect();
444        result.sort();
445        result
446    }
447
448    /// Returns the union of output paths for every invalidated source.
449    /// Sources that don't appear in the output map (templates,
450    /// partials, data files) contribute nothing.
451    ///
452    /// # Examples
453    ///
454    /// ```rust
455    /// use ssg::depgraph::DepGraph;
456    /// use std::path::{Path, PathBuf};
457    ///
458    /// let mut g = DepGraph::new();
459    /// g.add_dep(Path::new("p.md"), Path::new("l.html"));
460    /// g.add_output(Path::new("p.md"), Path::new("p.html"));
461    /// let outs = g.invalidated_outputs(&[PathBuf::from("l.html")]);
462    /// assert_eq!(outs, vec![PathBuf::from("p.html")]);
463    /// ```
464    #[must_use]
465    pub fn invalidated_outputs(&self, changed: &[PathBuf]) -> Vec<PathBuf> {
466        let mut out: HashSet<PathBuf> = HashSet::new();
467        for p in self.invalidated(changed) {
468            if let Some(outs) = self.outputs.get(&p) {
469                for o in outs {
470                    let _ = out.insert(o.clone());
471                }
472            }
473        }
474        let mut v: Vec<PathBuf> = out.into_iter().collect();
475        v.sort();
476        v
477    }
478
479    /// Inverts the forward edge map so the BFS can walk
480    /// `dependency → set<consumer>`.
481    fn reverse_edges(&self) -> HashMap<PathBuf, HashSet<PathBuf>> {
482        let mut rev: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
483        for (consumer, deps) in &self.deps {
484            for dep in deps {
485                let _ = rev
486                    .entry(dep.clone())
487                    .or_default()
488                    .insert(consumer.clone());
489            }
490        }
491        rev
492    }
493
494    /// Compares `current` (path → sha256) against the cached hashes
495    /// and returns `(changed, deleted)`.
496    ///
497    /// * `changed` — paths whose current hash differs from cache, or
498    ///   paths that weren't in the cache (new files).
499    /// * `deleted` — paths the cache knew about that are absent from
500    ///   `current`.
501    ///
502    /// # Examples
503    ///
504    /// ```rust
505    /// use ssg::depgraph::DepGraph;
506    /// use std::collections::HashMap;
507    /// use std::path::PathBuf;
508    ///
509    /// let g = DepGraph::new();
510    /// let mut current = HashMap::new();
511    /// current.insert(PathBuf::from("a.md"), DepGraph::sha256_hex(b"x"));
512    /// // Empty graph ⇒ everything in `current` looks new.
513    /// let d = g.diff(&current);
514    /// assert_eq!(d.changed, vec![PathBuf::from("a.md")]);
515    /// assert!(d.deleted.is_empty());
516    /// ```
517    #[must_use]
518    pub fn diff(&self, current: &HashMap<PathBuf, String>) -> Diff {
519        let mut changed = Vec::new();
520        let mut deleted = Vec::new();
521        for (path, hash) in current {
522            match self.hashes.get(path) {
523                Some(prev) if prev == hash => {}
524                _ => changed.push(path.clone()),
525            }
526        }
527        for path in self.hashes.keys() {
528            if !current.contains_key(path) {
529                deleted.push(path.clone());
530            }
531        }
532        changed.sort();
533        deleted.sort();
534        Diff { changed, deleted }
535    }
536}
537
538/// Result of [`DepGraph::diff`].
539#[derive(Debug, Clone, Default, PartialEq, Eq)]
540pub struct Diff {
541    /// Sources whose SHA-256 changed since the cached graph, or new
542    /// sources without a cached hash.
543    pub changed: Vec<PathBuf>,
544    /// Sources tracked by the cached graph that no longer exist on
545    /// disk.
546    pub deleted: Vec<PathBuf>,
547}
548
549impl Diff {
550    /// Returns `true` when nothing changed and nothing was deleted —
551    /// the warm-cache zero-work fast path.
552    ///
553    /// # Examples
554    ///
555    /// ```rust
556    /// use ssg::depgraph::Diff;
557    ///
558    /// let d = Diff::default();
559    /// assert!(d.is_empty());
560    /// ```
561    #[must_use]
562    pub const fn is_empty(&self) -> bool {
563        self.changed.is_empty() && self.deleted.is_empty()
564    }
565}
566
567// ---------------------------------------------------------------------
568// Populate helpers: scan content + templates to build the edge set.
569// ---------------------------------------------------------------------
570
571/// Every data file a build reads, in both supported locations.
572///
573/// Two conventions are in play and both are live: `data/` beside the
574/// content directory, read by [`TemplateEngine::load_data_files`], and
575/// `_data/` for the topic-cluster curation file. A build reads them
576/// globally rather than per page, so they are collected once.
577///
578/// [`TemplateEngine::load_data_files`]: crate::template_engine::TemplateEngine::load_data_files
579fn data_files(content_dir: &Path) -> Vec<PathBuf> {
580    /// Extensions `load_data_files` actually parses. A `.md` sitting in
581    /// `data/` is not a data file and must not become an edge.
582    const DATA_EXTS: &[&str] = &["json", "toml", "yaml", "yml"];
583
584    let mut roots = Vec::new();
585    if let Some(parent) = content_dir.parent() {
586        roots.push(parent.join("data"));
587        roots.push(parent.join("_data"));
588    }
589    roots.push(content_dir.join("data"));
590    roots.push(content_dir.join("_data"));
591
592    let mut out = Vec::new();
593    for root in roots {
594        let Ok(entries) = fs::read_dir(&root) else {
595            continue;
596        };
597        for entry in entries.flatten() {
598            let path = entry.path();
599            if !path.is_file() {
600                continue;
601            }
602            let ext = path
603                .extension()
604                .unwrap_or_default()
605                .to_string_lossy()
606                .to_lowercase();
607            if DATA_EXTS.contains(&ext.as_str()) {
608                out.push(path);
609            }
610        }
611    }
612    out.sort();
613    out.dedup();
614    out
615}
616
617/// Reads every `.md` file under `content_dir` and every `.html` file
618/// under `template_dir`, recording:
619///
620/// * a content → template edge for each `layout:` frontmatter value
621///   (`layout: "post"` resolves to `<template_dir>/<locale>/post.html`
622///   when a localised copy exists, otherwise `<template_dir>/post.html`);
623/// * a template → template edge for each `{{#extends "name"}}` and
624///   `{{->partial}}` reference inside the template;
625/// * the canonical output path `<build_dir>/<stem>/index.html`
626///   (or `<build_dir>/index.html` for `index.md`);
627/// * the SHA-256 freshness key for every source touched.
628///
629/// Self-edges (`page → page`) are always recorded so deleting a page
630/// invalidates its own output. Missing template files don't fault —
631/// the build will fail later with a friendlier message.
632///
633/// # Examples
634///
635/// ```rust
636/// use ssg::depgraph::{DepGraph, populate};
637/// use tempfile::tempdir;
638/// use std::fs;
639///
640/// let dir = tempdir().unwrap();
641/// let content = dir.path().join("content");
642/// let templates = dir.path().join("templates");
643/// let build = dir.path().join("build");
644/// fs::create_dir(&content).unwrap();
645/// fs::create_dir(&templates).unwrap();
646/// let mut g = DepGraph::new();
647/// // Walking empty trees is a no-op.
648/// populate(&mut g, &content, &templates, &build).unwrap();
649/// assert_eq!(g.page_count(), 0);
650/// ```
651pub fn populate(
652    graph: &mut DepGraph,
653    content_dir: &Path,
654    template_dir: &Path,
655    build_dir: &Path,
656) -> Result<(), SsgError> {
657    let md_files = crate::walk::walk_files_bounded_depth(
658        content_dir,
659        "md",
660        crate::MAX_DIR_DEPTH,
661    )?;
662
663    for md in &md_files {
664        let bytes = fs::read(md).map_err(|e| SsgError::Io {
665            path: md.clone(),
666            source: e,
667        })?;
668        graph.record_hash(md, &bytes);
669
670        let layout = extract_layout(&bytes);
671        let outputs = output_paths_for(md, content_dir, build_dir);
672        for o in &outputs {
673            graph.add_output(md, o);
674            // self-edge so deletes propagate via invalidated()
675            graph.add_dep(md, md);
676            if let Some(ref layout_name) = layout {
677                if let Some(tpl) =
678                    resolve_template(template_dir, md, content_dir, layout_name)
679                {
680                    graph.add_dep(md, &tpl);
681                }
682            }
683        }
684    }
685
686    // Data files are read globally, not per page: a template can
687    // address `data.foo` from anywhere, and the topic curation file
688    // reshapes every topic page. So a change to one invalidates every
689    // page, and the edge set says so rather than leaving those pages
690    // looking clean.
691    let data = data_files(content_dir);
692    for df in &data {
693        if let Ok(bytes) = fs::read(df) {
694            graph.record_hash(df, &bytes);
695        }
696        for md in &md_files {
697            graph.add_dep(md, df);
698        }
699    }
700
701    let tpl_files = crate::walk::walk_files_bounded_depth(
702        template_dir,
703        "html",
704        crate::MAX_DIR_DEPTH,
705    )?;
706    for tpl in &tpl_files {
707        let bytes = fs::read(tpl).map_err(|e| SsgError::Io {
708            path: tpl.clone(),
709            source: e,
710        })?;
711        graph.record_hash(tpl, &bytes);
712        let text = String::from_utf8_lossy(&bytes);
713        for parent in scan_template_refs(&text) {
714            let resolved = template_dir.join(format!("{parent}.html"));
715            // Edge: tpl depends on parent (extends / include).
716            graph.add_dep(tpl, &resolved);
717        }
718    }
719
720    Ok(())
721}
722
723/// Walks every tracked source on disk and returns
724/// `path → sha256(content)`. Used by [`DepGraph::diff`] on the
725/// incremental hot path. Sources that disappear silently drop out.
726///
727/// # Examples
728///
729/// ```rust
730/// use ssg::depgraph::current_hashes;
731/// use tempfile::tempdir;
732/// use std::fs;
733///
734/// let dir = tempdir().unwrap();
735/// let content = dir.path().join("content");
736/// let templates = dir.path().join("templates");
737/// fs::create_dir(&content).unwrap();
738/// fs::create_dir(&templates).unwrap();
739/// let map = current_hashes(&content, &templates).unwrap();
740/// assert!(map.is_empty());
741/// ```
742pub fn current_hashes(
743    content_dir: &Path,
744    template_dir: &Path,
745) -> Result<HashMap<PathBuf, String>, SsgError> {
746    let mut out = HashMap::new();
747    // Infallible by construction: sources that disappear between the
748    // walk and the read silently drop out (see the doc comment).
749    let mut push = |paths: Vec<PathBuf>| {
750        for p in paths {
751            if let Ok(bytes) = fs::read(&p) {
752                let _ = out.insert(p, DepGraph::sha256_hex(&bytes));
753            }
754        }
755    };
756    push(crate::walk::walk_files_bounded_depth(
757        content_dir,
758        "md",
759        crate::MAX_DIR_DEPTH,
760    )?);
761    push(crate::walk::walk_files_bounded_depth(
762        template_dir,
763        "html",
764        crate::MAX_DIR_DEPTH,
765    )?);
766    Ok(out)
767}
768
769/// Extracts the `layout:` field from a YAML frontmatter header. Returns
770/// `None` if the file lacks frontmatter or doesn't declare a layout.
771fn extract_layout(bytes: &[u8]) -> Option<String> {
772    let text = std::str::from_utf8(bytes).ok()?;
773    let trimmed = text.trim_start();
774    let body = trimmed.strip_prefix("---")?;
775    let end = body.find("\n---")?;
776    let fm = &body[..end];
777    for line in fm.lines() {
778        let l = line.trim();
779        if let Some(rest) = l.strip_prefix("layout:") {
780            return Some(
781                rest.trim()
782                    .trim_matches(|c| c == '"' || c == '\'')
783                    .split_whitespace()
784                    .next()?
785                    .trim_matches(|c| c == '"' || c == '\'')
786                    .to_string(),
787            );
788        }
789    }
790    None
791}
792
793/// Computes the canonical output paths the compiler will emit for a
794/// given content file. Mirrors `staticdatagen`'s naming convention:
795///
796///   `<content_dir>/index.md`             → `<build_dir>/index.html`
797///   `<content_dir>/<stem>.md`            → `<build_dir>/<stem>/index.html`
798///   `<content_dir>/<sub>/<stem>.md`      → `<build_dir>/<sub>/<stem>/index.html`
799///   `<content_dir>/<sub>/index.md`       → `<build_dir>/<sub>/index.html`
800fn output_paths_for(
801    md: &Path,
802    content_dir: &Path,
803    build_dir: &Path,
804) -> Vec<PathBuf> {
805    let rel = match md.strip_prefix(content_dir) {
806        Ok(r) => r.to_path_buf(),
807        Err(_) => return Vec::new(),
808    };
809    let parent = rel.parent().map(Path::to_path_buf).unwrap_or_default();
810    let stem = rel.file_stem().and_then(|s| s.to_str()).unwrap_or("");
811    let out = if stem == "index" {
812        build_dir.join(&parent).join("index.html")
813    } else {
814        build_dir.join(&parent).join(stem).join("index.html")
815    };
816    vec![out]
817}
818
819/// Resolves a `layout: "post"` frontmatter value to the on-disk
820/// template path. Prefers a locale-aware sibling
821/// (`<template_dir>/<locale>/post.html`) inferred from the leading
822/// directory component of the content file's relative path; falls back
823/// to `<template_dir>/post.html`. Returns `None` if neither exists —
824/// the consumer is free to record no edge or surface the error later.
825fn resolve_template(
826    template_dir: &Path,
827    md: &Path,
828    content_dir: &Path,
829    layout: &str,
830) -> Option<PathBuf> {
831    if let Ok(rel) = md.strip_prefix(content_dir) {
832        if let Some(first) = rel.components().next() {
833            let candidate = template_dir
834                .join(first.as_os_str())
835                .join(format!("{layout}.html"));
836            if candidate.exists() {
837                return Some(candidate);
838            }
839        }
840    }
841    let fallback = template_dir.join(format!("{layout}.html"));
842    if fallback.exists() {
843        Some(fallback)
844    } else {
845        None
846    }
847}
848
849/// Scans a template body for `{{#extends "name"}}` and `{{->name}}`
850/// references and returns each `name` once. Order-preserving but
851/// de-duplicated.
852fn scan_template_refs(text: &str) -> Vec<String> {
853    let mut out: Vec<String> = Vec::new();
854    let mut seen: HashSet<String> = HashSet::new();
855    let mut rest = text;
856    while let Some(start) = rest.find("{{") {
857        rest = &rest[start + 2..];
858        let Some(end) = rest.find("}}") else {
859            break;
860        };
861        let inner = rest[..end].trim();
862        rest = &rest[end + 2..];
863        let name_opt = if let Some(after) = inner.strip_prefix("#extends") {
864            Some(parse_name(after.trim()))
865        } else {
866            inner
867                .strip_prefix("->")
868                .map(|after| parse_name(after.trim()))
869        };
870        if let Some(name) = name_opt {
871            if !name.is_empty() && seen.insert(name.clone()) {
872                out.push(name);
873            }
874        }
875    }
876    out
877}
878
879/// Parses a bareword or quoted template name from an `extends` /
880/// `partial` invocation. Strips a trailing parameter list (the partial
881/// invocation `header title="foo"` parses to `header`).
882fn parse_name(s: &str) -> String {
883    let s = s.trim().trim_matches(|c| c == '"' || c == '\'');
884    s.split(|c: char| c.is_whitespace() || c == '"' || c == '\'')
885        .next()
886        .unwrap_or("")
887        .to_string()
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893    use tempfile::tempdir;
894
895    fn write(p: &Path, body: &str) {
896        if let Some(parent) = p.parent() {
897            fs::create_dir_all(parent).unwrap();
898        }
899        fs::write(p, body).unwrap();
900    }
901
902    #[test]
903    fn empty_graph_only_reports_changed_inputs() {
904        let graph = DepGraph::new();
905        let changed = vec![PathBuf::from("content/index.md")];
906        let result = graph.invalidated(&changed);
907        assert_eq!(result, vec![PathBuf::from("content/index.md")]);
908    }
909
910    #[test]
911    fn direct_change_invalidates_only_the_page() {
912        let mut graph = DepGraph::new();
913        let page = PathBuf::from("content/about.md");
914        let tmpl = PathBuf::from("templates/base.html");
915        graph.add_dep(&page, &tmpl);
916
917        let changed = vec![page.clone()];
918        let result = graph.invalidated(&changed);
919        assert!(result.contains(&page));
920        assert_eq!(result.len(), 1, "no other consumers should fire");
921    }
922
923    #[test]
924    fn dependency_change_invalidates_all_consumers() {
925        let mut graph = DepGraph::new();
926        let a = PathBuf::from("content/index.md");
927        let b = PathBuf::from("content/about.md");
928        let tmpl = PathBuf::from("templates/base.html");
929        graph.add_dep(&a, &tmpl);
930        graph.add_dep(&b, &tmpl);
931
932        let result = graph.invalidated(std::slice::from_ref(&tmpl));
933        assert!(result.contains(&a));
934        assert!(result.contains(&b));
935        assert!(result.contains(&tmpl));
936        assert_eq!(result.len(), 3);
937    }
938
939    #[test]
940    fn transitive_edges_are_tracked_via_bfs() {
941        // AC3: page → partial → base. Changing `base` must invalidate
942        // `page` as well. This flips the prior `transitive_not_tracked`
943        // assertion.
944        let mut graph = DepGraph::new();
945        let page = PathBuf::from("content/index.md");
946        let partial = PathBuf::from("templates/partial.html");
947        let base = PathBuf::from("templates/base.html");
948        graph.add_dep(&page, &partial);
949        graph.add_dep(&partial, &base);
950
951        let result = graph.invalidated(std::slice::from_ref(&base));
952        assert!(result.contains(&base));
953        assert!(result.contains(&partial));
954        assert!(
955            result.contains(&page),
956            "transitive consumer must be invalidated"
957        );
958    }
959
960    #[test]
961    fn invalidated_outputs_unions_outputs_of_every_consumer() {
962        let mut graph = DepGraph::new();
963        let page = PathBuf::from("content/about.md");
964        let out = PathBuf::from("public/about/index.html");
965        let tmpl = PathBuf::from("templates/page.html");
966        graph.add_dep(&page, &tmpl);
967        graph.add_output(&page, &out);
968
969        let result = graph.invalidated_outputs(&[tmpl]);
970        assert_eq!(result, vec![out]);
971    }
972
973    #[test]
974    fn diff_reports_changed_new_and_deleted() {
975        let mut graph = DepGraph::new();
976        graph.record_hash(Path::new("a.md"), b"alpha");
977        graph.record_hash(Path::new("b.md"), b"beta");
978        graph.record_hash(Path::new("c.md"), b"gamma");
979
980        let mut current = HashMap::new();
981        let _ = current
982            .insert(PathBuf::from("a.md"), DepGraph::sha256_hex(b"alpha"));
983        // b.md changed
984        let _ = current
985            .insert(PathBuf::from("b.md"), DepGraph::sha256_hex(b"beta-prime"));
986        // c.md deleted
987        // d.md new
988        let _ = current
989            .insert(PathBuf::from("d.md"), DepGraph::sha256_hex(b"delta"));
990
991        let diff = graph.diff(&current);
992        assert_eq!(
993            diff.changed,
994            vec![PathBuf::from("b.md"), PathBuf::from("d.md")]
995        );
996        assert_eq!(diff.deleted, vec![PathBuf::from("c.md")]);
997        assert!(!diff.is_empty());
998    }
999
1000    #[test]
1001    fn diff_no_changes_is_empty() {
1002        let mut graph = DepGraph::new();
1003        graph.record_hash(Path::new("a.md"), b"alpha");
1004        let mut current = HashMap::new();
1005        let _ = current
1006            .insert(PathBuf::from("a.md"), DepGraph::sha256_hex(b"alpha"));
1007        let diff = graph.diff(&current);
1008        assert!(diff.is_empty());
1009    }
1010
1011    #[test]
1012    fn save_and_load_round_trip_preserves_edges_and_hashes() {
1013        let dir = tempdir().unwrap();
1014        let mut graph = DepGraph::new();
1015        let page = PathBuf::from("content/index.md");
1016        let tmpl = PathBuf::from("templates/base.html");
1017        let out = PathBuf::from("public/index.html");
1018        graph.add_dep(&page, &tmpl);
1019        graph.add_output(&page, &out);
1020        graph.record_hash(&page, b"hello");
1021
1022        graph.save(dir.path()).unwrap();
1023        let loaded = DepGraph::load(dir.path());
1024
1025        assert_eq!(loaded.deps_for(&page).unwrap().len(), 1);
1026        assert!(loaded.outputs_for(&page).unwrap().contains(&out));
1027        let mut current = HashMap::new();
1028        let _ = current.insert(page, DepGraph::sha256_hex(b"hello"));
1029        assert!(loaded.diff(&current).is_empty());
1030    }
1031
1032    #[test]
1033    fn load_missing_file_yields_empty_graph() {
1034        let dir = tempdir().unwrap();
1035        let graph = DepGraph::load(dir.path());
1036        assert_eq!(graph.page_count(), 0);
1037        assert_eq!(graph.version, SCHEMA_VERSION);
1038    }
1039
1040    #[test]
1041    fn load_corrupt_json_falls_back_to_empty_ac6() {
1042        let dir = tempdir().unwrap();
1043        fs::write(dir.path().join(DEP_GRAPH_FILE), "{{ not json").unwrap();
1044        let graph = DepGraph::load(dir.path());
1045        assert_eq!(graph.page_count(), 0);
1046    }
1047
1048    #[test]
1049    fn load_wrong_schema_version_falls_back_to_empty_ac6() {
1050        let dir = tempdir().unwrap();
1051        let body =
1052            r#"{"version":0,"deps":{},"outputs":{},"hashes":{}}"#.to_string();
1053        fs::write(dir.path().join(DEP_GRAPH_FILE), body).unwrap();
1054        let graph = DepGraph::load(dir.path());
1055        assert_eq!(graph.page_count(), 0);
1056    }
1057
1058    #[test]
1059    fn forget_removes_all_traces_of_a_path() {
1060        let mut graph = DepGraph::new();
1061        let page = PathBuf::from("content/about.md");
1062        let other = PathBuf::from("content/index.md");
1063        let tmpl = PathBuf::from("templates/page.html");
1064        graph.add_dep(&page, &tmpl);
1065        graph.add_dep(&other, &tmpl);
1066        graph.add_dep(&other, &page); // sibling reference
1067        graph.add_output(&page, Path::new("public/about/index.html"));
1068        graph.record_hash(&page, b"x");
1069
1070        graph.forget(&page);
1071
1072        assert!(graph.deps_for(&page).is_none());
1073        assert!(graph.outputs_for(&page).is_none());
1074        let other_deps = graph.deps_for(&other).unwrap();
1075        assert!(!other_deps.contains(&page));
1076        assert!(other_deps.contains(&tmpl));
1077    }
1078
1079    #[test]
1080    fn clear_empties_everything() {
1081        let mut graph = DepGraph::new();
1082        graph.add_dep(Path::new("a"), Path::new("b"));
1083        graph.add_output(Path::new("a"), Path::new("o"));
1084        graph.record_hash(Path::new("a"), b"x");
1085        graph.clear();
1086        assert_eq!(graph.page_count(), 0);
1087        assert!(
1088            graph.tracked_sources().is_empty(),
1089            "{:?}",
1090            graph.tracked_sources()
1091        );
1092    }
1093
1094    #[test]
1095    fn sha256_hex_is_deterministic_64_chars() {
1096        let h = DepGraph::sha256_hex(b"hello");
1097        assert_eq!(h.len(), 64);
1098        assert_eq!(h, DepGraph::sha256_hex(b"hello"));
1099    }
1100
1101    #[test]
1102    fn sha256_hex_distinguishes_inputs() {
1103        assert_ne!(DepGraph::sha256_hex(b"a"), DepGraph::sha256_hex(b"b"));
1104    }
1105
1106    /// A change to a data file must invalidate every page.
1107    ///
1108    /// Data is read globally — a template can address `data.foo` from
1109    /// anywhere — so there is no page that provably does not use it.
1110    /// Before this edge existed, editing `data/site.toml` left every
1111    /// page looking clean and an incremental build would have skipped
1112    /// all of them.
1113    #[test]
1114    fn a_data_file_change_invalidates_every_page() {
1115        let dir = tempdir().unwrap();
1116        let content = dir.path().join("content");
1117        let template = dir.path().join("templates");
1118        let build = dir.path().join("public");
1119        let data = dir.path().join("data");
1120        for d in [&content, &template, &data] {
1121            fs::create_dir_all(d).unwrap();
1122        }
1123        write(
1124            &content.join("index.md"),
1125            "---\nlayout: \"page\"\n---\nbody",
1126        );
1127        write(
1128            &content.join("about.md"),
1129            "---\nlayout: \"page\"\n---\nbody",
1130        );
1131        write(
1132            &template.join("page.html"),
1133            "<html>{{ data.site.name }}</html>",
1134        );
1135        write(&data.join("site.toml"), "name = \"Example\"\n");
1136
1137        let mut graph = DepGraph::new();
1138        populate(&mut graph, &content, &template, &build).unwrap();
1139
1140        let site_data = data.join("site.toml");
1141        let hit = graph.invalidated(std::slice::from_ref(&site_data));
1142        for page in [content.join("index.md"), content.join("about.md")] {
1143            assert!(
1144                hit.contains(&page),
1145                "{} not invalidated by a data-file change",
1146                page.display()
1147            );
1148        }
1149    }
1150
1151    /// The topic curation file lives in `_data/`, not `data/`.
1152    ///
1153    /// It reshapes every topic page, so it is a dependency by the same
1154    /// argument — and it would have been missed by a helper that only
1155    /// knew the one convention.
1156    #[test]
1157    fn the_underscore_data_convention_is_tracked_too() {
1158        let dir = tempdir().unwrap();
1159        let content = dir.path().join("content");
1160        let template = dir.path().join("templates");
1161        let build = dir.path().join("public");
1162        let data = dir.path().join("_data");
1163        for d in [&content, &template, &data] {
1164            fs::create_dir_all(d).unwrap();
1165        }
1166        write(
1167            &content.join("index.md"),
1168            "---\nlayout: \"page\"\n---\nbody",
1169        );
1170        write(&template.join("page.html"), "<html></html>");
1171        write(&data.join("topics.toml"), "[rust]\ntitle = \"Rust\"\n");
1172
1173        let mut graph = DepGraph::new();
1174        populate(&mut graph, &content, &template, &build).unwrap();
1175
1176        let topics = data.join("topics.toml");
1177        assert!(
1178            graph
1179                .invalidated(std::slice::from_ref(&topics))
1180                .contains(&content.join("index.md")),
1181            "_data/topics.toml is not tracked"
1182        );
1183    }
1184
1185    /// A stray file in `data/` that the loader cannot parse is not a
1186    /// dependency, and must not become an edge.
1187    #[test]
1188    fn non_data_extensions_in_the_data_dir_are_ignored() {
1189        let dir = tempdir().unwrap();
1190        let content = dir.path().join("content");
1191        let template = dir.path().join("templates");
1192        let build = dir.path().join("public");
1193        let data = dir.path().join("data");
1194        for d in [&content, &template, &data] {
1195            fs::create_dir_all(d).unwrap();
1196        }
1197        write(
1198            &content.join("index.md"),
1199            "---\nlayout: \"page\"\n---\nbody",
1200        );
1201        write(&template.join("page.html"), "<html></html>");
1202        write(&data.join("notes.md"), "not a data file");
1203        write(&data.join("real.json"), "{}");
1204
1205        let mut graph = DepGraph::new();
1206        populate(&mut graph, &content, &template, &build).unwrap();
1207
1208        let index = content.join("index.md");
1209        let deps = graph.deps_for(&index).unwrap();
1210        assert!(deps.contains(&data.join("real.json")), "json not tracked");
1211        assert!(
1212            !deps.contains(&data.join("notes.md")),
1213            "a .md in data/ is not a data file"
1214        );
1215    }
1216
1217    #[test]
1218    fn populate_walks_real_directories_and_records_edges() {
1219        let dir = tempdir().unwrap();
1220        let content = dir.path().join("content");
1221        let template = dir.path().join("templates");
1222        let build = dir.path().join("public");
1223        fs::create_dir_all(&content).unwrap();
1224        fs::create_dir_all(&template).unwrap();
1225
1226        write(
1227            &content.join("index.md"),
1228            "---\nlayout: \"page\"\n---\nbody",
1229        );
1230        write(
1231            &content.join("about.md"),
1232            "---\nlayout: \"page\"\n---\nbody",
1233        );
1234        write(&template.join("page.html"), "<html>{{title}}</html>");
1235
1236        let mut graph = DepGraph::new();
1237        populate(&mut graph, &content, &template, &build).unwrap();
1238
1239        // Edges recorded
1240        let index = content.join("index.md");
1241        let about = content.join("about.md");
1242        let page_tpl = template.join("page.html");
1243        assert!(graph.deps_for(&index).unwrap().contains(&page_tpl));
1244        assert!(graph.deps_for(&about).unwrap().contains(&page_tpl));
1245
1246        // Outputs recorded correctly
1247        let outs_index = graph.outputs_for(&index).unwrap();
1248        assert!(outs_index.contains(&build.join("index.html")));
1249        let outs_about = graph.outputs_for(&about).unwrap();
1250        assert!(outs_about.contains(&build.join("about").join("index.html")));
1251
1252        // Hashes recorded
1253        assert!(!graph.hashes.is_empty());
1254    }
1255
1256    #[test]
1257    fn populate_records_template_to_template_edges() {
1258        let dir = tempdir().unwrap();
1259        let content = dir.path().join("content");
1260        let template = dir.path().join("templates");
1261        let build = dir.path().join("public");
1262        fs::create_dir_all(&content).unwrap();
1263        fs::create_dir_all(&template).unwrap();
1264
1265        write(&content.join("index.md"), "---\nlayout: \"page\"\n---\n");
1266        write(
1267            &template.join("page.html"),
1268            "{{#extends \"base\"}}\n<p>x</p>",
1269        );
1270        write(&template.join("base.html"), "<html>{{body}}</html>");
1271
1272        let mut graph = DepGraph::new();
1273        populate(&mut graph, &content, &template, &build).unwrap();
1274
1275        let page_tpl = template.join("page.html");
1276        let base_tpl = template.join("base.html");
1277        assert!(
1278            graph.deps_for(&page_tpl).unwrap().contains(&base_tpl),
1279            "template→template edge must be recorded"
1280        );
1281
1282        // Transitive: changing base.html must invalidate the content page.
1283        let invalidated = graph.invalidated(&[base_tpl]);
1284        assert!(invalidated.contains(&content.join("index.md")));
1285    }
1286
1287    #[test]
1288    fn populate_records_partial_references() {
1289        let dir = tempdir().unwrap();
1290        let content = dir.path().join("content");
1291        let template = dir.path().join("templates");
1292        let build = dir.path().join("public");
1293        fs::create_dir_all(&content).unwrap();
1294        fs::create_dir_all(&template).unwrap();
1295
1296        write(&content.join("index.md"), "---\nlayout: \"page\"\n---\n");
1297        write(
1298            &template.join("page.html"),
1299            "<div>{{->header title=\"x\"}}</div>",
1300        );
1301        write(&template.join("header.html"), "<h1>{{title}}</h1>");
1302
1303        let mut graph = DepGraph::new();
1304        populate(&mut graph, &content, &template, &build).unwrap();
1305
1306        let page_tpl = template.join("page.html");
1307        let header_tpl = template.join("header.html");
1308        assert!(graph.deps_for(&page_tpl).unwrap().contains(&header_tpl));
1309    }
1310
1311    #[test]
1312    fn output_paths_for_root_index_emits_root_index_html() {
1313        let outs = output_paths_for(
1314            Path::new("/c/index.md"),
1315            Path::new("/c"),
1316            Path::new("/b"),
1317        );
1318        assert_eq!(outs, vec![PathBuf::from("/b/index.html")]);
1319    }
1320
1321    #[test]
1322    fn output_paths_for_nested_post_emits_subdir_index() {
1323        let outs = output_paths_for(
1324            Path::new("/c/blog/foo.md"),
1325            Path::new("/c"),
1326            Path::new("/b"),
1327        );
1328        assert_eq!(outs, vec![PathBuf::from("/b/blog/foo/index.html")]);
1329    }
1330
1331    #[test]
1332    fn output_paths_for_nested_index_emits_subdir_index_html() {
1333        let outs = output_paths_for(
1334            Path::new("/c/blog/index.md"),
1335            Path::new("/c"),
1336            Path::new("/b"),
1337        );
1338        assert_eq!(outs, vec![PathBuf::from("/b/blog/index.html")]);
1339    }
1340
1341    #[test]
1342    fn extract_layout_reads_yaml_frontmatter() {
1343        let text = "---\ntitle: foo\nlayout: \"post\"\n---\nbody";
1344        assert_eq!(extract_layout(text.as_bytes()), Some("post".to_string()));
1345    }
1346
1347    #[test]
1348    fn extract_layout_bareword() {
1349        let text = "---\nlayout: post\n---\nbody";
1350        assert_eq!(extract_layout(text.as_bytes()), Some("post".to_string()));
1351    }
1352
1353    #[test]
1354    fn extract_layout_missing_returns_none() {
1355        let text = "---\ntitle: foo\n---\nbody";
1356        assert!(extract_layout(text.as_bytes()).is_none());
1357    }
1358
1359    #[test]
1360    fn extract_layout_no_frontmatter_returns_none() {
1361        let text = "# just a heading";
1362        assert!(extract_layout(text.as_bytes()).is_none());
1363    }
1364
1365    #[test]
1366    fn scan_template_refs_handles_extends_and_partial() {
1367        let body =
1368            "{{#extends \"base\"}}\n{{->header title=\"x\"}}\n{{->footer}}";
1369        let refs = scan_template_refs(body);
1370        assert_eq!(
1371            refs,
1372            vec![
1373                "base".to_string(),
1374                "header".to_string(),
1375                "footer".to_string()
1376            ]
1377        );
1378    }
1379
1380    #[test]
1381    fn scan_template_refs_deduplicates() {
1382        let body = "{{->header}} {{->header}} {{->header title=\"a\"}}";
1383        let refs = scan_template_refs(body);
1384        assert_eq!(refs, vec!["header".to_string()]);
1385    }
1386
1387    #[test]
1388    fn scan_template_refs_ignores_plain_variables() {
1389        let body = "<p>{{title}}</p>{{!raw_html}}";
1390        assert!(
1391            scan_template_refs(body).is_empty(),
1392            "{:?}",
1393            scan_template_refs(body)
1394        );
1395    }
1396
1397    #[test]
1398    fn current_hashes_picks_up_md_and_html() {
1399        let dir = tempdir().unwrap();
1400        let content = dir.path().join("c");
1401        let template = dir.path().join("t");
1402        fs::create_dir_all(&content).unwrap();
1403        fs::create_dir_all(&template).unwrap();
1404        write(&content.join("a.md"), "---\nlayout: page\n---");
1405        write(&template.join("page.html"), "<h1></h1>");
1406
1407        let hashes = current_hashes(&content, &template).unwrap();
1408        assert!(hashes.contains_key(&content.join("a.md")));
1409        assert!(hashes.contains_key(&template.join("page.html")));
1410    }
1411
1412    #[test]
1413    fn record_hash_from_disk_silently_skips_missing() {
1414        let mut graph = DepGraph::new();
1415        graph.record_hash_from_disk(Path::new("/nonexistent/x.md"));
1416        assert!(graph.hashes.is_empty());
1417    }
1418
1419    #[test]
1420    fn diff_is_empty_helper_round_trips() {
1421        let d = Diff::default();
1422        assert!(d.is_empty());
1423    }
1424
1425    #[test]
1426    fn tracked_sources_returns_sorted_unique_outputs() {
1427        let mut graph = DepGraph::new();
1428        graph.add_output(Path::new("b.md"), Path::new("b.html"));
1429        graph.add_output(Path::new("a.md"), Path::new("a.html"));
1430        assert_eq!(
1431            graph.tracked_sources(),
1432            vec![PathBuf::from("a.md"), PathBuf::from("b.md")]
1433        );
1434    }
1435
1436    // ── save error-path closure coverage ────────────────────────────
1437
1438    #[test]
1439    fn save_fails_when_cache_root_is_a_file_not_a_dir() {
1440        // Make `cache_root` point at an existing regular file so
1441        // fs::create_dir_all returns AlreadyExists+NotADirectory and
1442        // the map_err closure constructing SsgError::Io fires.
1443        let dir = tempdir().unwrap();
1444        let blocker = dir.path().join("not-a-dir");
1445        fs::write(&blocker, b"i am a file").unwrap();
1446        let cache_root = blocker.join("sub");
1447        let graph = DepGraph::new();
1448        let err = graph.save(&cache_root).unwrap_err();
1449        let msg = format!("{err}");
1450        assert!(!msg.is_empty(), "should not be empty");
1451    }
1452
1453    #[test]
1454    fn save_writes_then_renames_to_final_path() {
1455        // Exercises the happy-path: success path of all three map_err
1456        // arms (create_dir_all, write, rename). Verifies the final
1457        // depgraph.json exists and the .tmp file is gone.
1458        let dir = tempdir().unwrap();
1459        let cache_root = dir.path().join("cache");
1460        let mut g = DepGraph::new();
1461        g.add_dep(Path::new("a.md"), Path::new("b.html"));
1462        g.add_output(Path::new("a.md"), Path::new("out.html"));
1463        g.record_hash(Path::new("a.md"), b"contents");
1464        g.save(&cache_root).unwrap();
1465
1466        let final_path = cache_root.join(DEP_GRAPH_FILE);
1467        assert!(final_path.exists());
1468        let tmp_path = cache_root.join(format!("{DEP_GRAPH_FILE}.tmp"));
1469        assert!(
1470            !tmp_path.exists(),
1471            ".tmp file should be renamed away after save"
1472        );
1473    }
1474
1475    // ── default_version: hit serde's default-field fallback ─────────
1476
1477    #[test]
1478    fn load_treats_missing_version_field_as_incompatible() {
1479        // Write a graph with no version field. serde's `default =
1480        // default_version` returns 0, which mismatches SCHEMA_VERSION,
1481        // so load() returns an empty graph (the AC6 fallback path).
1482        let dir = tempdir().unwrap();
1483        let cache_root = dir.path();
1484        let path = cache_root.join(DEP_GRAPH_FILE);
1485        fs::write(&path, br#"{"deps":{},"outputs":{},"hashes":{}}"#).unwrap();
1486        let loaded = DepGraph::load(cache_root);
1487        assert_eq!(loaded.page_count(), 0);
1488        assert!(
1489            loaded.tracked_sources().is_empty(),
1490            "{:?}",
1491            loaded.tracked_sources()
1492        );
1493    }
1494
1495    // ── populate error-path closure coverage ────────────────────────
1496
1497    #[test]
1498    fn populate_propagates_unreadable_markdown_via_map_err_closure() {
1499        // Drop a .md file then make it unreadable. The closure that
1500        // wraps fs::read's error into SsgError::Io fires.
1501        let dir = tempdir().unwrap();
1502        let content = dir.path().join("content");
1503        let templates = dir.path().join("templates");
1504        let build = dir.path().join("build");
1505        fs::create_dir_all(&content).unwrap();
1506        fs::create_dir_all(&templates).unwrap();
1507        let md = content.join("page.md");
1508        fs::write(&md, b"---\nlayout: post\n---\nhi").unwrap();
1509
1510        #[cfg(unix)]
1511        {
1512            use std::os::unix::fs::PermissionsExt;
1513            // chmod 000 so fs::read returns PermissionDenied
1514            fs::set_permissions(&md, fs::Permissions::from_mode(0o000))
1515                .unwrap();
1516        }
1517
1518        let mut g = DepGraph::new();
1519        let res = populate(&mut g, &content, &templates, &build);
1520
1521        #[cfg(unix)]
1522        {
1523            use std::os::unix::fs::PermissionsExt;
1524            // Restore perms so tempdir cleanup works.
1525            let _ = fs::set_permissions(&md, fs::Permissions::from_mode(0o644));
1526            // On unix the error path is exercised. Some CI runners
1527            // run as root and bypass perms — don't fail if we did get
1528            // through, but assert: if it errored, the message is non-empty.
1529            assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1530        }
1531        #[cfg(not(unix))]
1532        {
1533            let _ = res;
1534        }
1535    }
1536
1537    #[test]
1538    fn populate_propagates_unreadable_template_via_map_err_closure() {
1539        let dir = tempdir().unwrap();
1540        let content = dir.path().join("content");
1541        let templates = dir.path().join("templates");
1542        let build = dir.path().join("build");
1543        fs::create_dir_all(&content).unwrap();
1544        fs::create_dir_all(&templates).unwrap();
1545        let tpl = templates.join("post.html");
1546        fs::write(&tpl, b"{{#extends \"base\"}}").unwrap();
1547
1548        #[cfg(unix)]
1549        {
1550            use std::os::unix::fs::PermissionsExt;
1551            fs::set_permissions(&tpl, fs::Permissions::from_mode(0o000))
1552                .unwrap();
1553        }
1554
1555        let mut g = DepGraph::new();
1556        let res = populate(&mut g, &content, &templates, &build);
1557
1558        #[cfg(unix)]
1559        {
1560            use std::os::unix::fs::PermissionsExt;
1561            let _ =
1562                fs::set_permissions(&tpl, fs::Permissions::from_mode(0o644));
1563            assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1564        }
1565        #[cfg(not(unix))]
1566        {
1567            let _ = res;
1568        }
1569    }
1570
1571    // ── load — warn-branch format arguments ─────────────────────────
1572
1573    #[test]
1574    fn load_incompatible_schema_warns_and_falls_back() {
1575        // init_logger raises the max level so the `log::warn!` format
1576        // arguments (line 127) execute.
1577        crate::test_support::init_logger();
1578        let dir = tempdir().unwrap();
1579        let stale = serde_json::json!({
1580            "version": 1,
1581            "deps": {},
1582            "outputs": {},
1583            "hashes": {}
1584        });
1585        fs::write(dir.path().join(DEP_GRAPH_FILE), stale.to_string()).unwrap();
1586
1587        let g = DepGraph::load(dir.path());
1588        assert_eq!(g.page_count(), 0);
1589    }
1590
1591    #[test]
1592    fn load_corrupt_json_warns_and_falls_back() {
1593        // Same as above for the corrupt-JSON arm (line 134).
1594        crate::test_support::init_logger();
1595        let dir = tempdir().unwrap();
1596        fs::write(dir.path().join(DEP_GRAPH_FILE), "{ nope").unwrap();
1597
1598        let g = DepGraph::load(dir.path());
1599        assert_eq!(g.page_count(), 0);
1600    }
1601
1602    // ── save — serialization and I/O error paths ────────────────────
1603
1604    #[test]
1605    #[cfg(unix)]
1606    fn save_non_utf8_path_fails_serialization() {
1607        // serde's PathBuf serializer rejects non-UTF-8 paths, driving
1608        // the `to_string` map_err closure (lines 166-169).
1609        use std::ffi::OsStr;
1610        use std::os::unix::ffi::OsStrExt;
1611        let dir = tempdir().unwrap();
1612        let bad = PathBuf::from(OsStr::from_bytes(&[0x66, 0xFF, 0xFE]));
1613        let mut g = DepGraph::new();
1614        g.add_dep(&bad, Path::new("layout.html"));
1615
1616        assert!(g.save(dir.path()).is_err());
1617    }
1618
1619    #[test]
1620    #[cfg(unix)]
1621    fn save_unwritable_cache_root_fails_tmp_write() {
1622        // cache_root exists but is read-only: create_dir_all succeeds
1623        // (already there), the tmp-file write fails (lines 170-173).
1624        use std::os::unix::fs::PermissionsExt;
1625        let dir = tempdir().unwrap();
1626        let root = dir.path().join("cache");
1627        fs::create_dir_all(&root).unwrap();
1628        fs::set_permissions(&root, fs::Permissions::from_mode(0o555)).unwrap();
1629
1630        let res = DepGraph::new().save(&root);
1631
1632        let _ = fs::set_permissions(&root, fs::Permissions::from_mode(0o755));
1633        // Root bypasses permissions on some CI runners, so tolerate Ok.
1634        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1635    }
1636
1637    #[test]
1638    fn save_rename_over_directory_fails() {
1639        // A non-empty directory squatting on the final path makes the
1640        // atomic rename fail (lines 174-177).
1641        let dir = tempdir().unwrap();
1642        let blocker = dir.path().join(DEP_GRAPH_FILE);
1643        fs::create_dir_all(&blocker).unwrap();
1644        fs::write(blocker.join("keep.txt"), "x").unwrap();
1645
1646        assert!(DepGraph::new().save(dir.path()).is_err());
1647    }
1648
1649    // ── record_hash_from_disk — both arms ───────────────────────────
1650
1651    #[test]
1652    fn record_hash_from_disk_reads_existing_and_skips_missing() {
1653        let dir = tempdir().unwrap();
1654        let p = dir.path().join("a.md");
1655        fs::write(&p, "hello").unwrap();
1656
1657        let mut g = DepGraph::new();
1658        g.record_hash_from_disk(&p);
1659        // Missing files are silently ignored (the else arm).
1660        g.record_hash_from_disk(&dir.path().join("missing.md"));
1661
1662        let current = current_hashes(dir.path(), dir.path()).unwrap();
1663        assert!(g.diff(&current).is_empty());
1664    }
1665
1666    // ── invalidated — revisit guard ─────────────────────────────────
1667
1668    #[test]
1669    fn invalidated_deduplicates_repeated_inputs() {
1670        // A duplicated changed path hits the `!seen.insert` continue.
1671        let g = DepGraph::new();
1672        let changed =
1673            vec![PathBuf::from("content/a.md"), PathBuf::from("content/a.md")];
1674        assert_eq!(
1675            g.invalidated(&changed),
1676            vec![PathBuf::from("content/a.md")]
1677        );
1678    }
1679
1680    // ── populate / current_hashes — walk failures ───────────────────
1681
1682    #[test]
1683    #[cfg(unix)]
1684    fn populate_propagates_unreadable_content_dir() {
1685        // The walk itself fails when the content dir can't be listed
1686        // (the `?` on the first walk).
1687        use std::os::unix::fs::PermissionsExt;
1688        let dir = tempdir().unwrap();
1689        let content = dir.path().join("content");
1690        let templates = dir.path().join("templates");
1691        fs::create_dir_all(&content).unwrap();
1692        fs::create_dir_all(&templates).unwrap();
1693        fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
1694            .unwrap();
1695
1696        let mut g = DepGraph::new();
1697        let res = populate(&mut g, &content, &templates, &dir.path().join("b"));
1698
1699        let _ =
1700            fs::set_permissions(&content, fs::Permissions::from_mode(0o755));
1701        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1702    }
1703
1704    #[test]
1705    #[cfg(unix)]
1706    fn populate_propagates_unreadable_template_dir() {
1707        // Content walk succeeds; the template walk fails (the `?` on
1708        // the second walk).
1709        use std::os::unix::fs::PermissionsExt;
1710        let dir = tempdir().unwrap();
1711        let content = dir.path().join("content");
1712        let templates = dir.path().join("templates");
1713        fs::create_dir_all(&content).unwrap();
1714        fs::create_dir_all(&templates).unwrap();
1715        fs::set_permissions(&templates, fs::Permissions::from_mode(0o000))
1716            .unwrap();
1717
1718        let mut g = DepGraph::new();
1719        let res = populate(&mut g, &content, &templates, &dir.path().join("b"));
1720
1721        let _ =
1722            fs::set_permissions(&templates, fs::Permissions::from_mode(0o755));
1723        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1724    }
1725
1726    #[test]
1727    #[cfg(unix)]
1728    fn current_hashes_propagates_walk_failures_from_both_dirs() {
1729        use std::os::unix::fs::PermissionsExt;
1730        let dir = tempdir().unwrap();
1731        let content = dir.path().join("content");
1732        let templates = dir.path().join("templates");
1733        fs::create_dir_all(&content).unwrap();
1734        fs::create_dir_all(&templates).unwrap();
1735
1736        // Unreadable content dir → first `?`.
1737        fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
1738            .unwrap();
1739        let res_content = current_hashes(&content, &templates);
1740        let _ =
1741            fs::set_permissions(&content, fs::Permissions::from_mode(0o755));
1742
1743        // Unreadable template dir → second `?`.
1744        fs::set_permissions(&templates, fs::Permissions::from_mode(0o000))
1745            .unwrap();
1746        let res_templates = current_hashes(&content, &templates);
1747        let _ =
1748            fs::set_permissions(&templates, fs::Permissions::from_mode(0o755));
1749
1750        assert!(res_content.err().is_none_or(|e| !format!("{e}").is_empty()));
1751        assert!(res_templates
1752            .err()
1753            .is_none_or(|e| !format!("{e}").is_empty()));
1754    }
1755
1756    #[test]
1757    #[cfg(unix)]
1758    fn current_hashes_skips_unreadable_sources() {
1759        // A dangling .md symlink is returned by the walk but fails
1760        // fs::read, taking the silent-skip arm inside `push`.
1761        let dir = tempdir().unwrap();
1762        let content = dir.path().join("content");
1763        let templates = dir.path().join("templates");
1764        fs::create_dir_all(&content).unwrap();
1765        fs::create_dir_all(&templates).unwrap();
1766        fs::write(content.join("real.md"), "hi").unwrap();
1767        std::os::unix::fs::symlink(
1768            content.join("ghost-target.md"),
1769            content.join("ghost.md"),
1770        )
1771        .unwrap();
1772
1773        let map = current_hashes(&content, &templates).unwrap();
1774        assert_eq!(map.len(), 1, "only the readable file is hashed");
1775    }
1776
1777    // ── populate — unresolved layout edge ───────────────────────────
1778
1779    #[test]
1780    fn populate_skips_edge_when_layout_cannot_be_resolved() {
1781        // `layout: ghost` with no matching template file: the
1782        // resolve_template else-arm leaves the page without a
1783        // template edge.
1784        let dir = tempdir().unwrap();
1785        let content = dir.path().join("content");
1786        let templates = dir.path().join("templates");
1787        let build = dir.path().join("build");
1788        fs::create_dir_all(&content).unwrap();
1789        fs::create_dir_all(&templates).unwrap();
1790        fs::write(content.join("page.md"), "---\nlayout: ghost\n---\nbody")
1791            .unwrap();
1792
1793        let mut g = DepGraph::new();
1794        populate(&mut g, &content, &templates, &build).unwrap();
1795        // Only the self-edge is recorded.
1796        let deps = g
1797            .deps_for(&content.join("page.md"))
1798            .expect("page must be tracked");
1799        assert_eq!(deps.len(), 1);
1800    }
1801
1802    // ── extract_layout — rejection paths ────────────────────────────
1803
1804    #[test]
1805    fn extract_layout_rejects_malformed_frontmatter() {
1806        // Non-UTF-8 bytes.
1807        assert_eq!(extract_layout(&[0xFF, 0xFE, 0x00]), None);
1808        // Opening fence with no closing fence.
1809        assert_eq!(extract_layout(b"---\nlayout: x"), None);
1810        // Empty layout value: `split_whitespace().next()` is None.
1811        assert_eq!(extract_layout(b"---\nlayout:\n---\nbody"), None);
1812    }
1813
1814    // ── output_paths_for / resolve_template — fallback arms ─────────
1815
1816    #[test]
1817    fn output_paths_for_foreign_path_returns_empty() {
1818        // A file outside content_dir fails strip_prefix.
1819        let out = output_paths_for(
1820            Path::new("/elsewhere/post.md"),
1821            Path::new("/content"),
1822            Path::new("/build"),
1823        );
1824        assert!(out.is_empty(), "{:?}", out);
1825    }
1826
1827    #[test]
1828    fn resolve_template_prefers_locale_sibling() {
1829        let dir = tempdir().unwrap();
1830        let content = dir.path().join("content");
1831        let templates = dir.path().join("templates");
1832        fs::create_dir_all(content.join("fr")).unwrap();
1833        fs::create_dir_all(templates.join("fr")).unwrap();
1834        fs::write(templates.join("fr/post.html"), "x").unwrap();
1835        fs::write(templates.join("post.html"), "x").unwrap();
1836
1837        let got = resolve_template(
1838            &templates,
1839            &content.join("fr/a.md"),
1840            &content,
1841            "post",
1842        );
1843        assert_eq!(got, Some(templates.join("fr/post.html")));
1844    }
1845
1846    #[test]
1847    fn resolve_template_falls_back_when_locale_candidate_is_missing() {
1848        // `rel.components().next()` is `Some(first)` (the content path
1849        // has a leading directory component) but the locale-sibling
1850        // candidate doesn't exist on disk — the inner
1851        // `if candidate.exists()` false arm, distinct from
1852        // `resolve_template_prefers_locale_sibling` (which always hits
1853        // the true arm) and from the empty/foreign-path test below
1854        // (which never enters the `Some(first)` branch at all).
1855        let dir = tempdir().unwrap();
1856        let content = dir.path().join("content");
1857        let templates = dir.path().join("templates");
1858        fs::create_dir_all(content.join("fr")).unwrap();
1859        fs::create_dir_all(&templates).unwrap();
1860        // No `templates/fr/post.html` — only the plain fallback exists.
1861        fs::write(templates.join("post.html"), "x").unwrap();
1862
1863        let got = resolve_template(
1864            &templates,
1865            &content.join("fr/a.md"),
1866            &content,
1867            "post",
1868        );
1869        assert_eq!(got, Some(templates.join("post.html")));
1870    }
1871
1872    #[test]
1873    fn resolve_template_falls_back_for_empty_and_foreign_paths() {
1874        let dir = tempdir().unwrap();
1875        let content = dir.path().join("content");
1876        let templates = dir.path().join("templates");
1877        fs::create_dir_all(&content).unwrap();
1878        fs::create_dir_all(&templates).unwrap();
1879        fs::write(templates.join("page.html"), "x").unwrap();
1880
1881        // md == content_dir: the relative path has no components.
1882        assert_eq!(
1883            resolve_template(&templates, &content, &content, "page"),
1884            Some(templates.join("page.html"))
1885        );
1886        // md outside content_dir: strip_prefix fails.
1887        assert_eq!(
1888            resolve_template(
1889                &templates,
1890                Path::new("/elsewhere/a.md"),
1891                &content,
1892                "page"
1893            ),
1894            Some(templates.join("page.html"))
1895        );
1896        // Nothing on disk at all: fallback is None.
1897        assert_eq!(
1898            resolve_template(
1899                &templates,
1900                &content.join("a.md"),
1901                &content,
1902                "missing"
1903            ),
1904            None
1905        );
1906    }
1907
1908    // ── scan_template_refs / parse_name — edge shapes ───────────────
1909
1910    #[test]
1911    fn scan_template_refs_handles_unclosed_and_plain_refs() {
1912        // Unclosed `{{` → break arm.
1913        assert!(
1914            scan_template_refs("{{#extends \"base\"").is_empty(),
1915            "{:?}",
1916            scan_template_refs("{{#extends \"base\"")
1917        );
1918        // Plain variable refs produce no names; empty extends name is
1919        // filtered; duplicates are deduped.
1920        let refs =
1921            scan_template_refs("{{ title }}{{#extends \"\"}}{{->p}}{{->p}}");
1922        assert_eq!(refs, vec!["p".to_string()]);
1923    }
1924}