use std::{
collections::BTreeMap,
env,
error::Error,
fmt::Write as _,
fs,
path::{Path, PathBuf},
};
type BuildResult<T = ()> = Result<T, Box<dyn Error>>;
fn main() -> BuildResult {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
let corpus_root = manifest_dir.join("tests/integration/parity_corpus");
println!("cargo:rerun-if-changed=tests/integration/parity_corpus");
println!("cargo:rerun-if-changed=build.rs");
let mut tree = TopicTree::default();
if corpus_root.is_dir() {
walk(&corpus_root, &corpus_root, &mut tree)?;
}
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
let generated = out_dir.join("parity_corpus_generated.rs");
let mut output = String::new();
output.push_str(GENERATED_HEADER);
tree.emit(&mut output, 0)?;
fs::write(&generated, output)?;
Ok(())
}
const GENERATED_HEADER: &str = "// AUTO-GENERATED by build.rs. Do not edit by hand.\n//\n// Each `#[test]` here corresponds to one `.py` snippet under\n// `tests/integration/parity_corpus/`. The snippet's bytes are embedded via\n// `include_str!` so the test binary is self-contained.\n\n";
#[derive(Default)]
struct TopicTree {
children: BTreeMap<String, Self>,
snippets: BTreeMap<String, PathBuf>,
}
impl TopicTree {
fn emit(&self, out: &mut String, depth: usize) -> BuildResult {
let indent = " ".repeat(depth);
for (snippet_name, snippet_path) in &self.snippets {
let path_literal = snippet_path.to_string_lossy().replace('\\', "/");
let fn_ident = rustify_ident(snippet_name)?;
writeln!(out, "{indent}#[test]")?;
writeln!(out, "{indent}fn {fn_ident}() {{")?;
writeln!(out, "{indent} let code = include_str!(\"{path_literal}\");")?;
writeln!(
out,
"{indent} crate::parity_corpus_runner::run_parity_test(module_path!(), \"{snippet_name}\", code);"
)?;
writeln!(out, "{indent}}}")?;
writeln!(out)?;
}
for (child_name, child) in &self.children {
let module_ident = rustify_ident(child_name)?;
writeln!(out, "{indent}pub mod {module_ident} {{")?;
child.emit(out, depth + 1)?;
writeln!(out, "{indent}}}")?;
writeln!(out)?;
}
Ok(())
}
}
fn walk(root: &Path, current: &Path, tree: &mut TopicTree) -> BuildResult {
let read = fs::read_dir(current)?;
let mut entries: Vec<PathBuf> = read.filter_map(Result::ok).map(|entry| entry.path()).collect();
entries.sort();
for path in entries {
if path.is_dir() {
let name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("non-utf8 directory name at {}", path.display()))?;
check_ident(name, "topic directory")?;
let child = tree.children.entry(name.to_string()).or_default();
walk(root, &path, child)?;
continue;
}
let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
continue;
};
if ext != "py" {
continue;
}
let rel = path
.strip_prefix(root)
.map_err(|err| format!("path {} not under root: {err}", path.display()))?;
println!(
"cargo:rerun-if-changed=tests/integration/parity_corpus/{}",
rel.to_string_lossy().replace('\\', "/")
);
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| format!("non-utf8 file stem at {}", path.display()))?;
check_ident(stem, "snippet file")?;
let prior = tree.snippets.insert(stem.to_string(), path.clone());
if prior.is_some() {
return Err(format!(
"duplicate snippet name `{stem}` in {}; rename one of the files",
current.display()
)
.into());
}
}
Ok(())
}
fn check_ident(name: &str, kind: &str) -> BuildResult {
let valid_chars =
name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if !valid_chars {
return Err(format!("{kind} `{name}` is not snake_case; rename it").into());
}
if name.starts_with(|c: char| c.is_ascii_digit()) {
return Err(format!("{kind} `{name}` starts with a digit; Rust identifiers cannot").into());
}
Ok(())
}
fn rustify_ident(name: &str) -> BuildResult<String> {
const KEYWORDS: &[&str] = &[
"as", "async", "await", "break", "const", "continue", "do", "dyn", "else", "enum",
"extern", "false", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "match", "mod",
"move", "mut", "pub", "ref", "return", "static", "struct", "trait", "true", "try", "type",
"unsafe", "use", "where", "while", "yield", "abstract", "become", "box", "final", "macro",
"override", "priv", "typeof", "unsized", "virtual",
];
const BANNED: &[&str] = &["self", "Self", "super", "crate", "_"];
if BANNED.contains(&name) {
return Err(format!(
"corpus path component `{name}` collides with a Rust identifier that cannot be raw-escaped; rename it",
)
.into());
}
if KEYWORDS.contains(&name) { Ok(format!("r#{name}")) } else { Ok(name.to_string()) }
}