#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};
use tempfile::tempdir;
fn collect_rs_files(dir: &Path) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(collect_rs_files(&path));
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
files
}
fn test_slugify(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() {
c.to_lowercase().next().unwrap_or(c)
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn generate_pages(content_dir: &Path, n: usize) {
fs::create_dir_all(content_dir).unwrap();
for i in 0..n {
let content = format!(
"---\ntitle: \"Page {i}\"\ndate: \"2026-04-18T00:00:00Z\"\n\
description: \"Test page {i} for performance benchmarking\"\n\
keywords: \"test, perf, page{i}\"\nlang: \"en\"\n---\n\n\
# Page {i}\n\nThis is page {i} with enough content to be realistic.\n\n\
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do\n\
eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\n\
## Section A\n\nMore content for page {i}.\n\n\
## Section B\n\n- Item 1\n- Item 2\n- Item 3\n"
);
fs::write(content_dir.join(format!("page-{i}.md")), content).unwrap();
}
for required in ["index.md", "404.md"] {
if !content_dir.join(required).exists() {
let name = required.replace(".md", "");
fs::write(
content_dir.join(required),
format!(
"---\ntitle: \"{name}\"\ndate: \"2026-04-18T00:00:00Z\"\n\
description: \"Required page\"\n---\n\n# {name}\n\nContent.\n"
),
)
.unwrap();
}
}
}
fn generate_html_pages(site_dir: &Path, n: usize) {
fs::create_dir_all(site_dir).unwrap();
for i in 0..n {
let html = format!(
"<html lang=\"en\"><head><meta charset=\"utf-8\">\
<title>Page {i}</title>\
<meta name=\"description\" content=\"Page {i} description\">\
</head><body><main><h1>Page {i}</h1>\
<p>Content for page {i}.</p></main></body></html>"
);
let page_dir = site_dir.join(format!("page-{i}"));
fs::create_dir_all(&page_dir).unwrap();
fs::write(page_dir.join("index.html"), html).unwrap();
}
}
#[test]
fn compile_100_pages_under_5s() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
let build_dir = dir.path().join("build");
let site_dir = dir.path().join("public");
let template_dir = dir.path().join("templates");
fs::create_dir_all(&build_dir).unwrap();
fs::create_dir_all(&site_dir).unwrap();
fs::create_dir_all(&template_dir).unwrap();
generate_pages(&content_dir, 100);
let start = Instant::now();
let result =
ssg::compile_site(&build_dir, &content_dir, &site_dir, &template_dir);
let elapsed = start.elapsed();
println!(
" ⚡ 100 pages: {elapsed:.2?} ({})",
if result.is_ok() {
"ok"
} else {
"compile err — expected in synthetic env"
}
);
assert!(
elapsed < Duration::from_secs(5),
"100-page compilation took {elapsed:.2?} — exceeds 5s budget"
);
}
#[test]
fn search_index_50_pages_under_50ms() {
let dir = tempdir().unwrap();
let site_dir = dir.path().join("site");
generate_html_pages(&site_dir, 50);
let start = Instant::now();
let ctx = ssg::plugin::PluginContext::new(
dir.path(),
dir.path(),
&site_dir,
dir.path(),
);
let search = ssg::search::SearchPlugin;
let _ = ssg::plugin::Plugin::after_compile(&search, &ctx);
let elapsed = start.elapsed();
println!(" ⚡ 50-page search index: {elapsed:.2?}");
assert!(
elapsed < Duration::from_millis(500),
"Search indexing took {elapsed:.2?} — exceeds 500ms budget"
);
}
#[test]
fn cache_fingerprint_1000_files_under_50ms() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
fs::create_dir_all(&content_dir).unwrap();
for i in 0..1000 {
fs::write(
content_dir.join(format!("file-{i}.md")),
format!("Content for file {i}\n"),
)
.unwrap();
}
let start = Instant::now();
let mut cache =
ssg::cache::BuildCache::new(&dir.path().join(".ssg-cache.json"));
let _ = cache.update(&content_dir);
let elapsed = start.elapsed();
println!(" ⚡ 1000-file cache fingerprint: {elapsed:.2?}");
assert!(
elapsed < Duration::from_millis(500),
"Cache fingerprinting took {elapsed:.2?} — exceeds 500ms budget"
);
}
#[test]
fn stream_hash_1000_files_under_50ms() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
fs::create_dir_all(&content_dir).unwrap();
for i in 0..1000 {
fs::write(
content_dir.join(format!("file-{i}.txt")),
format!("Stream hash content {i}\n"),
)
.unwrap();
}
let start = Instant::now();
for i in 0..1000 {
let path = content_dir.join(format!("file-{i}.txt"));
let hash = ssg::stream::stream_hash(&path).unwrap();
assert!(!hash.is_empty());
}
let elapsed = start.elapsed();
println!(" ⚡ 1000-file stream hash: {elapsed:.2?}");
assert!(
elapsed < Duration::from_millis(500),
"Streaming hash took {elapsed:.2?} — exceeds 500ms budget"
);
}
#[test]
fn depgraph_10k_entries_under_50ms() {
let start = Instant::now();
let mut graph = ssg::depgraph::DepGraph::new();
for i in 0..10_000 {
graph.add_dep(
Path::new(&format!("page-{i}.html")),
Path::new("templates/base.html"),
);
}
let changed = vec![std::path::PathBuf::from("templates/base.html")];
let invalidated = graph.invalidated_pages(&changed);
let elapsed = start.elapsed();
assert!(
invalidated.len() >= 10_000,
"Expected >= 10000, got {}",
invalidated.len()
);
println!(" ⚡ 10K-entry depgraph invalidation: {elapsed:.2?}");
assert!(
elapsed < Duration::from_millis(500),
"DepGraph invalidation took {elapsed:.2?} — exceeds 500ms budget"
);
}
#[test]
fn memory_budget_calculation_instant() {
let start = Instant::now();
for _ in 0..100_000 {
let _ = ssg::streaming::MemoryBudget::from_mb(512);
}
let elapsed = start.elapsed();
println!(" ⚡ 100K budget calculations: {elapsed:.2?}");
assert!(
elapsed < Duration::from_millis(50),
"Budget calculation took {elapsed:.2?} — exceeds 50ms budget"
);
}
#[test]
fn seo_plugin_50_pages_under_50ms() {
let dir = tempdir().unwrap();
let site_dir = dir.path().join("site");
generate_html_pages(&site_dir, 50);
let start = Instant::now();
let ctx = ssg::plugin::PluginContext::new(
dir.path(),
dir.path(),
&site_dir,
dir.path(),
);
let seo = ssg::seo::SeoPlugin;
let _ = ssg::plugin::Plugin::after_compile(&seo, &ctx);
let elapsed = start.elapsed();
println!(" ⚡ 50-page SEO injection: {elapsed:.2?}");
assert!(
elapsed < Duration::from_millis(500),
"SEO plugin took {elapsed:.2?} — exceeds 500ms budget"
);
}
#[test]
fn accessibility_check_50_pages_under_50ms() {
let dir = tempdir().unwrap();
let site_dir = dir.path().join("site");
generate_html_pages(&site_dir, 50);
let start = Instant::now();
let ctx = ssg::plugin::PluginContext::new(
dir.path(),
dir.path(),
&site_dir,
dir.path(),
);
let a11y = ssg::accessibility::AccessibilityPlugin;
let _ = ssg::plugin::Plugin::after_compile(&a11y, &ctx);
let elapsed = start.elapsed();
println!(" ⚡ 50-page a11y check: {elapsed:.2?}");
assert!(
elapsed < Duration::from_millis(500),
"Accessibility check took {elapsed:.2?} — exceeds 500ms budget"
);
}
#[test]
fn cache_survives_corruption() {
let dir = tempdir().unwrap();
let cache_path = dir.path().join(".ssg-cache.json");
fs::write(&cache_path, "{{{{not json!!!!").unwrap();
let cache = ssg::cache::BuildCache::new(&cache_path);
assert!(cache.save().is_ok());
}
#[test]
fn cache_deterministic_across_runs() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
fs::create_dir_all(&content_dir).unwrap();
fs::write(content_dir.join("test.md"), "Hello world").unwrap();
let cache_path = dir.path().join(".ssg-cache.json");
let mut c1 = ssg::cache::BuildCache::new(&cache_path);
let _ = c1.update(&content_dir);
c1.save().unwrap();
let snap1 = fs::read_to_string(&cache_path).unwrap();
let mut c2 = ssg::cache::BuildCache::new(&cache_path);
let _ = c2.update(&content_dir);
c2.save().unwrap();
let snap2 = fs::read_to_string(&cache_path).unwrap();
assert_eq!(snap1, snap2, "Cache must be deterministic across runs");
}
#[test]
fn cache_detects_content_change() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
fs::create_dir_all(&content_dir).unwrap();
fs::write(content_dir.join("page.md"), "Version 1").unwrap();
let cache_path = dir.path().join(".ssg-cache.json");
let mut cache = ssg::cache::BuildCache::new(&cache_path);
let _ = cache.update(&content_dir);
cache.save().unwrap();
fs::write(content_dir.join("page.md"), "Version 2").unwrap();
let cache2 = ssg::cache::BuildCache::load(&cache_path)
.unwrap_or_else(|_| ssg::cache::BuildCache::new(&cache_path));
let changed = cache2.changed_files(&content_dir).unwrap_or_default();
assert!(
!changed.is_empty(),
"Cache must detect content modification"
);
}
#[test]
fn cache_handles_deleted_files() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("content");
fs::create_dir_all(&content_dir).unwrap();
fs::write(content_dir.join("keep.md"), "Keep").unwrap();
fs::write(content_dir.join("delete.md"), "Delete").unwrap();
let cache_path = dir.path().join(".ssg-cache.json");
let mut cache = ssg::cache::BuildCache::new(&cache_path);
let _ = cache.update(&content_dir);
cache.save().unwrap();
fs::remove_file(content_dir.join("delete.md")).unwrap();
let cache2 = ssg::cache::BuildCache::load(&cache_path)
.unwrap_or_else(|_| ssg::cache::BuildCache::new(&cache_path));
let result = cache2.changed_files(&content_dir);
assert!(result.is_ok(), "Cache must handle deleted files gracefully");
}
#[test]
fn cache_empty_dir_is_noop() {
let dir = tempdir().unwrap();
let content_dir = dir.path().join("empty");
fs::create_dir_all(&content_dir).unwrap();
let cache_path = dir.path().join(".ssg-cache.json");
let mut cache = ssg::cache::BuildCache::new(&cache_path);
let result = cache.update(&content_dir);
assert!(result.is_ok(), "Empty directory must not cause errors");
}
#[test]
fn depgraph_survives_corruption() {
let dir = tempdir().unwrap();
let site_dir = dir.path().join("site");
fs::create_dir_all(&site_dir).unwrap();
fs::write(site_dir.join(".ssg-deps.json"), "NOT JSON!!!").unwrap();
let graph = ssg::depgraph::DepGraph::load(&site_dir);
assert_eq!(graph.page_count(), 0);
}
#[test]
fn depgraph_save_load_roundtrip() {
let dir = tempdir().unwrap();
let site_dir = dir.path().join("site");
fs::create_dir_all(&site_dir).unwrap();
let mut graph = ssg::depgraph::DepGraph::new();
graph.add_dep(Path::new("a.html"), Path::new("base.html"));
graph.add_dep(Path::new("b.html"), Path::new("base.html"));
graph.save(&site_dir).unwrap();
let loaded = ssg::depgraph::DepGraph::load(&site_dir);
assert_eq!(loaded.page_count(), 2);
let changed = vec![std::path::PathBuf::from("base.html")];
let inv = loaded.invalidated_pages(&changed);
assert!(
inv.len() >= 2,
"Round-tripped graph must preserve deps, got {}",
inv.len()
);
}
#[test]
fn all_rust_files_have_spdx_header() {
let src_dir = Path::new("src");
if !src_dir.exists() {
return; }
let rust_files = collect_rs_files(src_dir);
let mut missing = Vec::new();
for path in &rust_files {
let content = fs::read_to_string(path).unwrap_or_default();
if !content.contains("SPDX-License-Identifier") {
missing.push(path.display().to_string());
}
}
assert!(
missing.is_empty(),
"Rust files missing SPDX-License-Identifier header:\n {}",
missing.join("\n ")
);
}
#[test]
fn all_rust_files_have_correct_licence() {
let src_dir = Path::new("src");
if !src_dir.exists() {
return;
}
let rust_files = collect_rs_files(src_dir);
let mut wrong = Vec::new();
for path in &rust_files {
let content = fs::read_to_string(path).unwrap_or_default();
if content.contains("SPDX-License-Identifier")
&& !content.contains("Apache-2.0 OR MIT")
{
wrong.push(path.display().to_string());
}
}
assert!(
wrong.is_empty(),
"Rust files with wrong licence (expected Apache-2.0 OR MIT):\n {}",
wrong.join("\n ")
);
}
#[test]
fn root_licence_files_exist() {
assert!(
Path::new("LICENSE-MIT").exists(),
"LICENSE-MIT file missing from repo root"
);
assert!(
Path::new("LICENSE-APACHE").exists(),
"LICENSE-APACHE file missing from repo root"
);
}
#[test]
fn cargo_toml_licence_field_correct() {
let cargo = fs::read_to_string("Cargo.toml").unwrap_or_default();
assert!(
cargo.contains("license = \"MIT OR Apache-2.0\""),
"Cargo.toml must declare license = \"MIT OR Apache-2.0\""
);
}
#[test]
fn slug_generation_ascii() {
assert_eq!(test_slugify("Hello World"), "hello-world");
assert_eq!(test_slugify("Hello World"), "hello-world");
assert_eq!(test_slugify(" spaces "), "spaces");
}
#[test]
fn slug_generation_unicode() {
let cjk = test_slugify("日本語テスト");
assert!(!cjk.is_empty(), "CJK slug must not be empty");
assert!(
!cjk.starts_with('-') && !cjk.ends_with('-'),
"Slug must not start/end with hyphen: {cjk}"
);
let accent = test_slugify("café résumé naïve");
assert!(!accent.is_empty(), "Accented slug must not be empty");
}
#[test]
fn slug_generation_rtl() {
let arabic = test_slugify("مرحبا بالعالم");
assert!(
!arabic.contains("--"),
"RTL slug must not have consecutive hyphens: {arabic}"
);
let hebrew = test_slugify("שלום עולם");
assert!(
!hebrew.contains("--"),
"Hebrew slug must not have consecutive hyphens: {hebrew}"
);
}
#[test]
fn markdown_rendering_unicode_safe() {
let inputs = [
"# 日本語見出し\n\nこんにちは世界",
"# عنوان عربي\n\nمحتوى عربي",
"# 🦀 Rust Crab\n\nEmoji content 🎉",
"# Ñoño\n\nSpanish accents: á é í ó ú",
"# Ελληνικά\n\nGreek content: αβγδ",
];
for input in &inputs {
let result = std::panic::catch_unwind(|| {
let _ = ssg::markdown_ext::expand_gfm(input);
});
assert!(
result.is_ok(),
"Markdown rendering panicked on Unicode input: {input}"
);
}
}
#[test]
fn hreflang_tag_format() {
let tag = "<link rel=\"alternate\" hreflang=\"en\" href=\"https://example.com/en/\">";
assert!(tag.contains("hreflang=\"en\""));
assert!(tag.contains("rel=\"alternate\""));
assert!(tag.contains("href=\""));
let valid_codes = [
"en", "fr", "de", "es", "it", "pt", "ja", "zh", "ko", "ar", "he", "ru",
"nl", "pl", "sv", "cs", "th", "vi", "id", "uk", "hi", "bn", "ha", "yo",
"tl", "ro", "zh-tw",
];
for code in &valid_codes {
assert!(
code.len() >= 2 && code.len() <= 5,
"Invalid BCP 47 code length: {code}"
);
assert!(
code.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
"Invalid BCP 47 characters: {code}"
);
}
}
#[test]
fn reading_time_multilingual() {
let _english = "word ".repeat(400); let time_en = ssg::stream::STREAM_BUFFER_SIZE; assert!(time_en > 0, "Buffer size must be positive");
let cjk = "日本語のテキスト ".repeat(100);
let result = std::panic::catch_unwind(|| {
let _ = ssg::markdown_ext::expand_gfm(&cjk);
});
assert!(result.is_ok(), "CJK processing must not panic");
}
#[test]
fn fused_transforms_idempotent() {
let dir = tempdir().unwrap();
let site_dir = dir.path().join("site");
generate_html_pages(&site_dir, 5);
let ctx = ssg::plugin::PluginContext::new(
dir.path(),
dir.path(),
&site_dir,
dir.path(),
);
let mut plugins = ssg::plugin::PluginManager::new();
plugins.register(ssg::postprocess::HtmlFixPlugin);
plugins.register(ssg::seo::SeoPlugin);
plugins.run_after_compile(&ctx).unwrap();
plugins.run_fused_transforms(&ctx).unwrap();
let html1 = fs::read_to_string(site_dir.join("page-0/index.html")).unwrap();
plugins.run_after_compile(&ctx).unwrap();
plugins.run_fused_transforms(&ctx).unwrap();
let html2 = fs::read_to_string(site_dir.join("page-0/index.html")).unwrap();
assert_eq!(html1, html2, "Fused transforms must be idempotent");
}
#[test]
fn plugin_order_deterministic() {
let mut plugins = ssg::plugin::PluginManager::new();
plugins.register(ssg::seo::SeoPlugin);
plugins.register(ssg::search::SearchPlugin);
plugins.register(ssg::postprocess::HtmlFixPlugin);
let names: Vec<&str> = plugins.names();
assert_eq!(names, vec!["seo", "search", "html-fix"]);
let mut plugins2 = ssg::plugin::PluginManager::new();
plugins2.register(ssg::seo::SeoPlugin);
plugins2.register(ssg::search::SearchPlugin);
plugins2.register(ssg::postprocess::HtmlFixPlugin);
let names2: Vec<&str> = plugins2.names();
assert_eq!(names, names2, "Plugin order must be deterministic");
}