krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.20.2: 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}