1use std::fs;
38use std::io;
39use std::path::Path;
40
41const LEXICON: &[&str] = &[
46 "compiler",
47 "pipeline",
48 "markdown",
49 "template",
50 "static",
51 "render",
52 "accessible",
53 "contrast",
54 "locale",
55 "sitemap",
56 "canonical",
57 "manifest",
58 "fingerprint",
59 "integrity",
60 "streaming",
61 "incremental",
62 "corpus",
63 "benchmark",
64 "throughput",
65 "latency",
66 "deterministic",
67 "artefact",
68];
69
70const TAGS: &[&str] = &[
72 "architecture",
73 "performance",
74 "accessibility",
75 "security",
76 "tooling",
77];
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct CorpusSpec {
92 pub pages: usize,
94 pub seed: u64,
97 pub words_per_page: usize,
99}
100
101impl CorpusSpec {
102 pub const DEFAULT_SEED: u64 = 0x5353_4720_4265_6e63; #[must_use]
109 pub const fn new(pages: usize) -> Self {
110 Self {
111 pages,
112 seed: Self::DEFAULT_SEED,
113 words_per_page: 220,
114 }
115 }
116
117 #[must_use]
119 pub const fn with_seed(mut self, seed: u64) -> Self {
120 self.seed = seed;
121 self
122 }
123}
124
125struct Rng(u64);
131
132impl Rng {
133 const fn new(seed: u64) -> Self {
134 Self(if seed == 0 {
136 0x9E37_79B9_7F4A_7C15
137 } else {
138 seed
139 })
140 }
141
142 const fn next(&mut self) -> u64 {
143 let mut x = self.0;
144 x ^= x >> 12;
145 x ^= x << 25;
146 x ^= x >> 27;
147 self.0 = x;
148 x.wrapping_mul(0x2545_F491_4F6C_DD1D)
149 }
150
151 const fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
152 let idx = (self.next() % items.len() as u64) as usize;
153 &items[idx]
154 }
155}
156
157pub fn generate_corpus(dir: &Path, spec: &CorpusSpec) -> io::Result<usize> {
175 fs::create_dir_all(dir)?;
176
177 for i in 0..spec.pages {
178 let mut rng =
182 Rng::new(spec.seed ^ (i as u64).wrapping_mul(0x9E37_79B9));
183
184 let words: Vec<&str> = (0..spec.words_per_page)
185 .map(|_| *rng.pick(LEXICON))
186 .collect();
187 let tag_a = rng.pick(TAGS);
188 let tag_b = rng.pick(TAGS);
189
190 let half = words.len() / 2;
193 let body = format!(
194 "## Section {i}\n\n{}\n\n### Detail\n\n{}\n",
195 words[..half].join(" "),
196 words[half..].join(" "),
197 );
198
199 let page = format!(
200 "---\n\
201 title: \"Benchmark page {i}\"\n\
202 description: \"Synthetic page {i} from the SSG benchmark corpus.\"\n\
203 date: \"2026-01-15T09:00:00+00:00\"\n\
204 language: \"en-GB\"\n\
205 layout: \"page\"\n\
206 permalink: \"https://example.com/page-{i}\"\n\
207 author: \"[email protected]\"\n\
208 tags: \"{tag_a}, {tag_b}\"\n\
209 charset: \"utf-8\"\n\
210 viewport: \"width=device-width, initial-scale=1, shrink-to-fit=no\"\n\
211 url: \"https://example.com/page-{i}\"\n\
212 id: \"https://example.com\"\n\
213 name: \"SSG Benchmark\"\n\
214 short_name: \"ssg-bench\"\n\
215 subtitle: \"Synthetic page {i}\"\n\
216 image: \"https://example.com/og-{i}.png\"\n\
217 logo: \"https://example.com/logo.svg\"\n\
218 logo_alt: \"SSG Benchmark logo\"\n\
219 logo_width: \"100\"\n\
220 logo_height: \"33\"\n\
221 theme-color: \"26, 58, 138\"\n\
222 cdn: \"https://cloudcdn.pro\"\n\
223 copyright: \"Copyright © 2026. All rights reserved.\"\n\
224 hreflang: \"en\"\n\
225 item_pub_date: \"2026-01-15T09:00:00+00:00\"\n\
226 last_build_date: \"2026-01-15T09:00:00+00:00\"\n\
227 primary: \"\"\n\
228 opengraph: \"\"\n\
229 twitter: \"\"\n\
230 apple: \"\"\n\
231 microsoft: \"\"\n\
232 ---\n\n{body}"
233 );
234
235 fs::write(dir.join(format!("page-{i:04}.md")), page)?;
238 }
239
240 Ok(spec.pages)
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn corpus_is_byte_identical_across_runs() {
249 let a = tempfile::tempdir().unwrap();
252 let b = tempfile::tempdir().unwrap();
253 let spec = CorpusSpec::new(16);
254
255 let _written = generate_corpus(a.path(), &spec).unwrap();
256 let _written = generate_corpus(b.path(), &spec).unwrap();
257
258 for i in 0..16 {
259 let name = format!("page-{i:04}.md");
260 assert_eq!(
261 fs::read(a.path().join(&name)).unwrap(),
262 fs::read(b.path().join(&name)).unwrap(),
263 "{name} differs between runs"
264 );
265 }
266 }
267
268 #[test]
269 fn page_content_does_not_depend_on_corpus_size() {
270 let small = tempfile::tempdir().unwrap();
273 let large = tempfile::tempdir().unwrap();
274 let _written =
275 generate_corpus(small.path(), &CorpusSpec::new(8)).unwrap();
276 let _written =
277 generate_corpus(large.path(), &CorpusSpec::new(64)).unwrap();
278
279 for i in 0..8 {
280 let name = format!("page-{i:04}.md");
281 assert_eq!(
282 fs::read(small.path().join(&name)).unwrap(),
283 fs::read(large.path().join(&name)).unwrap(),
284 "{name} differs between corpus sizes"
285 );
286 }
287 }
288
289 #[test]
290 fn different_seeds_produce_different_corpora() {
291 let a = tempfile::tempdir().unwrap();
292 let b = tempfile::tempdir().unwrap();
293 let _written = generate_corpus(a.path(), &CorpusSpec::new(8)).unwrap();
294 let _written =
295 generate_corpus(b.path(), &CorpusSpec::new(8).with_seed(1))
296 .unwrap();
297
298 let name = "page-0000.md";
299 assert_ne!(
300 fs::read(a.path().join(name)).unwrap(),
301 fs::read(b.path().join(name)).unwrap()
302 );
303 }
304
305 #[test]
306 fn pages_carry_frontmatter_and_structure() {
307 let dir = tempfile::tempdir().unwrap();
308 let _written =
309 generate_corpus(dir.path(), &CorpusSpec::new(1)).unwrap();
310 let page = fs::read_to_string(dir.path().join("page-0000.md")).unwrap();
311
312 assert!(page.starts_with("---\n"), "missing front matter");
313 assert!(page.contains("permalink: \"https://example.com/page-0\""));
314 assert!(page.contains("tags: \""), "taxonomy needs tags");
315 assert!(page.contains("## Section 0"), "missing heading");
316 assert!(page.contains("### Detail"), "missing subheading");
317 }
318
319 #[test]
320 fn zero_seed_does_not_collapse_the_generator() {
321 let dir = tempfile::tempdir().unwrap();
324 let _written =
325 generate_corpus(dir.path(), &CorpusSpec::new(1).with_seed(0))
326 .unwrap();
327 let page = fs::read_to_string(dir.path().join("page-0000.md")).unwrap();
328
329 let body: Vec<&str> = page
330 .split("---\n")
331 .nth(2)
332 .unwrap_or_default()
333 .split_whitespace()
334 .collect();
335 let distinct: std::collections::BTreeSet<_> = body.iter().collect();
336 assert!(
337 distinct.len() > 5,
338 "seed 0 collapsed the generator: {} distinct words",
339 distinct.len()
340 );
341 }
342}