use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
use std::process::{Command, ExitCode};
use std::time::{Duration, Instant};
use anyhow::{Context, anyhow, bail};
use serde::Deserialize;
mod judge;
mod scratch;
use judge::{Verdict, judge};
use scratch::Scratch;
use crate::contract::{Declared, covers, declarations, tagged_tests};
use impish_core::obligations;
#[derive(Deserialize)]
struct Mutant {
name: String,
file: String,
function: Option<Function>,
replacement: String,
span: Span,
}
#[derive(Deserialize)]
struct Function {
function_name: String,
}
#[derive(Deserialize)]
struct Span {
start: Position,
end: Position,
}
#[derive(Deserialize)]
struct Position {
line: usize,
column: usize,
}
enum Outcome {
Killed {
by: BTreeSet<String>,
tests: BTreeSet<String>,
},
Survived,
Unviable,
TimedOut,
}
pub fn run(root: &Path, primitive: &str, json: bool) -> anyhow::Result<ExitCode> {
let readme_file = format!("contract/{primitive}/README.md");
let readme =
fs::read_to_string(root.join(&readme_file)).with_context(|| format!("no {readme_file}"))?;
let ids =
obligations(&readme, &readme_file).map_err(|problems| anyhow!(problems.join("\n")))?;
let active: Vec<String> = ids
.iter()
.filter(|(_, o)| !o.retired)
.map(|(id, _)| format!("{primitive}/A{id}"))
.collect();
let targets = covers(&readme)
.into_iter()
.collect::<Result<Vec<_>, _>>()
.map_err(|line| anyhow!("{readme_file}: covers line `{line}` is not <file>::<function>"))?;
if targets.is_empty() {
bail!("{readme_file} covers no code, so there is nothing to mutate");
}
let declared_file = format!("contract/{primitive}/mutants.md");
let declared_text = match fs::read_to_string(root.join(&declared_file)) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).with_context(|| format!("read {declared_file}")),
};
let (declared, unreasoned) = declarations(&declared_text);
if !unreasoned.is_empty() {
let problems = unreasoned
.into_iter()
.map(|(line, kind)| {
format!(
"{declared_file}:{line}: an {kind} block with no ## heading giving its reason"
)
})
.collect();
let verdict = Verdict {
problems,
..Verdict::default()
};
return Ok(finish(primitive, &[], &declared, &verdict, json));
}
let tests_file = format!("tests/contract/{primitive}.rs");
let tests =
fs::read_to_string(root.join(&tests_file)).with_context(|| format!("no {tests_file}"))?;
let tags: BTreeMap<String, Vec<String>> = tagged_tests(&tests)
.with_context(|| format!("{tests_file} isn't valid Rust"))?
.into_iter()
.map(|t| (format!("{primitive}::{}", t.name), t.tags))
.collect();
let mutants = list(root, &targets)?;
if mutants.is_empty() {
bail!("cargo-mutants finds no mutants in the code {readme_file} covers");
}
let scratch = Scratch::copy(root)?;
let exe = scratch
.build()?
.ok_or_else(|| anyhow!("the contract tests don't build"))?;
let started = Instant::now();
let failed = match scratch.test(&exe, primitive, None)? {
Some(failed) => failed,
None => bail!("the contract tests time out before any mutation"),
};
if !failed.is_empty() {
let failed: Vec<_> = failed.into_iter().collect();
bail!(
"the baseline is red: {} fail before any mutation",
failed.join(", ")
);
}
let timeout = started.elapsed() * 5 + Duration::from_secs(10);
if !json {
println!(
"{primitive}: {} mutants of {} functions, {} tests",
mutants.len(),
targets.len(),
tags.len()
);
}
let mut outcomes = Vec::new();
for mutant in &mutants {
let outcome = scratch.try_mutant(mutant, primitive, timeout, &tags, &active)?;
let mut verdict = match &outcome {
Outcome::Killed { by, .. } => {
format!(
"killed by {}",
by.iter().cloned().collect::<Vec<_>>().join(", ")
)
}
Outcome::Survived => "SURVIVED".to_string(),
Outcome::Unviable => "unviable".to_string(),
Outcome::TimedOut => "timed out".to_string(),
};
if matches!(outcome, Outcome::Survived | Outcome::TimedOut)
&& let Some(d) = declared.iter().find(|d| d.name == key(mutant))
{
verdict = format!("{verdict}, declared {}", d.kind);
}
if !json {
println!(" {} {verdict}", mutant.name);
}
outcomes.push(outcome);
}
let tried: Vec<_> = mutants.iter().zip(&outcomes).collect();
let tests: Vec<&str> = tags.keys().map(String::as_str).collect();
let verdict = judge(&active, &tests, &tried, &declared, &declared_file);
Ok(finish(primitive, &tried, &declared, &verdict, json))
}
fn key(m: &Mutant) -> String {
let rest = m.name.strip_prefix(m.file.as_str());
let rest = rest
.and_then(|r| r.split_once(": "))
.map_or(m.name.as_str(), |(_, r)| r);
format!("{}: {rest}", m.file)
}
fn finish(
primitive: &str,
tried: &[(&Mutant, &Outcome)],
declared: &[Declared],
v: &Verdict,
json: bool,
) -> ExitCode {
let code = if v.problems.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
};
if json {
println!("{}", judge::json(primitive, tried, declared, v));
return code;
}
if !tried.is_empty() {
println!(
"\n{} killed, {} survived, {} timed out, {} unviable",
v.killed, v.survived, v.timed_out, v.unviable
);
let width = v
.rows
.iter()
.map(|(id, ..)| id.len())
.max()
.unwrap_or(0)
.max(10);
println!("\n {:width$} killed unique", "obligation");
for (id, killed, unique) in &v.rows {
println!(" {id:width$} {killed:>6} {unique:>6}");
}
println!(" unique: caught by no other obligation's tests.");
println!(" Tests outside the Contract weren't run; they may catch it too.");
}
if v.problems.is_empty() {
println!("\nEvery mutant of {primitive} is caught or declared.");
return code;
}
for problem in &v.problems {
eprintln!("{problem}");
}
eprintln!("The Contract is not met.");
code
}
fn list(root: &Path, targets: &[(&str, &str)]) -> anyhow::Result<Vec<Mutant>> {
let files: BTreeSet<&str> = targets.iter().map(|(file, _)| *file).collect();
let mut cmd = Command::new(cargo());
cmd.args(["mutants", "--list", "--json", "--workspace"])
.current_dir(root);
for file in &files {
cmd.args(["--file", file]);
}
let out = cmd
.output()
.context("run `cargo mutants`; is cargo-mutants installed?")?;
if !out.status.success() {
bail!(
"`cargo mutants --list` failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let all: Vec<Mutant> =
serde_json::from_slice(&out.stdout).context("read `cargo mutants --list --json`")?;
Ok(all
.into_iter()
.filter(|m| {
let name = m.function.as_ref().map_or("", |f| f.function_name.as_str());
targets.iter().any(|(file, func)| {
m.file == *file && (name == *func || name.ends_with(&format!("::{func}")))
})
})
.collect())
}
fn cargo() -> String {
std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())
}