use crate::cli::args::{TestArgs, TestEngine};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Expect {
Stdout,
Stderr,
}
#[derive(Debug, Clone)]
pub struct TestCase {
pub run_args: Vec<String>,
pub expected: String,
pub line: usize,
pub expect: Expect,
}
pub fn parse_cases(src: &str) -> Vec<TestCase> {
let mut cases = Vec::new();
let mut pending: Option<(Vec<String>, usize)> = None;
for (i, raw) in src.lines().enumerate() {
let line = raw.trim();
if let Some(rest) = line.strip_prefix("-- run:") {
let args = rest.split_whitespace().map(str::to_string).collect();
pending = Some((args, i + 1));
} else if let (Some(rest), Some((args, ln))) =
(line.strip_prefix("-- out:"), pending.as_ref())
{
cases.push(TestCase {
run_args: args.clone(),
expected: rest.trim().to_string(),
line: *ln,
expect: Expect::Stdout,
});
pending = None;
} else if let (Some(rest), Some((args, ln))) =
(line.strip_prefix("-- err:"), pending.as_ref())
{
cases.push(TestCase {
run_args: args.clone(),
expected: rest.trim().to_string(),
line: *ln,
expect: Expect::Stderr,
});
pending = None;
}
}
cases
}
pub fn parse_engine_skips(src: &str) -> std::collections::HashSet<String> {
let mut skips = std::collections::HashSet::new();
for raw in src.lines() {
let line = raw.trim();
if let Some(rest) = line.strip_prefix("-- engine-skip:") {
for token in rest.split_whitespace() {
skips.insert(token.to_string());
}
}
}
skips
}
#[derive(Clone, Copy, Debug)]
struct EngineDesc {
name: &'static str,
flag: &'static str,
}
const VM: EngineDesc = EngineDesc {
name: "vm",
flag: "--vm",
};
const JIT: EngineDesc = EngineDesc {
name: "jit",
flag: "--jit",
};
fn engines_for(sel: TestEngine) -> Vec<EngineDesc> {
match sel {
TestEngine::Vm => vec![VM],
TestEngine::Jit => vec![JIT],
TestEngine::All => vec![VM, JIT],
}
}
fn collect_ilo_recursive(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let p = entry.path();
if p.is_dir() {
collect_ilo_recursive(&p, out)?;
} else if p.extension().map(|e| e == "ilo").unwrap_or(false) {
out.push(p);
}
}
Ok(())
}
fn resolve_targets(path: &Path) -> Result<Vec<PathBuf>, String> {
if !path.exists() {
return Err(format!("no such file or directory: {}", path.display()));
}
if path.is_file() {
return Ok(vec![path.to_path_buf()]);
}
let mut files = Vec::new();
collect_ilo_recursive(path, &mut files)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
files.sort();
Ok(files)
}
pub fn run(args: TestArgs) -> i32 {
let path_arg = args.path.unwrap_or_else(|| "examples".to_string());
let path = PathBuf::from(&path_arg);
let targets = match resolve_targets(&path) {
Ok(t) => t,
Err(e) => {
eprintln!("error: {e}");
return 1;
}
};
let engines = engines_for(args.engine);
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!("error: cannot resolve current executable: {e}");
return 1;
}
};
let mut passed = 0usize;
let mut failed = 0usize;
let mut files_with_cases = 0usize;
for file in &targets {
let src = match std::fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
eprintln!("error: cannot read {}: {e}", file.display());
failed += 1;
continue;
}
};
let cases = parse_cases(&src);
if cases.is_empty() {
continue;
}
files_with_cases += 1;
let skips = parse_engine_skips(&src);
for engine in &engines {
if skips.contains(engine.name) {
continue;
}
for case in &cases {
let func_label = case
.run_args
.first()
.cloned()
.unwrap_or_else(|| "<default>".to_string());
let engine_tag = if engines.len() > 1 {
format!(" [{}]", engine.name)
} else {
String::new()
};
let label = format!("{}::{}{engine_tag}", file.display(), func_label);
let out = Command::new(&exe)
.arg(file)
.arg(engine.flag)
.args(&case.run_args)
.output();
let out = match out {
Ok(o) => o,
Err(e) => {
println!("FAIL {label} (line {}): spawn error: {e}", case.line);
failed += 1;
continue;
}
};
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let (ok, detail) = match case.expect {
Expect::Stdout => {
if !out.status.success() {
(
false,
format!(
"exit {} (expected 0), stderr: {stderr}",
out.status.code().unwrap_or(-1)
),
)
} else if stdout != case.expected {
(false, format!("got: {stdout:?}, want: {:?}", case.expected))
} else {
(true, String::new())
}
}
Expect::Stderr => {
if out.status.success() {
(
false,
format!("expected non-zero exit (got 0), stdout: {stdout}"),
)
} else if stderr != case.expected {
(
false,
format!("got stderr: {stderr:?}, want: {:?}", case.expected),
)
} else {
(true, String::new())
}
}
};
if ok {
println!("PASS {label} (line {})", case.line);
passed += 1;
} else {
println!("FAIL {label} (line {}) ({detail})", case.line);
failed += 1;
}
}
}
}
println!();
println!("{passed} passed, {failed} failed");
if files_with_cases == 0 && failed == 0 {
eprintln!(
"error: no `-- run:` / `-- out:` annotations found under {}",
path.display()
);
return 1;
}
if failed > 0 { 1 } else { 0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_cases_simple_pair() {
let src = "-- run: main foo\n-- out: foo\n";
let cases = parse_cases(src);
assert_eq!(cases.len(), 1);
assert_eq!(cases[0].run_args, vec!["main", "foo"]);
assert_eq!(cases[0].expected, "foo");
assert_eq!(cases[0].expect, Expect::Stdout);
assert_eq!(cases[0].line, 1);
}
#[test]
fn parse_cases_err_variant() {
let src = "-- run: bad\n-- err: ILO-R001\n";
let cases = parse_cases(src);
assert_eq!(cases.len(), 1);
assert_eq!(cases[0].expect, Expect::Stderr);
assert_eq!(cases[0].expected, "ILO-R001");
}
#[test]
fn parse_cases_multiple_with_comments_between() {
let src = "-- run: a 1\n-- comment\n-- out: 2\n-- run: a 2\n-- out: 4\n";
let cases = parse_cases(src);
assert_eq!(cases.len(), 2);
assert_eq!(cases[0].expected, "2");
assert_eq!(cases[1].expected, "4");
}
#[test]
fn parse_cases_orphan_out_ignored() {
let src = "-- out: orphan\n-- run: a\n-- out: real\n";
let cases = parse_cases(src);
assert_eq!(cases.len(), 1);
assert_eq!(cases[0].expected, "real");
}
#[test]
fn parse_engine_skips_basic() {
let s = parse_engine_skips("-- engine-skip: vm jit\n");
assert!(s.contains("vm"));
assert!(s.contains("jit"));
}
#[test]
fn engines_for_selects() {
assert_eq!(engines_for(TestEngine::Vm).len(), 1);
assert_eq!(engines_for(TestEngine::Jit).len(), 1);
assert_eq!(engines_for(TestEngine::All).len(), 2);
}
#[test]
fn collect_ilo_recursive_finds_nested() {
let tmp = tempdir();
let nested = tmp.join("a/b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(tmp.join("top.ilo"), "").unwrap();
std::fs::write(nested.join("deep.ilo"), "").unwrap();
std::fs::write(tmp.join("not.txt"), "").unwrap();
let mut out = Vec::new();
collect_ilo_recursive(&tmp, &mut out).unwrap();
out.sort();
assert_eq!(out.len(), 2);
assert!(out.iter().any(|p| p.ends_with("top.ilo")));
assert!(out.iter().any(|p| p.ends_with("deep.ilo")));
std::fs::remove_dir_all(&tmp).ok();
}
fn tempdir() -> PathBuf {
let mut p = std::env::temp_dir();
let pid = std::process::id();
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("ilo-test-runner-{pid}-{nonce}"));
std::fs::create_dir_all(&p).unwrap();
p
}
}