krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
1//! `watch`: the change filter, and one end-to-end run against real filesystem events.
2//!
3//! The filter carries the weight here. `orgo watch . -o _site` puts the output inside
4//! the source, so a rebuild writes files, writing files raises events, and events trigger
5//! a rebuild — a loop that never stops. That it is a pure function is what makes the
6//! guarantee testable without waiting on a filesystem.
7
8use std::sync::atomic::{AtomicU32, Ordering};
9use std::time::{Duration, Instant};
10
11use camino::{Utf8Path, Utf8PathBuf};
12
13use orgo::site::{build_site, BuildOptions};
14use orgo::watch::ChangeFilter;
15
16fn tmpdir(tag: &str) -> Utf8PathBuf {
17 static N: AtomicU32 = AtomicU32::new(0);
18 let n = N.fetch_add(1, Ordering::Relaxed);
19 let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
20 .expect("utf-8 temp dir")
21 .join(format!("orgo-watch-{}-{tag}-{n}", std::process::id()));
22 let _ = std::fs::remove_dir_all(&base);
23 std::fs::create_dir_all(&base).unwrap();
24 base
25}
26
27// ---------------------------------------------------------------------------
28// The change filter
29// ---------------------------------------------------------------------------
30
31/// The one that matters: without it, `watch . -o _site` rebuilds forever.
32#[test]
33fn changes_under_the_output_directory_are_ignored() {
34 let root = tmpdir("filterout");
35 let src = root.join("src");
36 let out = src.join("_site");
37 std::fs::create_dir_all(&out).unwrap();
38
39 let filter = ChangeFilter::new(&src, &out);
40 assert!(!filter.is_relevant(Utf8Path::new("_site/index.html")));
41 assert!(!filter.is_relevant(Utf8Path::new("_site/blog/post.html")));
42 assert!(!filter.is_relevant(Utf8Path::new("_site/.orgo-cache.json")));
43 assert!(filter.is_relevant(Utf8Path::new("index.org")), "real sources still count");
44}
45
46/// An output directory outside the source cannot cause a loop, and must not accidentally
47/// suppress a similarly-named source directory.
48#[test]
49fn an_external_output_directory_suppresses_nothing() {
50 let root = tmpdir("filterext");
51 let src = root.join("src");
52 let out = root.join("out");
53 std::fs::create_dir_all(&src).unwrap();
54 std::fs::create_dir_all(&out).unwrap();
55
56 let filter = ChangeFilter::new(&src, &out);
57 assert!(filter.is_relevant(Utf8Path::new("index.org")));
58 assert!(filter.is_relevant(Utf8Path::new("out/notes.org")), "a source dir named `out`");
59}
60
61/// A change to the config or a template changes the output, so both must rebuild — even
62/// though build-time *discovery* skips them as non-content. The watch rule is "would this
63/// change the site?", not "is this a page?".
64#[test]
65fn build_inputs_trigger_a_rebuild_even_though_discovery_skips_them() {
66 let root = tmpdir("filterinputs");
67 let src = root.join("src");
68 std::fs::create_dir_all(&src).unwrap();
69 let filter = ChangeFilter::new(&src, &root.join("out"));
70
71 assert!(filter.is_relevant(Utf8Path::new("orgo.toml")));
72 assert!(filter.is_relevant(Utf8Path::new("templates/base.html")));
73 assert!(filter.is_relevant(Utf8Path::new("templates/feed.xml")));
74 assert!(filter.is_relevant(Utf8Path::new("style.css")), "assets are copied through");
75}
76
77/// `.git` churns on every command, and rebuilding the site because git wrote an index
78/// lock would make watch useless in any repository.
79#[test]
80fn dot_directories_and_editor_scratch_files_are_ignored() {
81 let root = tmpdir("filterdots");
82 let src = root.join("src");
83 std::fs::create_dir_all(&src).unwrap();
84 let filter = ChangeFilter::new(&src, &root.join("out"));
85
86 for ignored in [
87 ".git/index",
88 ".git/objects/ab/cdef",
89 ".DS_Store",
90 "blog/.#post.org", // Emacs lock
91 "post.org~", // Emacs backup
92 ".post.org.swp", // vim
93 "#post.org#", // Emacs auto-save
94 "build.tmp",
95 ] {
96 assert!(
97 !filter.is_relevant(Utf8Path::new(ignored)),
98 "{ignored} should not trigger a rebuild"
99 );
100 }
101 for relevant in ["post.org", "blog/post.org", "a-file~with-tilde.org"] {
102 assert!(
103 filter.is_relevant(Utf8Path::new(relevant)),
104 "{relevant} should trigger a rebuild"
105 );
106 }
107}
108
109/// Events arrive as absolute paths and in bursts, often naming one file several times.
110#[test]
111fn absolute_event_paths_are_reduced_to_a_sorted_unique_set() {
112 let root = tmpdir("filterrel");
113 let src = root.join("src");
114 let out = src.join("_site");
115 std::fs::create_dir_all(&out).unwrap();
116
117 let filter = ChangeFilter::new(&src, &out);
118 let events = vec![
119 src.join("b.org"),
120 src.join("a.org"),
121 src.join("b.org"),
122 src.join("_site/a.html"),
123 src.join("a.org~"),
124 ];
125 assert_eq!(
126 filter.relevant(events),
127 vec![Utf8PathBuf::from("a.org"), Utf8PathBuf::from("b.org")]
128 );
129}
130
131// ---------------------------------------------------------------------------
132// End to end
133// ---------------------------------------------------------------------------
134
135/// Drive the real watcher against a real edit. Timing-dependent by nature, so it polls
136/// for the expected result with a generous ceiling rather than sleeping a fixed amount.
137#[test]
138fn watching_rebuilds_the_site_when_a_source_file_changes() {
139 let root = tmpdir("watchrun");
140 let src = root.join("src");
141 std::fs::create_dir_all(&src).unwrap();
142 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nFirst version.\n").unwrap();
143 let out = src.join("_site"); // deliberately inside the source: the loop case
144
145 build_site(&src, &out, &BuildOptions::default()).unwrap();
146 assert!(std::fs::read_to_string(out.join("index.html"))
147 .unwrap()
148 .contains("First version."));
149
150 let (src_t, out_t) = (src.clone(), out.clone());
151 let handle = std::thread::spawn(move || {
152 let _ = orgo::watch::run(&src_t, &out_t, &BuildOptions::default());
153 });
154
155 // The edit is repeated rather than made once after a fixed head start. The watcher
156 // registers its OS watches *after* an initial build, and that build loads syntect's
157 // syntax set — on a cold CI runner, comfortably longer than any head start worth
158 // hard-coding. An edit made before anything is listening produces no event at all,
159 // which looks exactly like a watcher that does not work, and only one of those is a
160 // defect worth failing a build over. (Found by the first CI run on Linux, where a
161 // 300ms head start was not enough and macOS had never noticed.)
162 let deadline = Instant::now() + Duration::from_secs(30);
163 let mut rebuilt = false;
164 let mut wrote_at: Option<Instant> = None;
165 while Instant::now() < deadline {
166 if wrote_at.is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) {
167 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap();
168 wrote_at = Some(Instant::now());
169 }
170 if std::fs::read_to_string(out.join("index.html"))
171 .map(|h| h.contains("Second version."))
172 .unwrap_or(false)
173 {
174 rebuilt = true;
175 break;
176 }
177 std::thread::sleep(Duration::from_millis(50));
178 }
179 assert!(rebuilt, "an edit should trigger a rebuild within 30s");
180
181 // The output lives inside the source, so the rebuild's own writes raised events. If
182 // those are not filtered out, watch spins forever.
183 //
184 // The file to watch for that is `syntax.css`, not `index.html`. The incremental
185 // build leaves an unchanged page alone, so `index.html` holds still even *during* a
186 // runaway loop — an assertion on it passes whether or not the filter works, which is
187 // exactly what it did before this comment existed. `syntax.css` is rewritten on
188 // every build, so its mtime is a direct record of how many builds have run.
189 // Long enough for the rebuild from the last repeated write to have landed before the
190 // first reading, or this measures that instead of a feedback loop.
191 let stylesheet = out.join("syntax.css");
192 std::thread::sleep(Duration::from_millis(1500));
193 let first = std::fs::metadata(&stylesheet).unwrap().modified().unwrap();
194 std::thread::sleep(Duration::from_millis(1200));
195 let second = std::fs::metadata(&stylesheet).unwrap().modified().unwrap();
196 assert_eq!(
197 first, second,
198 "the build's own writes must not feed back in as changes — watch is rebuilding \
199 in a loop"
200 );
201
202 drop(handle); // the watcher thread ends with the process
203}