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!(graph.tracked_sources().is_empty());
1088    }
1089
1090    #[test]
1091    fn sha256_hex_is_deterministic_64_chars() {
1092        let h = DepGraph::sha256_hex(b"hello");
1093        assert_eq!(h.len(), 64);
1094        assert_eq!(h, DepGraph::sha256_hex(b"hello"));
1095    }
1096
1097    #[test]
1098    fn sha256_hex_distinguishes_inputs() {
1099        assert_ne!(DepGraph::sha256_hex(b"a"), DepGraph::sha256_hex(b"b"));
1100    }
1101
1102    /// A change to a data file must invalidate every page.
1103    ///
1104    /// Data is read globally — a template can address `data.foo` from
1105    /// anywhere — so there is no page that provably does not use it.
1106    /// Before this edge existed, editing `data/site.toml` left every
1107    /// page looking clean and an incremental build would have skipped
1108    /// all of them.
1109    #[test]
1110    fn a_data_file_change_invalidates_every_page() {
1111        let dir = tempdir().unwrap();
1112        let content = dir.path().join("content");
1113        let template = dir.path().join("templates");
1114        let build = dir.path().join("public");
1115        let data = dir.path().join("data");
1116        for d in [&content, &template, &data] {
1117            fs::create_dir_all(d).unwrap();
1118        }
1119        write(
1120            &content.join("index.md"),
1121            "---\nlayout: \"page\"\n---\nbody",
1122        );
1123        write(
1124            &content.join("about.md"),
1125            "---\nlayout: \"page\"\n---\nbody",
1126        );
1127        write(
1128            &template.join("page.html"),
1129            "<html>{{ data.site.name }}</html>",
1130        );
1131        write(&data.join("site.toml"), "name = \"Example\"\n");
1132
1133        let mut graph = DepGraph::new();
1134        populate(&mut graph, &content, &template, &build).unwrap();
1135
1136        let site_data = data.join("site.toml");
1137        let hit = graph.invalidated(std::slice::from_ref(&site_data));
1138        for page in [content.join("index.md"), content.join("about.md")] {
1139            assert!(
1140                hit.contains(&page),
1141                "{} not invalidated by a data-file change",
1142                page.display()
1143            );
1144        }
1145    }
1146
1147    /// The topic curation file lives in `_data/`, not `data/`.
1148    ///
1149    /// It reshapes every topic page, so it is a dependency by the same
1150    /// argument — and it would have been missed by a helper that only
1151    /// knew the one convention.
1152    #[test]
1153    fn the_underscore_data_convention_is_tracked_too() {
1154        let dir = tempdir().unwrap();
1155        let content = dir.path().join("content");
1156        let template = dir.path().join("templates");
1157        let build = dir.path().join("public");
1158        let data = dir.path().join("_data");
1159        for d in [&content, &template, &data] {
1160            fs::create_dir_all(d).unwrap();
1161        }
1162        write(
1163            &content.join("index.md"),
1164            "---\nlayout: \"page\"\n---\nbody",
1165        );
1166        write(&template.join("page.html"), "<html></html>");
1167        write(&data.join("topics.toml"), "[rust]\ntitle = \"Rust\"\n");
1168
1169        let mut graph = DepGraph::new();
1170        populate(&mut graph, &content, &template, &build).unwrap();
1171
1172        let topics = data.join("topics.toml");
1173        assert!(
1174            graph
1175                .invalidated(std::slice::from_ref(&topics))
1176                .contains(&content.join("index.md")),
1177            "_data/topics.toml is not tracked"
1178        );
1179    }
1180
1181    /// A stray file in `data/` that the loader cannot parse is not a
1182    /// dependency, and must not become an edge.
1183    #[test]
1184    fn non_data_extensions_in_the_data_dir_are_ignored() {
1185        let dir = tempdir().unwrap();
1186        let content = dir.path().join("content");
1187        let template = dir.path().join("templates");
1188        let build = dir.path().join("public");
1189        let data = dir.path().join("data");
1190        for d in [&content, &template, &data] {
1191            fs::create_dir_all(d).unwrap();
1192        }
1193        write(
1194            &content.join("index.md"),
1195            "---\nlayout: \"page\"\n---\nbody",
1196        );
1197        write(&template.join("page.html"), "<html></html>");
1198        write(&data.join("notes.md"), "not a data file");
1199        write(&data.join("real.json"), "{}");
1200
1201        let mut graph = DepGraph::new();
1202        populate(&mut graph, &content, &template, &build).unwrap();
1203
1204        let index = content.join("index.md");
1205        let deps = graph.deps_for(&index).unwrap();
1206        assert!(deps.contains(&data.join("real.json")), "json not tracked");
1207        assert!(
1208            !deps.contains(&data.join("notes.md")),
1209            "a .md in data/ is not a data file"
1210        );
1211    }
1212
1213    #[test]
1214    fn populate_walks_real_directories_and_records_edges() {
1215        let dir = tempdir().unwrap();
1216        let content = dir.path().join("content");
1217        let template = dir.path().join("templates");
1218        let build = dir.path().join("public");
1219        fs::create_dir_all(&content).unwrap();
1220        fs::create_dir_all(&template).unwrap();
1221
1222        write(
1223            &content.join("index.md"),
1224            "---\nlayout: \"page\"\n---\nbody",
1225        );
1226        write(
1227            &content.join("about.md"),
1228            "---\nlayout: \"page\"\n---\nbody",
1229        );
1230        write(&template.join("page.html"), "<html>{{title}}</html>");
1231
1232        let mut graph = DepGraph::new();
1233        populate(&mut graph, &content, &template, &build).unwrap();
1234
1235        // Edges recorded
1236        let index = content.join("index.md");
1237        let about = content.join("about.md");
1238        let page_tpl = template.join("page.html");
1239        assert!(graph.deps_for(&index).unwrap().contains(&page_tpl));
1240        assert!(graph.deps_for(&about).unwrap().contains(&page_tpl));
1241
1242        // Outputs recorded correctly
1243        let outs_index = graph.outputs_for(&index).unwrap();
1244        assert!(outs_index.contains(&build.join("index.html")));
1245        let outs_about = graph.outputs_for(&about).unwrap();
1246        assert!(outs_about.contains(&build.join("about").join("index.html")));
1247
1248        // Hashes recorded
1249        assert!(!graph.hashes.is_empty());
1250    }
1251
1252    #[test]
1253    fn populate_records_template_to_template_edges() {
1254        let dir = tempdir().unwrap();
1255        let content = dir.path().join("content");
1256        let template = dir.path().join("templates");
1257        let build = dir.path().join("public");
1258        fs::create_dir_all(&content).unwrap();
1259        fs::create_dir_all(&template).unwrap();
1260
1261        write(&content.join("index.md"), "---\nlayout: \"page\"\n---\n");
1262        write(
1263            &template.join("page.html"),
1264            "{{#extends \"base\"}}\n<p>x</p>",
1265        );
1266        write(&template.join("base.html"), "<html>{{body}}</html>");
1267
1268        let mut graph = DepGraph::new();
1269        populate(&mut graph, &content, &template, &build).unwrap();
1270
1271        let page_tpl = template.join("page.html");
1272        let base_tpl = template.join("base.html");
1273        assert!(
1274            graph.deps_for(&page_tpl).unwrap().contains(&base_tpl),
1275            "template→template edge must be recorded"
1276        );
1277
1278        // Transitive: changing base.html must invalidate the content page.
1279        let invalidated = graph.invalidated(&[base_tpl]);
1280        assert!(invalidated.contains(&content.join("index.md")));
1281    }
1282
1283    #[test]
1284    fn populate_records_partial_references() {
1285        let dir = tempdir().unwrap();
1286        let content = dir.path().join("content");
1287        let template = dir.path().join("templates");
1288        let build = dir.path().join("public");
1289        fs::create_dir_all(&content).unwrap();
1290        fs::create_dir_all(&template).unwrap();
1291
1292        write(&content.join("index.md"), "---\nlayout: \"page\"\n---\n");
1293        write(
1294            &template.join("page.html"),
1295            "<div>{{->header title=\"x\"}}</div>",
1296        );
1297        write(&template.join("header.html"), "<h1>{{title}}</h1>");
1298
1299        let mut graph = DepGraph::new();
1300        populate(&mut graph, &content, &template, &build).unwrap();
1301
1302        let page_tpl = template.join("page.html");
1303        let header_tpl = template.join("header.html");
1304        assert!(graph.deps_for(&page_tpl).unwrap().contains(&header_tpl));
1305    }
1306
1307    #[test]
1308    fn output_paths_for_root_index_emits_root_index_html() {
1309        let outs = output_paths_for(
1310            Path::new("/c/index.md"),
1311            Path::new("/c"),
1312            Path::new("/b"),
1313        );
1314        assert_eq!(outs, vec![PathBuf::from("/b/index.html")]);
1315    }
1316
1317    #[test]
1318    fn output_paths_for_nested_post_emits_subdir_index() {
1319        let outs = output_paths_for(
1320            Path::new("/c/blog/foo.md"),
1321            Path::new("/c"),
1322            Path::new("/b"),
1323        );
1324        assert_eq!(outs, vec![PathBuf::from("/b/blog/foo/index.html")]);
1325    }
1326
1327    #[test]
1328    fn output_paths_for_nested_index_emits_subdir_index_html() {
1329        let outs = output_paths_for(
1330            Path::new("/c/blog/index.md"),
1331            Path::new("/c"),
1332            Path::new("/b"),
1333        );
1334        assert_eq!(outs, vec![PathBuf::from("/b/blog/index.html")]);
1335    }
1336
1337    #[test]
1338    fn extract_layout_reads_yaml_frontmatter() {
1339        let text = "---\ntitle: foo\nlayout: \"post\"\n---\nbody";
1340        assert_eq!(extract_layout(text.as_bytes()), Some("post".to_string()));
1341    }
1342
1343    #[test]
1344    fn extract_layout_bareword() {
1345        let text = "---\nlayout: post\n---\nbody";
1346        assert_eq!(extract_layout(text.as_bytes()), Some("post".to_string()));
1347    }
1348
1349    #[test]
1350    fn extract_layout_missing_returns_none() {
1351        let text = "---\ntitle: foo\n---\nbody";
1352        assert!(extract_layout(text.as_bytes()).is_none());
1353    }
1354
1355    #[test]
1356    fn extract_layout_no_frontmatter_returns_none() {
1357        let text = "# just a heading";
1358        assert!(extract_layout(text.as_bytes()).is_none());
1359    }
1360
1361    #[test]
1362    fn scan_template_refs_handles_extends_and_partial() {
1363        let body =
1364            "{{#extends \"base\"}}\n{{->header title=\"x\"}}\n{{->footer}}";
1365        let refs = scan_template_refs(body);
1366        assert_eq!(
1367            refs,
1368            vec![
1369                "base".to_string(),
1370                "header".to_string(),
1371                "footer".to_string()
1372            ]
1373        );
1374    }
1375
1376    #[test]
1377    fn scan_template_refs_deduplicates() {
1378        let body = "{{->header}} {{->header}} {{->header title=\"a\"}}";
1379        let refs = scan_template_refs(body);
1380        assert_eq!(refs, vec!["header".to_string()]);
1381    }
1382
1383    #[test]
1384    fn scan_template_refs_ignores_plain_variables() {
1385        let body = "<p>{{title}}</p>{{!raw_html}}";
1386        assert!(scan_template_refs(body).is_empty());
1387    }
1388
1389    #[test]
1390    fn current_hashes_picks_up_md_and_html() {
1391        let dir = tempdir().unwrap();
1392        let content = dir.path().join("c");
1393        let template = dir.path().join("t");
1394        fs::create_dir_all(&content).unwrap();
1395        fs::create_dir_all(&template).unwrap();
1396        write(&content.join("a.md"), "---\nlayout: page\n---");
1397        write(&template.join("page.html"), "<h1></h1>");
1398
1399        let hashes = current_hashes(&content, &template).unwrap();
1400        assert!(hashes.contains_key(&content.join("a.md")));
1401        assert!(hashes.contains_key(&template.join("page.html")));
1402    }
1403
1404    #[test]
1405    fn record_hash_from_disk_silently_skips_missing() {
1406        let mut graph = DepGraph::new();
1407        graph.record_hash_from_disk(Path::new("/nonexistent/x.md"));
1408        assert!(graph.hashes.is_empty());
1409    }
1410
1411    #[test]
1412    fn diff_is_empty_helper_round_trips() {
1413        let d = Diff::default();
1414        assert!(d.is_empty());
1415    }
1416
1417    #[test]
1418    fn tracked_sources_returns_sorted_unique_outputs() {
1419        let mut graph = DepGraph::new();
1420        graph.add_output(Path::new("b.md"), Path::new("b.html"));
1421        graph.add_output(Path::new("a.md"), Path::new("a.html"));
1422        assert_eq!(
1423            graph.tracked_sources(),
1424            vec![PathBuf::from("a.md"), PathBuf::from("b.md")]
1425        );
1426    }
1427
1428    // ── save error-path closure coverage ────────────────────────────
1429
1430    #[test]
1431    fn save_fails_when_cache_root_is_a_file_not_a_dir() {
1432        // Make `cache_root` point at an existing regular file so
1433        // fs::create_dir_all returns AlreadyExists+NotADirectory and
1434        // the map_err closure constructing SsgError::Io fires.
1435        let dir = tempdir().unwrap();
1436        let blocker = dir.path().join("not-a-dir");
1437        fs::write(&blocker, b"i am a file").unwrap();
1438        let cache_root = blocker.join("sub");
1439        let graph = DepGraph::new();
1440        let err = graph.save(&cache_root).unwrap_err();
1441        let msg = format!("{err}");
1442        assert!(!msg.is_empty());
1443    }
1444
1445    #[test]
1446    fn save_writes_then_renames_to_final_path() {
1447        // Exercises the happy-path: success path of all three map_err
1448        // arms (create_dir_all, write, rename). Verifies the final
1449        // depgraph.json exists and the .tmp file is gone.
1450        let dir = tempdir().unwrap();
1451        let cache_root = dir.path().join("cache");
1452        let mut g = DepGraph::new();
1453        g.add_dep(Path::new("a.md"), Path::new("b.html"));
1454        g.add_output(Path::new("a.md"), Path::new("out.html"));
1455        g.record_hash(Path::new("a.md"), b"contents");
1456        g.save(&cache_root).unwrap();
1457
1458        let final_path = cache_root.join(DEP_GRAPH_FILE);
1459        assert!(final_path.exists());
1460        let tmp_path = cache_root.join(format!("{DEP_GRAPH_FILE}.tmp"));
1461        assert!(
1462            !tmp_path.exists(),
1463            ".tmp file should be renamed away after save"
1464        );
1465    }
1466
1467    // ── default_version: hit serde's default-field fallback ─────────
1468
1469    #[test]
1470    fn load_treats_missing_version_field_as_incompatible() {
1471        // Write a graph with no version field. serde's `default =
1472        // default_version` returns 0, which mismatches SCHEMA_VERSION,
1473        // so load() returns an empty graph (the AC6 fallback path).
1474        let dir = tempdir().unwrap();
1475        let cache_root = dir.path();
1476        let path = cache_root.join(DEP_GRAPH_FILE);
1477        fs::write(&path, br#"{"deps":{},"outputs":{},"hashes":{}}"#).unwrap();
1478        let loaded = DepGraph::load(cache_root);
1479        assert_eq!(loaded.page_count(), 0);
1480        assert!(loaded.tracked_sources().is_empty());
1481    }
1482
1483    // ── populate error-path closure coverage ────────────────────────
1484
1485    #[test]
1486    fn populate_propagates_unreadable_markdown_via_map_err_closure() {
1487        // Drop a .md file then make it unreadable. The closure that
1488        // wraps fs::read's error into SsgError::Io fires.
1489        let dir = tempdir().unwrap();
1490        let content = dir.path().join("content");
1491        let templates = dir.path().join("templates");
1492        let build = dir.path().join("build");
1493        fs::create_dir_all(&content).unwrap();
1494        fs::create_dir_all(&templates).unwrap();
1495        let md = content.join("page.md");
1496        fs::write(&md, b"---\nlayout: post\n---\nhi").unwrap();
1497
1498        #[cfg(unix)]
1499        {
1500            use std::os::unix::fs::PermissionsExt;
1501            // chmod 000 so fs::read returns PermissionDenied
1502            fs::set_permissions(&md, fs::Permissions::from_mode(0o000))
1503                .unwrap();
1504        }
1505
1506        let mut g = DepGraph::new();
1507        let res = populate(&mut g, &content, &templates, &build);
1508
1509        #[cfg(unix)]
1510        {
1511            use std::os::unix::fs::PermissionsExt;
1512            // Restore perms so tempdir cleanup works.
1513            let _ = fs::set_permissions(&md, fs::Permissions::from_mode(0o644));
1514            // On unix the error path is exercised. Some CI runners
1515            // run as root and bypass perms — don't fail if we did get
1516            // through, but assert: if it errored, the message is non-empty.
1517            assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1518        }
1519        #[cfg(not(unix))]
1520        {
1521            let _ = res;
1522        }
1523    }
1524
1525    #[test]
1526    fn populate_propagates_unreadable_template_via_map_err_closure() {
1527        let dir = tempdir().unwrap();
1528        let content = dir.path().join("content");
1529        let templates = dir.path().join("templates");
1530        let build = dir.path().join("build");
1531        fs::create_dir_all(&content).unwrap();
1532        fs::create_dir_all(&templates).unwrap();
1533        let tpl = templates.join("post.html");
1534        fs::write(&tpl, b"{{#extends \"base\"}}").unwrap();
1535
1536        #[cfg(unix)]
1537        {
1538            use std::os::unix::fs::PermissionsExt;
1539            fs::set_permissions(&tpl, fs::Permissions::from_mode(0o000))
1540                .unwrap();
1541        }
1542
1543        let mut g = DepGraph::new();
1544        let res = populate(&mut g, &content, &templates, &build);
1545
1546        #[cfg(unix)]
1547        {
1548            use std::os::unix::fs::PermissionsExt;
1549            let _ =
1550                fs::set_permissions(&tpl, fs::Permissions::from_mode(0o644));
1551            assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1552        }
1553        #[cfg(not(unix))]
1554        {
1555            let _ = res;
1556        }
1557    }
1558
1559    // ── load — warn-branch format arguments ─────────────────────────
1560
1561    #[test]
1562    fn load_incompatible_schema_warns_and_falls_back() {
1563        // init_logger raises the max level so the `log::warn!` format
1564        // arguments (line 127) execute.
1565        crate::test_support::init_logger();
1566        let dir = tempdir().unwrap();
1567        let stale = serde_json::json!({
1568            "version": 1,
1569            "deps": {},
1570            "outputs": {},
1571            "hashes": {}
1572        });
1573        fs::write(dir.path().join(DEP_GRAPH_FILE), stale.to_string()).unwrap();
1574
1575        let g = DepGraph::load(dir.path());
1576        assert_eq!(g.page_count(), 0);
1577    }
1578
1579    #[test]
1580    fn load_corrupt_json_warns_and_falls_back() {
1581        // Same as above for the corrupt-JSON arm (line 134).
1582        crate::test_support::init_logger();
1583        let dir = tempdir().unwrap();
1584        fs::write(dir.path().join(DEP_GRAPH_FILE), "{ nope").unwrap();
1585
1586        let g = DepGraph::load(dir.path());
1587        assert_eq!(g.page_count(), 0);
1588    }
1589
1590    // ── save — serialization and I/O error paths ────────────────────
1591
1592    #[test]
1593    #[cfg(unix)]
1594    fn save_non_utf8_path_fails_serialization() {
1595        // serde's PathBuf serializer rejects non-UTF-8 paths, driving
1596        // the `to_string` map_err closure (lines 166-169).
1597        use std::ffi::OsStr;
1598        use std::os::unix::ffi::OsStrExt;
1599        let dir = tempdir().unwrap();
1600        let bad = PathBuf::from(OsStr::from_bytes(&[0x66, 0xFF, 0xFE]));
1601        let mut g = DepGraph::new();
1602        g.add_dep(&bad, Path::new("layout.html"));
1603
1604        assert!(g.save(dir.path()).is_err());
1605    }
1606
1607    #[test]
1608    #[cfg(unix)]
1609    fn save_unwritable_cache_root_fails_tmp_write() {
1610        // cache_root exists but is read-only: create_dir_all succeeds
1611        // (already there), the tmp-file write fails (lines 170-173).
1612        use std::os::unix::fs::PermissionsExt;
1613        let dir = tempdir().unwrap();
1614        let root = dir.path().join("cache");
1615        fs::create_dir_all(&root).unwrap();
1616        fs::set_permissions(&root, fs::Permissions::from_mode(0o555)).unwrap();
1617
1618        let res = DepGraph::new().save(&root);
1619
1620        let _ = fs::set_permissions(&root, fs::Permissions::from_mode(0o755));
1621        // Root bypasses permissions on some CI runners, so tolerate Ok.
1622        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1623    }
1624
1625    #[test]
1626    fn save_rename_over_directory_fails() {
1627        // A non-empty directory squatting on the final path makes the
1628        // atomic rename fail (lines 174-177).
1629        let dir = tempdir().unwrap();
1630        let blocker = dir.path().join(DEP_GRAPH_FILE);
1631        fs::create_dir_all(&blocker).unwrap();
1632        fs::write(blocker.join("keep.txt"), "x").unwrap();
1633
1634        assert!(DepGraph::new().save(dir.path()).is_err());
1635    }
1636
1637    // ── record_hash_from_disk — both arms ───────────────────────────
1638
1639    #[test]
1640    fn record_hash_from_disk_reads_existing_and_skips_missing() {
1641        let dir = tempdir().unwrap();
1642        let p = dir.path().join("a.md");
1643        fs::write(&p, "hello").unwrap();
1644
1645        let mut g = DepGraph::new();
1646        g.record_hash_from_disk(&p);
1647        // Missing files are silently ignored (the else arm).
1648        g.record_hash_from_disk(&dir.path().join("missing.md"));
1649
1650        let current = current_hashes(dir.path(), dir.path()).unwrap();
1651        assert!(g.diff(&current).is_empty());
1652    }
1653
1654    // ── invalidated — revisit guard ─────────────────────────────────
1655
1656    #[test]
1657    fn invalidated_deduplicates_repeated_inputs() {
1658        // A duplicated changed path hits the `!seen.insert` continue.
1659        let g = DepGraph::new();
1660        let changed =
1661            vec![PathBuf::from("content/a.md"), PathBuf::from("content/a.md")];
1662        assert_eq!(
1663            g.invalidated(&changed),
1664            vec![PathBuf::from("content/a.md")]
1665        );
1666    }
1667
1668    // ── populate / current_hashes — walk failures ───────────────────
1669
1670    #[test]
1671    #[cfg(unix)]
1672    fn populate_propagates_unreadable_content_dir() {
1673        // The walk itself fails when the content dir can't be listed
1674        // (the `?` on the first walk).
1675        use std::os::unix::fs::PermissionsExt;
1676        let dir = tempdir().unwrap();
1677        let content = dir.path().join("content");
1678        let templates = dir.path().join("templates");
1679        fs::create_dir_all(&content).unwrap();
1680        fs::create_dir_all(&templates).unwrap();
1681        fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
1682            .unwrap();
1683
1684        let mut g = DepGraph::new();
1685        let res = populate(&mut g, &content, &templates, &dir.path().join("b"));
1686
1687        let _ =
1688            fs::set_permissions(&content, fs::Permissions::from_mode(0o755));
1689        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1690    }
1691
1692    #[test]
1693    #[cfg(unix)]
1694    fn populate_propagates_unreadable_template_dir() {
1695        // Content walk succeeds; the template walk fails (the `?` on
1696        // the second walk).
1697        use std::os::unix::fs::PermissionsExt;
1698        let dir = tempdir().unwrap();
1699        let content = dir.path().join("content");
1700        let templates = dir.path().join("templates");
1701        fs::create_dir_all(&content).unwrap();
1702        fs::create_dir_all(&templates).unwrap();
1703        fs::set_permissions(&templates, fs::Permissions::from_mode(0o000))
1704            .unwrap();
1705
1706        let mut g = DepGraph::new();
1707        let res = populate(&mut g, &content, &templates, &dir.path().join("b"));
1708
1709        let _ =
1710            fs::set_permissions(&templates, fs::Permissions::from_mode(0o755));
1711        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1712    }
1713
1714    #[test]
1715    #[cfg(unix)]
1716    fn current_hashes_propagates_walk_failures_from_both_dirs() {
1717        use std::os::unix::fs::PermissionsExt;
1718        let dir = tempdir().unwrap();
1719        let content = dir.path().join("content");
1720        let templates = dir.path().join("templates");
1721        fs::create_dir_all(&content).unwrap();
1722        fs::create_dir_all(&templates).unwrap();
1723
1724        // Unreadable content dir → first `?`.
1725        fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
1726            .unwrap();
1727        let res_content = current_hashes(&content, &templates);
1728        let _ =
1729            fs::set_permissions(&content, fs::Permissions::from_mode(0o755));
1730
1731        // Unreadable template dir → second `?`.
1732        fs::set_permissions(&templates, fs::Permissions::from_mode(0o000))
1733            .unwrap();
1734        let res_templates = current_hashes(&content, &templates);
1735        let _ =
1736            fs::set_permissions(&templates, fs::Permissions::from_mode(0o755));
1737
1738        assert!(res_content.err().is_none_or(|e| !format!("{e}").is_empty()));
1739        assert!(res_templates
1740            .err()
1741            .is_none_or(|e| !format!("{e}").is_empty()));
1742    }
1743
1744    #[test]
1745    #[cfg(unix)]
1746    fn current_hashes_skips_unreadable_sources() {
1747        // A dangling .md symlink is returned by the walk but fails
1748        // fs::read, taking the silent-skip arm inside `push`.
1749        let dir = tempdir().unwrap();
1750        let content = dir.path().join("content");
1751        let templates = dir.path().join("templates");
1752        fs::create_dir_all(&content).unwrap();
1753        fs::create_dir_all(&templates).unwrap();
1754        fs::write(content.join("real.md"), "hi").unwrap();
1755        std::os::unix::fs::symlink(
1756            content.join("ghost-target.md"),
1757            content.join("ghost.md"),
1758        )
1759        .unwrap();
1760
1761        let map = current_hashes(&content, &templates).unwrap();
1762        assert_eq!(map.len(), 1, "only the readable file is hashed");
1763    }
1764
1765    // ── populate — unresolved layout edge ───────────────────────────
1766
1767    #[test]
1768    fn populate_skips_edge_when_layout_cannot_be_resolved() {
1769        // `layout: ghost` with no matching template file: the
1770        // resolve_template else-arm leaves the page without a
1771        // template edge.
1772        let dir = tempdir().unwrap();
1773        let content = dir.path().join("content");
1774        let templates = dir.path().join("templates");
1775        let build = dir.path().join("build");
1776        fs::create_dir_all(&content).unwrap();
1777        fs::create_dir_all(&templates).unwrap();
1778        fs::write(content.join("page.md"), "---\nlayout: ghost\n---\nbody")
1779            .unwrap();
1780
1781        let mut g = DepGraph::new();
1782        populate(&mut g, &content, &templates, &build).unwrap();
1783        // Only the self-edge is recorded.
1784        let deps = g
1785            .deps_for(&content.join("page.md"))
1786            .expect("page must be tracked");
1787        assert_eq!(deps.len(), 1);
1788    }
1789
1790    // ── extract_layout — rejection paths ────────────────────────────
1791
1792    #[test]
1793    fn extract_layout_rejects_malformed_frontmatter() {
1794        // Non-UTF-8 bytes.
1795        assert_eq!(extract_layout(&[0xFF, 0xFE, 0x00]), None);
1796        // Opening fence with no closing fence.
1797        assert_eq!(extract_layout(b"---\nlayout: x"), None);
1798        // Empty layout value: `split_whitespace().next()` is None.
1799        assert_eq!(extract_layout(b"---\nlayout:\n---\nbody"), None);
1800    }
1801
1802    // ── output_paths_for / resolve_template — fallback arms ─────────
1803
1804    #[test]
1805    fn output_paths_for_foreign_path_returns_empty() {
1806        // A file outside content_dir fails strip_prefix.
1807        let out = output_paths_for(
1808            Path::new("/elsewhere/post.md"),
1809            Path::new("/content"),
1810            Path::new("/build"),
1811        );
1812        assert!(out.is_empty());
1813    }
1814
1815    #[test]
1816    fn resolve_template_prefers_locale_sibling() {
1817        let dir = tempdir().unwrap();
1818        let content = dir.path().join("content");
1819        let templates = dir.path().join("templates");
1820        fs::create_dir_all(content.join("fr")).unwrap();
1821        fs::create_dir_all(templates.join("fr")).unwrap();
1822        fs::write(templates.join("fr/post.html"), "x").unwrap();
1823        fs::write(templates.join("post.html"), "x").unwrap();
1824
1825        let got = resolve_template(
1826            &templates,
1827            &content.join("fr/a.md"),
1828            &content,
1829            "post",
1830        );
1831        assert_eq!(got, Some(templates.join("fr/post.html")));
1832    }
1833
1834    #[test]
1835    fn resolve_template_falls_back_when_locale_candidate_is_missing() {
1836        // `rel.components().next()` is `Some(first)` (the content path
1837        // has a leading directory component) but the locale-sibling
1838        // candidate doesn't exist on disk — the inner
1839        // `if candidate.exists()` false arm, distinct from
1840        // `resolve_template_prefers_locale_sibling` (which always hits
1841        // the true arm) and from the empty/foreign-path test below
1842        // (which never enters the `Some(first)` branch at all).
1843        let dir = tempdir().unwrap();
1844        let content = dir.path().join("content");
1845        let templates = dir.path().join("templates");
1846        fs::create_dir_all(content.join("fr")).unwrap();
1847        fs::create_dir_all(&templates).unwrap();
1848        // No `templates/fr/post.html` — only the plain fallback exists.
1849        fs::write(templates.join("post.html"), "x").unwrap();
1850
1851        let got = resolve_template(
1852            &templates,
1853            &content.join("fr/a.md"),
1854            &content,
1855            "post",
1856        );
1857        assert_eq!(got, Some(templates.join("post.html")));
1858    }
1859
1860    #[test]
1861    fn resolve_template_falls_back_for_empty_and_foreign_paths() {
1862        let dir = tempdir().unwrap();
1863        let content = dir.path().join("content");
1864        let templates = dir.path().join("templates");
1865        fs::create_dir_all(&content).unwrap();
1866        fs::create_dir_all(&templates).unwrap();
1867        fs::write(templates.join("page.html"), "x").unwrap();
1868
1869        // md == content_dir: the relative path has no components.
1870        assert_eq!(
1871            resolve_template(&templates, &content, &content, "page"),
1872            Some(templates.join("page.html"))
1873        );
1874        // md outside content_dir: strip_prefix fails.
1875        assert_eq!(
1876            resolve_template(
1877                &templates,
1878                Path::new("/elsewhere/a.md"),
1879                &content,
1880                "page"
1881            ),
1882            Some(templates.join("page.html"))
1883        );
1884        // Nothing on disk at all: fallback is None.
1885        assert_eq!(
1886            resolve_template(
1887                &templates,
1888                &content.join("a.md"),
1889                &content,
1890                "missing"
1891            ),
1892            None
1893        );
1894    }
1895
1896    // ── scan_template_refs / parse_name — edge shapes ───────────────
1897
1898    #[test]
1899    fn scan_template_refs_handles_unclosed_and_plain_refs() {
1900        // Unclosed `{{` → break arm.
1901        assert!(scan_template_refs("{{#extends \"base\"").is_empty());
1902        // Plain variable refs produce no names; empty extends name is
1903        // filtered; duplicates are deduped.
1904        let refs =
1905            scan_template_refs("{{ title }}{{#extends \"\"}}{{->p}}{{->p}}");
1906        assert_eq!(refs, vec!["p".to_string()]);
1907    }
1908}