interpretthis 0.1.0

Sandboxed Python AST interpreter for untrusted and LLM-generated code
Documentation
// Copyright 2026 Thomas Santerre and Moderately AI Inc.
//
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Build script: discover `tests/integration/parity_corpus/**/*.py` snippets at
//! compile time and emit one `#[test]` per file into a generated Rust source
//! that `tests/integration/parity_corpus_runner.rs` includes.
//!
//! Compile-time discovery (vs. runtime walking + `libtest-mimic`) is chosen
//! because it lets each `.py` snippet appear as a distinct
//! `parity::<topic>::<name>` entry in `cargo nextest run` output — the
//! standard `#[test]` mechanism — without pulling a new dependency into the
//! crate. The trade-off is that adding or removing a snippet triggers a
//! rebuild of the test binary; the `cargo:rerun-if-changed` directives below
//! make that detection accurate so no manual touch is needed.

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");

    // Re-run when the corpus tree changes. We watch the root explicitly so
    // newly-added topic directories trigger regeneration; the `.py` watchers
    // are added per-file inside the walk.
    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";

/// Nested directory tree of corpus topics, each leaf carrying the absolute
/// paths of `.py` files it contains.
#[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(())
}

/// Reject names that wouldn't generate valid Rust identifiers. Topic
/// directories and snippet file stems share the same snake_case-and-digits
/// constraint.
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(())
}

/// Wrap a snake_case identifier with `r#` when it collides with a Rust
/// keyword (`match`, `type`, `for`, `if`, …). The corpus directory layout
/// mirrors Python concepts and several of those concepts share names with
/// Rust keywords; raw identifiers let those names round-trip cleanly into
/// generated module / fn declarations.
fn rustify_ident(name: &str) -> BuildResult<String> {
    // Rust 2024 reserved keywords. The list is the strict / reserved set
    // pulled from the Rust reference; raw identifiers cannot wrap `self`,
    // `Self`, `super`, `crate`, or `_`, so those four are rejected here.
    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()) }
}