//! Integration tests — compile every `.rpg` sample and validate stdout. //! //! Tests are discovered automatically: every file in `samples/` whose name //! ends with `.rpg` becomes a test case. Companion files control behaviour: //! //! | file | purpose | //! |-----------------------|--------------------------------------------| //! | `.rpg.stdout` | expected stdout (required to run the test) | //! | `.rpg.stdin` | bytes piped to stdin (optional) | //! //! # Adding a new sample //! //! 1. Drop `samples/.rpg` into the directory. //! 2. Create `samples/.rpg.stdout` with the expected output. //! 3. Optionally create `samples/.rpg.stdin` with any required input. //! //! No changes to this file are needed. use std::{ fs, io::Write as _, path::PathBuf, process::{Command, Stdio}, }; /// 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"); // ───────────────────────────────────────────────────────────────────────────── // Discovery // ───────────────────────────────────────────────────────────────────────────── /// Returns a sorted list of `.rpg` file names found in `SAMPLES_DIR`. fn discover_samples() -> Vec { let dir = fs::read_dir(SAMPLES_DIR) .unwrap_or_else(|e| panic!("cannot read samples dir '{}': {e}", SAMPLES_DIR)); let mut names: Vec = dir .filter_map(|entry| { let entry = entry.expect("dir entry error"); let name = entry.file_name().into_string().expect("non-UTF-8 filename"); if name.ends_with(".rpg") && !name.contains('.') == false { // Accept only plain `.rpg` (no extra dots apart from the extension). let stem = &name[..name.len() - 4]; // strip ".rpg" if !stem.contains('.') { return Some(name); } } None }) .collect(); names.sort(); names } // ───────────────────────────────────────────────────────────────────────────── // Driver // ───────────────────────────────────────────────────────────────────────────── /// Compile and run one sample, returning `Err(message)` on any failure. fn run_one(sample_name: &str) -> Result<(), String> { let base = PathBuf::from(SAMPLES_DIR); let src = base.join(sample_name); let stdout_path = base.join(format!("{}.stdout", sample_name)); let stdin_path = base.join(format!("{}.stdin", sample_name)); // Skip samples that have no golden file yet — they are works in progress. if !stdout_path.exists() { return Err(format!( "SKIP '{}': no expected-output file '{}'", sample_name, stdout_path.display(), )); } let stdin_bytes: Vec = if stdin_path.exists() { fs::read(&stdin_path) .map_err(|e| format!("cannot read stdin file '{}': {e}", stdin_path.display()))? } else { Vec::new() }; // ── 1. Compile ──────────────────────────────────────────────────────────── 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() .map_err(|e| format!("failed to spawn compiler for '{}': {e}", sample_name))?; if !compile_out.status.success() { return Err(format!( "compilation of '{}' failed (exit {})\nstderr:\n{}", sample_name, compile_out.status, String::from_utf8_lossy(&compile_out.stderr), )); } if !exe.exists() { return Err(format!( "compiler exited 0 for '{}' but produced no executable at '{}'", sample_name, exe.display(), )); } // ── 2. Execute ──────────────────────────────────────────────────────────── let run_out = if stdin_bytes.is_empty() { Command::new(&exe) .stdin(Stdio::null()) .output() .map_err(|e| format!("failed to run '{}': {e}", sample_name))? } else { let mut child = Command::new(&exe) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("failed to spawn '{}': {e}", sample_name))?; child .stdin .take() .expect("stdin was piped") .write_all(&stdin_bytes) .map_err(|e| format!("failed to write stdin for '{}': {e}", sample_name))?; child .wait_with_output() .map_err(|e| format!("failed to wait on '{}': {e}", sample_name))? }; if !run_out.status.success() { return Err(format!( "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 expected output ─────────────────────────────────── let actual_raw = String::from_utf8_lossy(&run_out.stdout); let expected_raw = fs::read_to_string(&stdout_path) .map_err(|e| format!("could not read '{}': {e}", stdout_path.display()))?; // Trim trailing whitespace per line; ignore a lone trailing blank line so // `.rpg.stdout` files don't need a precise final newline. let normalise = |s: &str| -> Vec { let mut lines: Vec = s.lines().map(|l| l.trim_end().to_string()).collect(); if lines.last().map(|l| l.is_empty()).unwrap_or(false) { lines.pop(); } lines }; let actual_lines = normalise(&actual_raw); let expected_lines = normalise(&expected_raw); if actual_lines == expected_lines { return Ok(()); } // ── Build a readable diff-style failure message ─────────────────────────── 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'); } 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: expected {}, got {}\n", expected_lines.len(), actual_lines.len(), )); } Err(msg) } // ───────────────────────────────────────────────────────────────────────────── // Single test entry-point // ───────────────────────────────────────────────────────────────────────────── #[test] fn samples() { let names = discover_samples(); assert!( !names.is_empty(), "no .rpg files found in '{}'", SAMPLES_DIR, ); let mut failures: Vec = Vec::new(); let mut skipped: Vec = Vec::new(); for name in &names { eprint!(" sample '{}' … ", name); match run_one(name) { Ok(()) => eprintln!("ok"), Err(msg) if msg.starts_with("SKIP") => { eprintln!("skipped"); skipped.push(msg); } Err(msg) => { eprintln!("FAILED"); failures.push(msg); } } } if !skipped.is_empty() { eprintln!("\n{} sample(s) skipped (no .rpg.stdout file):", skipped.len()); for s in &skipped { eprintln!(" {}", s); } } if failures.is_empty() { return; } let mut report = format!( "\n{} of {} sample(s) failed:\n", failures.len(), names.len(), ); for (i, f) in failures.iter().enumerate() { report.push_str(&format!("\n── failure {} ──────────────────────────────────────────\n{}\n", i + 1, f)); } panic!("{}", report); }