krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
v0.3.0: 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 org_ssg::incremental::{manifest_path, Manifest, CACHE_FORMAT_VERSION};
20use org_ssg::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!("org-ssg-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(".org-ssg-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 strict: false,
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}