pub mod discovery;
pub mod mutations;
pub mod report;
pub mod runner;
use crate::config::Config;
use crate::discovery::discover;
use crate::project::ProjectType;
use anyhow::Result;
use colored::Colorize;
use runner::{MutationSummary, RunnerConfig};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct MutateOptions {
pub workers: Option<usize>,
pub timeout_secs: u64,
pub json: bool,
pub filter: Option<String>,
}
impl Default for MutateOptions {
fn default() -> Self {
Self {
workers: None,
timeout_secs: 30,
json: false,
filter: None,
}
}
}
#[derive(Debug)]
pub struct MutateReport {
pub summary: MutationSummary,
pub results: Vec<runner::MutationResult>,
}
pub fn run(workdir: &Path, opts: &MutateOptions) -> Result<MutateReport> {
let config = Config::load();
let files = discover(&config)?;
let project_type = crate::project::ProjectType::detect_in(workdir);
let mut runner_config = config_for_project(project_type);
if let Some(workers) = opts.workers {
runner_config.workers = workers;
}
runner_config.timeout_secs = opts.timeout_secs;
let target_files = filter_files(&files, opts.filter.as_deref());
if !opts.json {
print_header(&target_files, &runner_config);
}
let points = discover_all_mutations(&target_files);
if points.is_empty() {
return Ok(MutateReport {
summary: MutationSummary {
total: 0,
killed: 0,
survived: 0,
score: 100.0,
total_duration_ms: 0,
},
results: Vec::new(),
});
}
if !opts.json {
println!(
"Found {} mutation points across {} files\n",
points.len().to_string().cyan(),
target_files.len()
);
}
let results = runner::run_mutations(&points, &runner_config, workdir, |cur, total, result| {
if !opts.json {
println!("{}", report::format_progress(cur, total, result));
}
})?;
let summary = runner::summarize(&results);
if opts.json {
println!("{}", report::format_json(&results, &summary));
} else {
println!("{}", report::format_summary(&summary));
println!("{}", report::format_survivors(&results));
}
Ok(MutateReport { summary, results })
}
fn config_for_project(project_type: ProjectType) -> RunnerConfig {
match project_type {
ProjectType::Rust => RunnerConfig::rust(),
ProjectType::Node => RunnerConfig::typescript(), ProjectType::Python => RunnerConfig::python(),
_ => RunnerConfig::default(),
}
}
fn filter_files(files: &[PathBuf], filter: Option<&str>) -> Vec<PathBuf> {
match filter {
Some(pattern) => {
let pattern = pattern.replace('\\', "/");
files
.iter()
.filter(|f| {
let s = f.to_string_lossy().replace('\\', "/");
s.contains(&pattern)
})
.cloned()
.collect()
}
None => files.to_vec(),
}
}
fn discover_all_mutations(
files: &[PathBuf],
) -> Vec<mutations::MutationPoint> {
let mut all_points = Vec::new();
for file in files {
match discovery::discover_mutations(file) {
Ok(points) => all_points.extend(points),
Err(e) => {
eprintln!("Warning: Could not scan {}: {e}", file.display());
}
}
}
all_points
}
fn print_header(files: &[PathBuf], config: &RunnerConfig) {
println!();
println!("{}", "MUTATION TESTING [EXPERIMENTAL]".bold().cyan());
println!("{}", "═".repeat(60));
println!(
" Files: {}",
files.len().to_string().cyan()
);
println!(
" Timeout: {}s",
config.timeout_secs.to_string().cyan()
);
println!(
" Command: {} {}",
config.test_command.cyan(),
config.test_args.join(" ")
);
println!("{}", "═".repeat(60));
println!();
}