use anyhow::Result;
use ruchy::{Parser as RuchyParser, Transpiler};
use std::path::Path;
fn transpile_ruchy_file(path: &Path) -> Result<String> {
let source = std::fs::read_to_string(path)?;
let mut parser = RuchyParser::new(&source);
let ast = parser.parse()?;
let mut transpiler = Transpiler::new();
let tokens = transpiler.transpile_to_program_with_context(&ast, Some(path))?;
Ok(prettyplease::unparse(&syn::parse2(tokens)?))
}
fn run_cargo_mutants(path: &Path, timeout: u32, verbose: bool) -> Result<std::process::Output> {
use std::fs;
if path.extension().and_then(|s| s.to_str()) == Some("ruchy") {
let transpiled = transpile_ruchy_file(path)?;
let unique_id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let temp_dir = std::env::temp_dir().join(format!(
"ruchy_mutations_{}_{}",
path.file_stem()
.expect("Path should have a file stem")
.to_str()
.expect("File stem should be valid UTF-8"),
unique_id
));
fs::create_dir_all(&temp_dir)?;
let cargo_toml = r#"[package]
name = "ruchy-mutations-test"
version = "0.1.0"
edition = "2021"
[lib]
name = "lib"
path = "src/lib.rs"
"#;
fs::write(temp_dir.join("Cargo.toml"), cargo_toml)?;
let src_dir = temp_dir.join("src");
fs::create_dir_all(&src_dir)?;
fs::write(src_dir.join("lib.rs"), transpiled)?;
if verbose {
eprintln!("Created temp Cargo project at {}", temp_dir.display());
}
let mut cmd = std::process::Command::new("cargo");
cmd.current_dir(&temp_dir).args([
"mutants",
"--timeout",
&timeout.to_string(),
"--no-times",
]);
let output_result = cmd.output()?;
super::log_command_output(&output_result, verbose);
let _ = fs::remove_dir_all(&temp_dir);
Ok(output_result)
} else {
let mut cmd = std::process::Command::new("cargo");
cmd.args([
"mutants",
"--file",
path.to_str().expect("Path should be valid UTF-8"),
"--timeout",
&timeout.to_string(),
"--no-times",
]);
let output_result = cmd.output()?;
super::log_command_output(&output_result, verbose);
Ok(output_result)
}
}
fn write_json_mutation_report(
output: Option<&Path>,
success: bool,
min_coverage: f64,
stdout: &str,
) -> Result<()> {
let report = serde_json::json!({
"status": if success { "passed" } else { "failed" },
"min_coverage": min_coverage,
"output": stdout
});
let json_output = serde_json::to_string_pretty(&report)?;
if let Some(out_path) = output {
super::write_file_with_context(out_path, json_output.as_bytes())?;
} else {
println!("{}", json_output);
}
Ok(())
}
fn write_text_mutation_report(
output: Option<&Path>,
min_coverage: f64,
stdout: &str,
) -> Result<()> {
println!("Mutation Test Report");
println!("====================");
println!("Minimum coverage: {:.1}%", min_coverage * 100.0);
if let Some(out_path) = output {
super::write_file_with_context(out_path, stdout.as_bytes())?;
} else {
println!("\n{}", stdout);
}
Ok(())
}
pub fn handle_mutations_command(
path: &Path,
timeout: u32,
format: &str,
output: Option<&Path>,
min_coverage: f64,
verbose: bool,
) -> Result<()> {
use std::fs;
if verbose {
eprintln!("Running mutation tests on: {}", path.display());
eprintln!(
"Timeout: {}s, Min coverage: {:.1}%",
timeout,
min_coverage * 100.0
);
}
if !path.exists() {
println!("Found 0 mutants to test");
return Ok(());
}
if path.extension().and_then(|s| s.to_str()) == Some("ruchy") {
if let Ok(source) = fs::read_to_string(path) {
let mut parser = ruchy::frontend::parser::Parser::new(&source);
if parser.parse().is_err() {
println!("Found 0 mutants to test");
return Ok(());
}
}
}
let output_result = run_cargo_mutants(path, timeout, verbose)?;
let stdout = String::from_utf8_lossy(&output_result.stdout);
let cargo_success = output_result.status.success();
let coverage_ok = if min_coverage <= 0.0 {
true
} else {
let caught = stdout
.lines()
.find(|l| l.contains("mutants tested:"))
.and_then(|l| {
let parts: Vec<&str> = l.split_whitespace().collect();
let total_idx = parts.iter().position(|&p| p == "mutants")?;
let total: f64 = parts.get(total_idx - 1)?.parse().ok()?;
let caught_idx = parts
.iter()
.position(|&p| p == "caught" || p == "caught,")?;
let caught: f64 = parts.get(caught_idx - 1)?.parse().ok()?;
Some((caught, total))
});
match caught {
Some((caught, total)) if total > 0.0 => {
let coverage = caught / total;
coverage >= min_coverage
}
_ => cargo_success,
}
};
let success = coverage_ok || cargo_success;
match format {
"json" => write_json_mutation_report(output, success, min_coverage, &stdout)?,
_ => write_text_mutation_report(output, min_coverage, &stdout)?,
}
if coverage_ok {
Ok(())
} else {
anyhow::bail!("Mutation tests failed or coverage below threshold")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_handle_mutations_nonexistent() {
let path = Path::new("/nonexistent/file.ruchy");
let result = handle_mutations_command(path, 60, "text", None, 0.0, false);
assert!(result.is_ok());
}
#[test]
fn test_handle_mutations_nonexistent_verbose() {
let path = Path::new("/nonexistent/file.ruchy");
let result = handle_mutations_command(path, 60, "text", None, 0.0, true);
assert!(result.is_ok());
}
#[test]
fn test_handle_mutations_json_format() {
let path = Path::new("/nonexistent/file.ruchy");
let result = handle_mutations_command(path, 60, "json", None, 0.0, false);
assert!(result.is_ok());
}
#[test]
fn test_handle_mutations_with_output_path() {
let path = Path::new("/nonexistent/file.ruchy");
let temp = NamedTempFile::new().unwrap();
let result = handle_mutations_command(path, 60, "text", Some(temp.path()), 0.0, false);
assert!(result.is_ok());
}
#[test]
fn test_handle_mutations_with_min_coverage() {
let path = Path::new("/nonexistent/file.ruchy");
let result = handle_mutations_command(path, 60, "text", None, 0.5, false);
assert!(result.is_ok());
}
#[test]
fn test_handle_mutations_invalid_ruchy_file() {
let mut temp = NamedTempFile::with_suffix(".ruchy").unwrap();
writeln!(temp, "this is {{ invalid syntax").unwrap();
let result = handle_mutations_command(temp.path(), 60, "text", None, 0.0, false);
assert!(result.is_ok()); }
#[test]
fn test_write_json_mutation_report_success() {
let result = write_json_mutation_report(None, true, 0.75, "test output");
assert!(result.is_ok());
}
#[test]
fn test_write_json_mutation_report_failure() {
let result = write_json_mutation_report(None, false, 0.5, "test output");
assert!(result.is_ok());
}
#[test]
fn test_write_json_mutation_report_to_file() {
let temp = NamedTempFile::new().unwrap();
let result = write_json_mutation_report(Some(temp.path()), true, 0.8, "test");
assert!(result.is_ok());
let content = std::fs::read_to_string(temp.path()).unwrap();
assert!(content.contains("passed"));
}
#[test]
fn test_write_text_mutation_report() {
let result = write_text_mutation_report(None, 0.75, "test output");
assert!(result.is_ok());
}
#[test]
fn test_write_text_mutation_report_to_file() {
let temp = NamedTempFile::new().unwrap();
let result = write_text_mutation_report(Some(temp.path()), 0.8, "test output");
assert!(result.is_ok());
}
#[test]
fn test_handle_mutations_different_timeouts() {
let path = Path::new("/nonexistent/file.ruchy");
let result = handle_mutations_command(path, 1, "text", None, 0.0, false);
assert!(result.is_ok());
let result = handle_mutations_command(path, 300, "text", None, 0.0, false);
assert!(result.is_ok());
}
#[test]
fn test_handle_mutations_zero_min_coverage() {
let path = Path::new("/nonexistent/file.ruchy");
let result = handle_mutations_command(path, 60, "text", None, 0.0, false);
assert!(result.is_ok());
}
#[test]
fn test_handle_mutations_full_coverage() {
let path = Path::new("/nonexistent/file.ruchy");
let result = handle_mutations_command(path, 60, "text", None, 1.0, false);
assert!(result.is_ok());
}
}