1use std::path::{Path, PathBuf};
19use std::process::Command;
20use std::sync::atomic::{AtomicU64, Ordering};
21
22use anyhow::{bail, Context, Result};
23use serde::Deserialize;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Survivor {
28 pub file: String,
30 pub line: u32,
32 pub description: String,
34}
35
36#[derive(Debug, Clone, Deserialize)]
39pub struct MutantsReport {
40 pub outcomes: Vec<MutantOutcome>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
47pub struct MutantOutcome {
48 pub summary: String,
49 pub scenario: Scenario,
50}
51
52#[derive(Debug, Clone, Deserialize)]
55pub enum Scenario {
56 Baseline,
57 Mutant(MutantInfo),
58}
59
60#[derive(Debug, Clone, Deserialize)]
64pub struct MutantInfo {
65 pub file: String,
66 pub span: Span,
67 pub name: String,
68}
69
70#[derive(Debug, Clone, Deserialize)]
72pub struct Span {
73 pub start: LineCol,
74}
75
76#[derive(Debug, Clone, Deserialize)]
78pub struct LineCol {
79 pub line: u32,
80}
81
82pub fn parse_mutants_report(json: &str) -> Result<MutantsReport> {
84 serde_json::from_str(json).context("parsing cargo-mutants outcomes.json")
85}
86
87pub fn unexplained_survivors(report: &MutantsReport, exempt: &[String]) -> Vec<Survivor> {
96 report
97 .outcomes
98 .iter()
99 .filter_map(|outcome| {
100 if outcome.summary != "MissedMutant" {
101 return None;
102 }
103 let Scenario::Mutant(mutant) = &outcome.scenario else {
104 return None;
105 };
106 if exempt.iter().any(|path| path == &mutant.file) {
107 return None;
108 }
109 Some(Survivor {
110 file: mutant.file.clone(),
111 line: mutant.span.start.line,
112 description: mutant.name.clone(),
113 })
114 })
115 .collect()
116}
117
118pub fn measure_rust(root: &Path, exempt: &[String], base: Option<&str>) -> Result<Vec<Survivor>> {
124 let out = MutantsOut::new();
125 let diff = match base {
126 Some(base) => Some(write_base_diff(root, base, &out)?),
127 None => None,
128 };
129 run_cargo_mutants(root, &out.0, diff.as_deref())?;
130 let outcomes = out.0.join("mutants.out").join("outcomes.json");
131 let json = std::fs::read_to_string(&outcomes).with_context(|| {
132 format!(
133 "reading cargo-mutants outcomes at `{}` — the run wrote none",
134 outcomes.display()
135 )
136 })?;
137 let report = parse_mutants_report(&json)?;
138 Ok(unexplained_survivors(&report, exempt))
139}
140
141struct MutantsOut(PathBuf);
144
145impl MutantsOut {
146 fn new() -> Self {
147 static COUNTER: AtomicU64 = AtomicU64::new(0);
148 let name = format!(
149 "testing-conventions-mutants-{}-{}",
150 std::process::id(),
151 COUNTER.fetch_add(1, Ordering::Relaxed),
152 );
153 MutantsOut(std::env::temp_dir().join(name))
154 }
155}
156
157impl Drop for MutantsOut {
158 fn drop(&mut self) {
159 let _ = std::fs::remove_dir_all(&self.0);
160 }
161}
162
163fn write_base_diff(root: &Path, base: &str, out: &MutantsOut) -> Result<PathBuf> {
165 let range = format!("{base}...HEAD");
166 let output = Command::new("git")
167 .current_dir(root)
168 .args(["diff", &range])
169 .output()
170 .context("running `git diff` for `--base` (is git installed?)")?;
171 if !output.status.success() {
172 bail!(
173 "git diff {range} failed: {}",
174 String::from_utf8_lossy(&output.stderr)
175 );
176 }
177 std::fs::create_dir_all(&out.0).context("creating the mutants output dir")?;
178 let path = out.0.join("base.diff");
179 std::fs::write(&path, &output.stdout).context("writing the base diff")?;
180 Ok(path)
181}
182
183fn run_cargo_mutants(root: &Path, out: &Path, in_diff: Option<&Path>) -> Result<()> {
192 let mut command = Command::new("cargo");
193 command
194 .current_dir(root)
195 .arg("mutants")
196 .arg("--output")
197 .arg(out);
198 if let Some(diff) = in_diff {
199 command.arg("--in-diff").arg(diff);
200 }
201 for var in [
202 "RUSTFLAGS",
203 "CARGO_ENCODED_RUSTFLAGS",
204 "RUSTDOCFLAGS",
205 "CARGO_ENCODED_RUSTDOCFLAGS",
206 "LLVM_PROFILE_FILE",
207 "CARGO_LLVM_COV",
208 "CARGO_LLVM_COV_SHOW_ENV",
209 "CARGO_LLVM_COV_TARGET_DIR",
210 "CARGO_LLVM_COV_BUILD_DIR",
211 "RUSTC_WRAPPER",
212 "RUSTC_WORKSPACE_WRAPPER",
213 "__CARGO_LLVM_COV_RUSTC_WRAPPER",
214 "__CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS",
215 "__CARGO_LLVM_COV_RUSTC_WRAPPER_CRATE_NAMES",
216 ] {
217 command.env_remove(var);
218 }
219 let output = command
220 .output()
221 .context("running `cargo mutants` (is cargo-mutants installed?)")?;
222 match output.status.code() {
223 Some(0) | Some(2) => Ok(()),
225 _ => bail!(
226 "cargo-mutants did not run cleanly in `{}` (baseline build/test failure?):\n{}{}",
227 root.display(),
228 String::from_utf8_lossy(&output.stdout),
229 String::from_utf8_lossy(&output.stderr),
230 ),
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 const SAMPLE: &str = r#"{
241 "outcomes": [
242 {"scenario": "Baseline", "summary": "Success",
243 "phase_results": []},
244 {"scenario": {"Mutant": {"file": "src/lib.rs", "package": "p", "genre": "FnValue",
245 "replacement": "true", "name": "src/lib.rs:7:7: replace > with == in is_positive",
246 "function": {"function_name": "is_positive"},
247 "span": {"start": {"line": 7, "column": 7}, "end": {"line": 7, "column": 8}}}},
248 "summary": "MissedMutant"},
249 {"scenario": {"Mutant": {"file": "src/other.rs", "package": "p", "genre": "FnValue",
250 "replacement": "0", "name": "src/other.rs:3:5: replace add -> i32 with 0",
251 "span": {"start": {"line": 3, "column": 5}, "end": {"line": 3, "column": 9}}}},
252 "summary": "CaughtMutant"}
253 ],
254 "total_mutants": 2
255 }"#;
256
257 #[test]
258 fn parses_the_outcomes_export() {
259 let report = parse_mutants_report(SAMPLE).expect("valid outcomes.json");
260 assert_eq!(report.outcomes.len(), 3);
261 assert!(matches!(report.outcomes[0].scenario, Scenario::Baseline));
262 }
263
264 #[test]
265 fn collects_only_missed_mutants_as_survivors() {
266 let report = parse_mutants_report(SAMPLE).unwrap();
267 let survivors = unexplained_survivors(&report, &[]);
268 assert_eq!(survivors.len(), 1);
270 assert_eq!(survivors[0].file, "src/lib.rs");
271 assert_eq!(survivors[0].line, 7);
272 assert!(survivors[0].description.contains("replace > with =="));
273 }
274
275 #[test]
276 fn an_exemption_drops_a_survivor_in_that_file() {
277 let report = parse_mutants_report(SAMPLE).unwrap();
278 let exempt = vec!["src/lib.rs".to_string()];
279 assert!(unexplained_survivors(&report, &exempt).is_empty());
280 }
281
282 #[test]
283 fn an_exemption_on_another_file_leaves_the_survivor() {
284 let report = parse_mutants_report(SAMPLE).unwrap();
285 let exempt = vec!["src/elsewhere.rs".to_string()];
286 assert_eq!(unexplained_survivors(&report, &exempt).len(), 1);
287 }
288}