krz/orgo

Lightning fast org-mode static site generator.

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

main: tests/incremental.rs · raw

  1//! Incremental build layer gates (spec §4, Phase 6). These are the hard correctness
  2//! tests the incremental design exists to satisfy:
  3//!
  4//! - **Byte-equivalence**: a full (`--no-cache`) build and an incremental rebuild of an
  5//!   unchanged site produce byte-identical output, and the second build re-renders ZERO
  6//!   pages (spec §4.5, R5).
  7//! - **Edit-one-file**: editing a page re-renders exactly that page plus the pages that
  8//!   link into it — no more, no less (spec §4.3).
  9//! - **Renamed-heading**: renaming a heading a cross-page link points at invalidates the
 10//!   linking page and updates its emitted anchor (spec §4.3, R2 — the load-bearing case).
 11//! - **Cache fallback**: a version bump, a missing cache, or a corrupt cache all fall
 12//!   back to a full rebuild (spec §4.5).
 13
 14use std::collections::BTreeMap;
 15use std::sync::atomic::{AtomicU32, Ordering};
 16
 17use camino::Utf8PathBuf;
 18
 19use orgo::incremental::{manifest_path, Manifest, CACHE_FORMAT_VERSION};
 20use orgo::site::{build_site, BuildOptions};
 21
 22/// A fresh, empty temp directory unique to this process + call.
 23fn tmpdir(tag: &str) -> Utf8PathBuf {
 24    static N: AtomicU32 = AtomicU32::new(0);
 25    let n = N.fetch_add(1, Ordering::Relaxed);
 26    let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
 27        .expect("utf-8 temp dir")
 28        .join(format!("orgo-it-{}-{tag}-{n}", std::process::id()));
 29    if base.exists() {
 30        std::fs::remove_dir_all(&base).unwrap();
 31    }
 32    std::fs::create_dir_all(&base).unwrap();
 33    base
 34}
 35
 36fn write(dir: &Utf8PathBuf, name: &str, content: &str) {
 37    std::fs::write(dir.join(name), content).unwrap();
 38}
 39
 40/// Every output file (relative path → bytes) except the cache manifest, which is an
 41/// internal artifact with non-deterministic map ordering.
 42fn output_files(out: &Utf8PathBuf) -> BTreeMap<String, Vec<u8>> {
 43    let mut map = BTreeMap::new();
 44    for entry in walkdir::WalkDir::new(out).sort_by_file_name() {
 45        let entry = entry.unwrap();
 46        if !entry.file_type().is_file() {
 47            continue;
 48        }
 49        let path = Utf8PathBuf::from_path_buf(entry.path().to_owned()).unwrap();
 50        if path.file_name() == Some(".orgo-cache.json") {
 51            continue;
 52        }
 53        let rel = path.strip_prefix(out).unwrap().to_string();
 54        map.insert(rel, std::fs::read(&path).unwrap());
 55    }
 56    map
 57}
 58
 59fn out(p: &str) -> Utf8PathBuf {
 60    Utf8PathBuf::from(p)
 61}
 62
 63/// Two linked pages: `b.org` links to a `:CUSTOM_ID:` heading in `a.org`, plus a css asset.
 64fn write_linked_site(src: &Utf8PathBuf) {
 65    write(
 66        src,
 67        "a.org",
 68        "#+TITLE: A\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nOriginal body.\n",
 69    );
 70    write(src, "b.org", "#+TITLE: B\n\nSee [[#setup][the setup]].\n");
 71    write(src, "style.css", "body { color: black; }\n");
 72}
 73
 74#[test]
 75fn full_and_incremental_are_byte_identical_and_second_build_renders_nothing() {
 76    let root = tmpdir("byteeq");
 77    let src = root.join("src");
 78    std::fs::create_dir_all(&src).unwrap();
 79    write_linked_site(&src);
 80
 81    // Full build (cache bypassed) to a reference directory.
 82    let full = root.join("full");
 83    let rfull = build_site(
 84        &src,
 85        &full,
 86        &BuildOptions {
 87            no_cache: true,
 88            ..Default::default()
 89        },
 90    )
 91    .unwrap();
 92    assert_eq!(rfull.rendered.len(), 2, "full build renders every page");
 93
 94    // Incremental directory: first build populates the cache and renders everything.
 95    let inc = root.join("inc");
 96    let r1 = build_site(&src, &inc, &BuildOptions::default()).unwrap();
 97    assert_eq!(r1.rendered.len(), 2, "first incremental build renders all");
 98
 99    // Second incremental build of the UNCHANGED site must re-render ZERO pages.
100    let r2 = build_site(&src, &inc, &BuildOptions::default()).unwrap();
101    assert!(
102        r2.rendered.is_empty(),
103        "unchanged rebuild must render nothing, rendered: {:?}",
104        r2.rendered
105    );
106    assert_eq!(r2.skipped.len(), 2, "both pages reused from cache");
107
108    // Full output == incremental output, byte for byte.
109    assert_eq!(
110        output_files(&full),
111        output_files(&inc),
112        "incremental output must be byte-identical to a full build"
113    );
114}
115
116#[test]
117fn editing_a_page_rebuilds_it_and_its_linkers_exactly() {
118    let root = tmpdir("editone");
119    let src = root.join("src");
120    std::fs::create_dir_all(&src).unwrap();
121    write_linked_site(&src);
122    let out_dir = root.join("out");
123
124    // Prime the cache.
125    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
126
127    // Edit a.org's body (not its heading/custom-id): b.org links into a.org, so the
128    // invalidation set is exactly {a, b} — b is re-rendered because it links to a.
129    write(
130        &src,
131        "a.org",
132        "#+TITLE: A\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nEdited body.\n",
133    );
134    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
135
136    let mut rendered = r.rendered.clone();
137    rendered.sort();
138    assert_eq!(
139        rendered,
140        vec![out("a.html"), out("b.html")],
141        "editing a.org re-renders exactly a.html and its linker b.html"
142    );
143    assert_eq!(r.skipped, Vec::<Utf8PathBuf>::new(), "nothing else exists to skip");
144}
145
146#[test]
147fn editing_a_leaf_page_rebuilds_only_itself() {
148    let root = tmpdir("editleaf");
149    let src = root.join("src");
150    std::fs::create_dir_all(&src).unwrap();
151    write_linked_site(&src);
152    let out_dir = root.join("out");
153
154    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
155
156    // b.org has NO inbound links, so editing it invalidates only itself.
157    write(&src, "b.org", "#+TITLE: B\n\nSee [[#setup][the setup]]. Edited.\n");
158    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
159
160    assert_eq!(r.rendered, vec![out("b.html")], "only the edited leaf re-renders");
161    assert!(
162        r.skipped.contains(&out("a.html")),
163        "the unlinked page a.html is reused"
164    );
165}
166
167#[test]
168fn renaming_a_linked_heading_invalidates_the_linking_page() {
169    let root = tmpdir("rename");
170    let src = root.join("src");
171    std::fs::create_dir_all(&src).unwrap();
172    // b.org links to a.org's heading BY TEXT (the fragile `[[*Heading]]` case, spec §4.3).
173    write(&src, "a.org", "#+TITLE: A\n\n* Target Heading\nBody.\n");
174    write(&src, "b.org", "#+TITLE: B\n\nJump to [[*Target Heading][there]].\n");
175    let out_dir = root.join("out");
176
177    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
178    let b_before = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
179    assert!(
180        b_before.contains("a.html#target-heading"),
181        "b.html should link to the target heading anchor initially:\n{b_before}"
182    );
183
184    // Rename the heading a.org owns. b.org's [[*Target Heading]] now dangles.
185    write(&src, "a.org", "#+TITLE: A\n\n* Renamed Heading\nBody.\n");
186    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
187
188    assert!(
189        r.rendered.contains(&out("b.html")),
190        "the linking page must be invalidated by the rename, rendered: {:?}",
191        r.rendered
192    );
193    assert!(
194        r.rendered.contains(&out("a.html")),
195        "the renamed page itself is re-rendered"
196    );
197
198    let b_after = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
199    assert_ne!(b_before, b_after, "b.html's emitted link must change");
200    assert!(
201        !b_after.contains("a.html#target-heading"),
202        "the stale cross-file anchor must be gone:\n{b_after}"
203    );
204    assert!(
205        !r.broken.is_empty(),
206        "the now-dangling link should be reported as broken"
207    );
208}
209
210#[test]
211fn changing_a_title_rebuilds_every_page_for_the_shared_nav() {
212    let root = tmpdir("navtitle");
213    let src = root.join("src");
214    std::fs::create_dir_all(&src).unwrap();
215    write_linked_site(&src);
216    let out_dir = root.join("out");
217
218    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
219    let b_before = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
220
221    // a.org's #+TITLE feeds the nav bar on every page, so changing it must re-render all.
222    write(
223        &src,
224        "a.org",
225        "#+TITLE: A Renamed\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nOriginal body.\n",
226    );
227    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
228
229    assert_eq!(r.rendered.len(), 2, "a title change re-renders every page");
230    let b_after = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
231    assert_ne!(b_before, b_after, "b.html's nav must reflect a.org's new title");
232    assert!(b_after.contains("A Renamed"), "b.html nav shows the updated title");
233}
234
235#[test]
236fn missing_cache_falls_back_to_full_rebuild() {
237    let root = tmpdir("nocache");
238    let src = root.join("src");
239    std::fs::create_dir_all(&src).unwrap();
240    write_linked_site(&src);
241    let out_dir = root.join("out");
242
243    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
244    // Delete the cache manifest → next build has nothing to skip against.
245    std::fs::remove_file(manifest_path(&out_dir)).unwrap();
246
247    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
248    assert_eq!(r.rendered.len(), 2, "a missing cache forces a full rebuild");
249    assert!(r.skipped.is_empty());
250}
251
252#[test]
253fn cache_version_mismatch_falls_back_to_full_rebuild() {
254    let root = tmpdir("versionbump");
255    let src = root.join("src");
256    std::fs::create_dir_all(&src).unwrap();
257    write_linked_site(&src);
258    let out_dir = root.join("out");
259
260    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
261
262    // Rewrite the manifest with a future cache-format version. On mismatch the loader
263    // discards it (spec §4.5), so the next build re-renders everything.
264    let stale = Manifest {
265        format_version: CACHE_FORMAT_VERSION + 1,
266        ..Default::default()
267    };
268    std::fs::write(
269        manifest_path(&out_dir),
270        serde_json::to_vec(&stale).unwrap(),
271    )
272    .unwrap();
273
274    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
275    assert_eq!(
276        r.rendered.len(),
277        2,
278        "a cache-format version bump forces a full rebuild"
279    );
280    assert!(r.skipped.is_empty());
281}
282
283#[test]
284fn corrupt_cache_falls_back_without_crashing() {
285    let root = tmpdir("corrupt");
286    let src = root.join("src");
287    std::fs::create_dir_all(&src).unwrap();
288    write_linked_site(&src);
289    let out_dir = root.join("out");
290
291    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
292    std::fs::write(manifest_path(&out_dir), b"this is not json{{{").unwrap();
293
294    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
295    assert_eq!(r.rendered.len(), 2, "a corrupt cache is never a correctness dependency");
296}
297
298/// PARSE, RESOLVE and RENDER/EMIT all run in parallel (rayon). Parallelism must not be
299/// observable in the result: the emitted bytes and the *ordering* of the build report
300/// have to be identical run to run, or a build stops being reproducible.
301///
302/// The report ordering is the fragile half. Pushing to `rendered`/`skipped` from inside
303/// the parallel pass would order them by thread scheduling, giving a non-deterministic
304/// report over a deterministic site — so the report is assembled sequentially afterwards,
305/// and this test is what holds that line. Enough pages to make a race likely if one exists.
306#[test]
307fn parallel_builds_are_deterministic_in_output_and_report_order() {
308    let root = tmpdir("parallel");
309    let src = root.join("src");
310    std::fs::create_dir_all(src.join("deep")).unwrap();
311
312    for i in 0..40 {
313        // Cross-link every page to its neighbour so RESOLVE has real work, and give each
314        // a source block so RENDER does too.
315        let body = format!(
316            "#+TITLE: Page {i}\n#+SLUG: page-{i}\n\n\
317             See [[#anchor-{next}][the next page]].\n\n\
318             * Heading {i}\n:PROPERTIES:\n:CUSTOM_ID: anchor-{i}\n:END:\n\n\
319             #+BEGIN_SRC rust\nfn page_{i}() -> u32 {{ {i} }}\n#+END_SRC\n",
320            next = (i + 1) % 40
321        );
322        let dir = if i % 3 == 0 { src.join("deep") } else { src.clone() };
323        std::fs::write(dir.join(format!("p{i}.org")), body).unwrap();
324    }
325
326    let build = |out: &Utf8PathBuf| {
327        build_site(
328            &src,
329            out,
330            &BuildOptions {
331                no_cache: true,
332                ..Default::default()
333            },
334        )
335        .unwrap()
336    };
337
338    let first_out = root.join("first");
339    let first = build(&first_out);
340    assert_eq!(first.rendered.len(), 40, "every page renders");
341
342    for _ in 0..3 {
343        let out = tmpdir("parallel-again").join("out");
344        let again = build(&out);
345        assert_eq!(
346            first.pages, again.pages,
347            "page ordering in the report must be deterministic"
348        );
349        assert_eq!(
350            first.rendered, again.rendered,
351            "rendered ordering in the report must be deterministic"
352        );
353        assert_eq!(
354            first.skipped, again.skipped,
355            "skipped ordering in the report must be deterministic"
356        );
357        assert_eq!(
358            output_files(&first_out),
359            output_files(&out),
360            "emitted bytes must be identical across runs"
361        );
362    }
363}
364
365/// A site with pages in subdirectories.
366fn write_nested_site(src: &Utf8PathBuf) {
367    std::fs::create_dir_all(src.join("blog")).unwrap();
368    write(src, "index.org", "#+TITLE: Home\n\nWelcome.\n");
369    write(src, "about.org", "#+TITLE: About\n\nAbout me.\n");
370    write(&src.join("blog"), "first.org", "#+TITLE: First Post\n\nPost body.\n");
371    write(&src.join("blog"), "second.org", "#+TITLE: Second Post\n\nPost body.\n");
372}
373
374/// The nav is a map of the site's top level, not an index of its contents. Listing every
375/// page made an n-page site emit n² nav links: 1,790 pages produced 284 MB of output,
376/// nearly all of it nav.
377#[test]
378fn nav_lists_only_top_level_pages() {
379    let root = tmpdir("navtop");
380    let src = root.join("src");
381    std::fs::create_dir_all(&src).unwrap();
382    write_nested_site(&src);
383    let out_dir = root.join("out");
384
385    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
386    let home = std::fs::read_to_string(out_dir.join("index.html")).unwrap();
387    let nav = home
388        .split("<nav>")
389        .nth(1)
390        .and_then(|s| s.split("</nav>").next())
391        .expect("a nav element");
392
393    assert!(nav.contains("About"), "a root-level page belongs in the nav:\n{nav}");
394    assert!(nav.contains("Home"), "the index page belongs in the nav:\n{nav}");
395    assert!(
396        !nav.contains("First Post") && !nav.contains("Second Post"),
397        "pages in subdirectories must not appear in the nav:\n{nav}"
398    );
399
400    // Nested pages still get the nav — they just are not *in* it.
401    let post = std::fs::read_to_string(out_dir.join("blog/first.html")).unwrap();
402    assert!(
403        post.contains("href=\"../about.html\"") && post.contains("href=\"../index.html\""),
404        "a nested page links up to the top-level nav:\n{post}"
405    );
406}
407
408/// The payoff for narrowing the site-structure hash to nav entries. Adding a blog post
409/// cannot change any other page's nav, so it must not re-render the site — which is what
410/// hashing *every* page's (path, title) used to force.
411#[test]
412fn adding_a_nested_page_does_not_rebuild_the_site() {
413    let root = tmpdir("navadd");
414    let src = root.join("src");
415    std::fs::create_dir_all(&src).unwrap();
416    write_nested_site(&src);
417    let out_dir = root.join("out");
418
419    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
420
421    write(&src.join("blog"), "third.org", "#+TITLE: Third Post\n\nBody.\n");
422    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
423
424    assert_eq!(
425        r.rendered,
426        vec![out("blog/third.html")],
427        "only the new nested page renders, got: {:?}",
428        r.rendered
429    );
430    assert_eq!(r.skipped.len(), 4, "every pre-existing page is reused");
431}
432
433/// The other half of the same rule: a page that IS in the nav still invalidates
434/// everything when its title changes, because every page renders that title.
435#[test]
436fn retitling_a_top_level_page_still_rebuilds_the_site() {
437    let root = tmpdir("navretitle");
438    let src = root.join("src");
439    std::fs::create_dir_all(&src).unwrap();
440    write_nested_site(&src);
441    let out_dir = root.join("out");
442
443    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
444    write(&src, "about.org", "#+TITLE: Colophon\n\nAbout me.\n");
445    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
446
447    assert_eq!(r.rendered.len(), 4, "a nav title change re-renders every page");
448    let post = std::fs::read_to_string(out_dir.join("blog/first.html")).unwrap();
449    assert!(post.contains("Colophon"), "nested pages show the updated nav title");
450}
451
452/// Editing one layout must re-render the pages that use it, and only those. Hashing every
453/// template into every page means a change to the feed template rewrites the whole site,
454/// which is most of the wait in a `serve` session spent on design.
455#[test]
456fn editing_one_template_rebuilds_only_the_pages_that_use_it() {
457    let root = tmpdir("tmplscope");
458    let src = root.join("src");
459    std::fs::create_dir_all(src.join("blog")).unwrap();
460    std::fs::create_dir_all(src.join("templates")).unwrap();
461    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
462    std::fs::write(src.join("about.org"), "#+TITLE: About\n\nAbout.\n").unwrap();
463    std::fs::write(
464        src.join("blog/post.org"),
465        "#+TITLE: Post\n#+DATE: 2026-01-01\n\nBody.\n",
466    )
467    .unwrap();
468    std::fs::write(
469        src.join("templates/base.html"),
470        "<html><body>{% block content %}{{ body | safe }}{% endblock %}</body></html>",
471    )
472    .unwrap();
473    std::fs::write(
474        src.join("templates/post.html"),
475        "{% extends \"base.html\" %}{% block content %}{{ body | safe }}<p>reply</p>{% endblock %}",
476    )
477    .unwrap();
478    std::fs::write(
479        src.join("orgo.toml"),
480        "[[pages]]\nmatch = \"blog\"\ntemplate = \"post.html\"\n",
481    )
482    .unwrap();
483    let out_dir = root.join("out");
484    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
485
486    // post.html is used by one page.
487    std::fs::write(
488        src.join("templates/post.html"),
489        "{% extends \"base.html\" %}{% block content %}{{ body | safe }}<p>reply now</p>{% endblock %}",
490    )
491    .unwrap();
492    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
493    assert_eq!(
494        r.rendered,
495        vec![Utf8PathBuf::from("blog/post.html")],
496        "only the page whose layout changed"
497    );
498    assert!(std::fs::read_to_string(out_dir.join("blog/post.html"))
499        .unwrap()
500        .contains("reply now"));
501
502    // base.html is extended by post.html, so editing it reaches both.
503    std::fs::write(
504        src.join("templates/base.html"),
505        "<html><body class=\"new\">{% block content %}{{ body | safe }}{% endblock %}</body></html>",
506    )
507    .unwrap();
508    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
509    assert_eq!(
510        r.rendered.len(),
511        3,
512        "a layout everything inherits still re-renders everything: {:?}",
513        r.rendered
514    );
515}