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