krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
v0.21.0: src/incremental.rs · raw
1//! Incremental build layer (spec §4): content/config/template hashing, the link
2//! dependency graph, the cache manifest, and the invalidation algorithm.
3//!
4//! This is the hard, non-retrofittable part (spec §4). The data model is already
5//! shaped for it — pure, hashable, dependency-tracked units — so this layer is mostly
6//! bookkeeping over the graph.
7//!
8//! The three hash classes (spec §4.1) all feed a page's composed `render_key`:
9//! 1. **content hash** — blake3 of a source file's raw bytes (drives re-parse).
10//! 2. **config hash** — blake3 of the resolved global config (a base-URL/options change
11//! can invalidate everything).
12//! 3. **template hash** — blake3 of the templates (a base-layout edit invalidates every
13//! page that uses it).
14//!
15//! Skip rule (spec §4.1): if a page's `render_key` is unchanged, its emitted file on
16//! disk is already correct — skip it. The dependency graph (spec §4.3) additionally
17//! invalidates pages that *link into* a changed file's targets, so a renamed/removed
18//! heading invalidates the pages that link to it, not just the file that owns it.
19
20use std::collections::{HashMap, HashSet};
21
22use camino::{Utf8Path, Utf8PathBuf};
23use serde::{Deserialize, Serialize};
24
25use crate::index::{SymbolTable, TargetId};
26use crate::model::ContentHash;
27use crate::util::output_url;
28
29/// Bump whenever the `Document` type, hashing scheme, or resolution rules change.
30/// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major
31/// version is folded in as the "hash-algo version" so a hash upgrade also busts.
32pub const CACHE_FORMAT_VERSION: u32 = 7;
33
34/// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1).
35pub type Hash = ContentHash;
36
37/// The resolved global build config is [`crate::config::Config`]; its hash is a
38/// component of every page's render key (spec §4.1), so editing `orgo.toml`
39/// invalidates the pages it affects.
40pub use crate::config::Config as BuildConfig;
41
42/// Compose bytes into a blake3 hash. The one place hashing happens for composite keys.
43fn hash_bytes(bytes: &[u8]) -> Hash {
44 ContentHash(*blake3::hash(bytes).as_bytes())
45}
46
47/// blake3 of the resolved global config (spec §4.1, hash class 2).
48pub fn config_hash(config: &BuildConfig) -> Hash {
49 let json = serde_json::to_vec(config).expect("BuildConfig serializes");
50 hash_bytes(&json)
51}
52
53/// Fold two hashes into one composite (order-sensitive).
54pub fn combine(a: Hash, b: Hash) -> Hash {
55 let mut hasher = blake3::Hasher::new();
56 hasher.update(&a.0);
57 hasher.update(&b.0);
58 ContentHash(*hasher.finalize().as_bytes())
59}
60
61/// Hash of the global site structure that appears in every page's chrome. The nav bar is
62/// built from every page's `(path, title)`, so any title change, path change, page
63/// addition, or removal alters the nav on ALL pages and must invalidate them. This is a
64/// genuine global dependency (like the config/template hashes, spec §4.1), so it is
65/// folded into every page's render key. Deterministic: entries are sorted.
66pub fn site_structure_hash(entries: &[(String, String)]) -> Hash {
67 let mut sorted = entries.to_vec();
68 sorted.sort();
69 site_structure_hash_ordered(&sorted)
70}
71
72/// As [`site_structure_hash`], but hashing the sequence *as given*. Used where order is
73/// itself part of the output — a listing page's entries are sorted deliberately, so
74/// re-ordering them is a real change even when the set is identical.
75pub fn site_structure_hash_ordered(entries: &[(String, String)]) -> Hash {
76 let mut hasher = blake3::Hasher::new();
77 for (path, title) in entries {
78 hasher.update(path.as_bytes());
79 hasher.update(&[0]);
80 hasher.update(title.as_bytes());
81 hasher.update(&[0]);
82 }
83 ContentHash(*hasher.finalize().as_bytes())
84}
85
86/// blake3 over the template sources (spec §4.1, hash class 3). One combined hash over
87/// all templates; when partials land, split this per-template so a single-partial edit
88/// invalidates only its users.
89pub fn template_hash(sources: &[(String, String)]) -> Hash {
90 let mut hasher = blake3::Hasher::new();
91 for (name, src) in sources {
92 hasher.update(name.as_bytes());
93 hasher.update(&[0]);
94 hasher.update(src.as_bytes());
95 hasher.update(&[0]);
96 }
97 ContentHash(*hasher.finalize().as_bytes())
98}
99
100/// The link dependency graph (spec §4.3). `defines`: file → targets it owns.
101/// `uses`: page → targets it resolved a link to (the invalidation-critical edges).
102#[derive(Debug, Default, Clone, Serialize, Deserialize)]
103pub struct DepGraph {
104 pub defines: HashMap<Utf8PathBuf, HashSet<TargetId>>,
105 pub uses: HashMap<Utf8PathBuf, HashSet<TargetId>>,
106}
107
108impl DepGraph {
109 /// Merge `self` (typically the previous build's graph) with `other` (this build's),
110 /// unioning the `defines` targets per file and taking `other`'s `uses`. Used to build
111 /// the graph handed to [`invalidation_set`]: a target that a changed file *removed*
112 /// is still present in the old `defines`, so pages that linked to it are still found
113 /// (the renamed/removed-heading case, spec §4.3 step 2).
114 pub fn merged_defines_with(&self, other: &DepGraph) -> DepGraph {
115 let mut defines = self.defines.clone();
116 for (file, targets) in &other.defines {
117 defines.entry(file.clone()).or_default().extend(targets.iter().cloned());
118 }
119 DepGraph {
120 defines,
121 uses: other.uses.clone(),
122 }
123 }
124}
125
126/// blake3 over the resolved URLs of the targets a page consumed (spec §4.1: the
127/// `resolved_links_hash` component). Computed relative to the linking page, so a target
128/// whose resolved URL/anchor changed — a renamed `*Heading`, a moved file — flips this
129/// hash and therefore the page's `render_key`. Deterministic: targets are sorted.
130pub fn resolved_links_hash(
131 from: &Utf8Path,
132 used: &HashSet<TargetId>,
133 symbols: &SymbolTable,
134) -> Hash {
135 let mut pairs: Vec<(String, String)> = used
136 .iter()
137 .map(|tid| {
138 let url = symbols
139 .targets
140 .get(tid)
141 .map(|loc| output_url(from, &loc.source_path, loc.anchor.as_deref()))
142 .unwrap_or_default();
143 (tid.sort_key(), url)
144 })
145 .collect();
146 pairs.sort();
147 let mut hasher = blake3::Hasher::new();
148 for (tid, url) in &pairs {
149 hasher.update(tid.as_bytes());
150 hasher.update(&[0]);
151 hasher.update(url.as_bytes());
152 hasher.update(&[0]);
153 }
154 ContentHash(*hasher.finalize().as_bytes())
155}
156
157/// Compose a page's final-output cache key (spec §4.1):
158///
159/// ```text
160/// render_key = H( parse_result_hash ⊕ resolved_links_hash ⊕ config_hash ⊕ template_hash )
161/// ```
162///
163/// `parse_result_hash` is the source content hash (PARSE is a pure function of the file
164/// bytes, so the content hash fully identifies the parse result). If `render_key` is
165/// unchanged the on-disk output is already correct and the page is skipped.
166pub fn render_key(
167 parse_result_hash: Hash,
168 resolved_links_hash: Hash,
169 config_hash: Hash,
170 template_hash: Hash,
171) -> Hash {
172 let mut hasher = blake3::Hasher::new();
173 hasher.update(&parse_result_hash.0);
174 hasher.update(&resolved_links_hash.0);
175 hasher.update(&config_hash.0);
176 hasher.update(&template_hash.0);
177 ContentHash(*hasher.finalize().as_bytes())
178}
179
180/// Per-page cache record persisted in the manifest (spec §4.5).
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct PageRecord {
183 pub content_hash: ContentHash,
184 pub render_key: Hash,
185 pub output_path: Utf8PathBuf,
186}
187
188/// The on-disk cache manifest (spec §4.5). Serialized as JSON (human-diffable; the
189/// cache is an optimization, never a correctness dependency — a `--no-cache`/`clean`
190/// run always produces byte-identical output).
191#[derive(Debug, Default, Serialize, Deserialize)]
192pub struct Manifest {
193 pub format_version: u32,
194 pub config_hash: Option<Hash>,
195 pub pages: HashMap<Utf8PathBuf, PageRecord>,
196 pub graph: DepGraph,
197}
198
199/// The cache-manifest file lives inside the output directory (spec §4.5: an on-disk
200/// cache dir). `clean` removes the output directory, taking the cache with it.
201pub fn manifest_path(out: &Utf8Path) -> Utf8PathBuf {
202 out.join(".orgo-cache.json")
203}
204
205/// Load the manifest, returning `None` on ANY of: missing file, read/parse error, or a
206/// cache-format version mismatch (spec §4.5). `None` ⇒ the caller does a full rebuild.
207/// The cache is never a correctness dependency, so a corrupt cache is never a crash.
208pub fn load_manifest(out: &Utf8Path) -> Option<Manifest> {
209 let bytes = std::fs::read(manifest_path(out)).ok()?;
210 let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
211 if manifest.format_version != CACHE_FORMAT_VERSION {
212 return None;
213 }
214 Some(manifest)
215}
216
217/// Persist the manifest into the output directory. A write failure is surfaced to the
218/// caller (a failed cache write only costs the next build a full rebuild).
219pub fn save_manifest(out: &Utf8Path, manifest: &Manifest) -> std::io::Result<()> {
220 let json = serde_json::to_vec_pretty(manifest).expect("manifest serializes");
221 std::fs::write(manifest_path(out), json)
222}
223
224/// Given the set of changed files and the dependency graph, compute the set of pages to
225/// rebuild (spec §4.3 invalidation algorithm): the changed files themselves, plus every
226/// page with a `uses` edge into a target *defined by* a changed file. Pass a graph whose
227/// `defines` is the union of the previous and current builds (see
228/// [`DepGraph::merged_defines_with`]) so that a target a changed file *removed* still
229/// pulls in the pages that linked to it (the renamed/removed-heading case).
230pub fn invalidation_set(
231 changed: &HashSet<Utf8PathBuf>,
232 graph: &DepGraph,
233) -> HashSet<Utf8PathBuf> {
234 // Targets touched by any changed file (added, removed, or possibly-moved).
235 let mut delta_targets: HashSet<&TargetId> = HashSet::new();
236 for file in changed {
237 if let Some(targets) = graph.defines.get(file) {
238 delta_targets.extend(targets.iter());
239 }
240 }
241 // Changed files themselves, plus any page that links into a delta'd target.
242 let mut result: HashSet<Utf8PathBuf> = changed.clone();
243 for (page, used) in &graph.uses {
244 if used.iter().any(|t| delta_targets.contains(t)) {
245 result.insert(page.clone());
246 }
247 }
248 result
249}