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}