//! Integration tests — compile every `.rpg` sample and validate stdout //! against the accompanying `.rpg.golden` file. //! //! Each test follows the same three-step pipeline: //! 1. `rust-langrpg -o .rpg` — compile //! 2. `` — execute //! 3. compare stdout with `.rpg.golden` //! //! # Adding a new sample //! //! Drop the `.rpg` source and a `.rpg.golden` file into `samples/` and add a //! one-line `#[test]` that calls `run_sample("your_file.rpg")`. use std::{fs, path::PathBuf, process::Command}; /// Path to the freshly-built compiler binary, injected by Cargo. const BIN: &str = env!("CARGO_BIN_EXE_rust-langrpg"); /// Absolute path to the `samples/` directory, resolved at compile time. const SAMPLES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/samples"); // ───────────────────────────────────────────────────────────────────────────── // Shared driver // ───────────────────────────────────────────────────────────────────────────── /// Compile `sample_name` from `samples/`, run the resulting binary, and assert /// that its stdout matches `samples/.golden` line-for-line. /// /// Temp binaries are written to the OS temp directory with a name derived from /// the sample so parallel test execution doesn't cause collisions. fn run_sample(sample_name: &str) { let src = PathBuf::from(SAMPLES_DIR).join(sample_name); let golden_path = PathBuf::from(SAMPLES_DIR).join(format!("{}.golden", sample_name)); assert!( src.exists(), "source file not found: {}", src.display() ); assert!( golden_path.exists(), "golden file not found: {}\n\ Create it with the expected stdout to enable this test.", golden_path.display() ); // ── 1. Compile ──────────────────────────────────────────────────────────── // Sanitise the stem so it is safe to embed in a file name (e.g. "for.rpg" // → "for_rpg") and prefix with a fixed string to make it easy to identify. let safe_stem = sample_name.replace('.', "_").replace(std::path::MAIN_SEPARATOR, "_"); let exe = std::env::temp_dir().join(format!("rpg_sample_test_{}.out", safe_stem)); let compile_out = Command::new(BIN) .args(["-o", exe.to_str().unwrap(), src.to_str().unwrap()]) .output() .unwrap_or_else(|e| panic!("failed to spawn compiler for '{}': {e}", sample_name)); assert!( compile_out.status.success(), "compilation of '{}' failed (exit {})\nstderr:\n{}", sample_name, compile_out.status, String::from_utf8_lossy(&compile_out.stderr), ); assert!( exe.exists(), "compiler exited 0 for '{}' but no executable was produced at '{}'", sample_name, exe.display(), ); // ── 2. Execute ──────────────────────────────────────────────────────────── let run_out = Command::new(&exe) .output() .unwrap_or_else(|e| panic!("failed to run compiled binary for '{}': {e}", sample_name)); assert!( run_out.status.success(), "binary for '{}' exited non-zero ({})\nstdout:\n{}\nstderr:\n{}", sample_name, run_out.status, String::from_utf8_lossy(&run_out.stdout), String::from_utf8_lossy(&run_out.stderr), ); // ── 3. Compare against golden ───────────────────────────────────────────── let actual_raw = String::from_utf8_lossy(&run_out.stdout); let expected_raw = fs::read_to_string(&golden_path).unwrap_or_else(|e| { panic!("could not read golden file '{}': {e}", golden_path.display()) }); // Normalise both sides: trim trailing whitespace from every line and ignore // a trailing blank line, so golden files don't need a precise final newline. let normalise = |s: &str| -> Vec { s.lines().map(|l| l.trim_end().to_string()).collect() }; let actual_lines = normalise(&actual_raw); let expected_lines = normalise(&expected_raw); if actual_lines != expected_lines { let mut msg = format!( "stdout mismatch for '{}'\n\n\ ── expected ({} line{}) ──────────────────────\n", sample_name, expected_lines.len(), if expected_lines.len() == 1 { "" } else { "s" }, ); for line in &expected_lines { msg.push_str(" "); msg.push_str(line); msg.push('\n'); } msg.push_str(&format!( "\n── actual ({} line{}) ────────────────────────\n", actual_lines.len(), if actual_lines.len() == 1 { "" } else { "s" }, )); for line in &actual_lines { msg.push_str(" "); msg.push_str(line); msg.push('\n'); } // Highlight the first diverging line to make failures easy to spot. for (i, (exp, act)) in expected_lines.iter().zip(actual_lines.iter()).enumerate() { if exp != act { msg.push_str(&format!( "\nfirst difference at line {}:\n expected: {:?}\n actual: {:?}\n", i + 1, exp, act, )); break; } } if actual_lines.len() != expected_lines.len() { msg.push_str(&format!( "\nline count differs: expected {}, got {}\n", expected_lines.len(), actual_lines.len(), )); } panic!("{}", msg); } } // ───────────────────────────────────────────────────────────────────────────── // One #[test] per sample // ───────────────────────────────────────────────────────────────────────────── #[test] fn sample_hello() { run_sample("hello.rpg"); } #[test] fn sample_for() { run_sample("for.rpg"); } #[test] fn sample_fib() { run_sample("fib.rpg"); }