1use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15use anyhow::{Context, Result};
16
17use crate::ops::verify::run_verify_command;
18
19#[derive(Debug, Clone)]
25pub struct Mutant {
26 pub file: PathBuf,
28 pub line_number: usize,
30 pub original: String,
32 pub mutated: String,
34 pub operator: MutationOperator,
36}
37
38#[derive(Debug, Clone, PartialEq)]
40pub enum MutationOperator {
41 FlipComparison,
43 FlipLogical,
45 SwapBoolean,
47 FlipArithmetic,
49 DeleteLine,
51}
52
53impl std::fmt::Display for MutationOperator {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 MutationOperator::FlipComparison => write!(f, "flip-comparison"),
57 MutationOperator::FlipLogical => write!(f, "flip-logical"),
58 MutationOperator::SwapBoolean => write!(f, "swap-boolean"),
59 MutationOperator::FlipArithmetic => write!(f, "flip-arithmetic"),
60 MutationOperator::DeleteLine => write!(f, "delete-line"),
61 }
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct MutantResult {
68 pub mutant: Mutant,
70 pub killed: bool,
72 pub timed_out: bool,
74}
75
76#[derive(Debug)]
78pub struct MutationReport {
79 pub total: usize,
81 pub killed: usize,
83 pub survived: usize,
85 pub timed_out: usize,
87 pub score: f64,
89 pub results: Vec<MutantResult>,
91}
92
93pub struct MutateOpts {
95 pub max_mutants: usize,
97 pub timeout_secs: Option<u64>,
99 pub diff_base: String,
101}
102
103impl Default for MutateOpts {
104 fn default() -> Self {
105 Self {
106 max_mutants: 0,
107 timeout_secs: Some(60),
108 diff_base: "HEAD".to_string(),
109 }
110 }
111}
112
113pub fn run_mutation_test(
124 project_root: &Path,
125 verify_cmd: &str,
126 opts: &MutateOpts,
127) -> Result<MutationReport> {
128 let hunks = get_diff_hunks(project_root, &opts.diff_base)?;
130 if hunks.is_empty() {
131 return Ok(MutationReport {
132 total: 0,
133 killed: 0,
134 survived: 0,
135 timed_out: 0,
136 score: 100.0,
137 results: vec![],
138 });
139 }
140
141 let mut mutants = Vec::new();
143 for hunk in &hunks {
144 let file_path = project_root.join(&hunk.file);
145 if !file_path.exists() {
146 continue;
147 }
148 let content = fs::read_to_string(&file_path)
149 .with_context(|| format!("Failed to read {}", hunk.file.display()))?;
150 let lines: Vec<&str> = content.lines().collect();
151
152 for &line_num in &hunk.added_lines {
153 if line_num == 0 || line_num > lines.len() {
154 continue;
155 }
156 let line = lines[line_num - 1];
157 let line_mutants = generate_mutations(&hunk.file, line_num, line);
158 mutants.extend(line_mutants);
159 }
160 }
161
162 if opts.max_mutants > 0 && mutants.len() > opts.max_mutants {
164 mutants.truncate(opts.max_mutants);
165 }
166
167 let total = mutants.len();
168 if total == 0 {
169 return Ok(MutationReport {
170 total: 0,
171 killed: 0,
172 survived: 0,
173 timed_out: 0,
174 score: 100.0,
175 results: vec![],
176 });
177 }
178
179 let mut results = Vec::with_capacity(total);
182 let mut killed = 0;
183 let mut survived = 0;
184 let mut timed_out_count = 0;
185
186 let mut originals: HashMap<PathBuf, String> = HashMap::new();
188
189 for mutant in mutants {
190 let file_path = project_root.join(&mutant.file);
191 let abs_file = file_path.clone();
192
193 if !originals.contains_key(&abs_file) {
195 let content = fs::read_to_string(&abs_file)
196 .with_context(|| format!("Failed to read {}", mutant.file.display()))?;
197 originals.insert(abs_file.clone(), content);
198 }
199 let original_content = originals[&abs_file].clone();
200
201 let mutated_content =
203 apply_line_mutation(&original_content, mutant.line_number, &mutant.mutated);
204 fs::write(&abs_file, &mutated_content)
205 .with_context(|| format!("Failed to write mutated {}", mutant.file.display()))?;
206
207 let verify_result = run_verify_command(verify_cmd, project_root, opts.timeout_secs);
209
210 fs::write(&abs_file, &original_content)
212 .with_context(|| format!("Failed to restore {}", mutant.file.display()))?;
213
214 let (is_killed, is_timed_out) = match verify_result {
215 Ok(vr) => {
216 if vr.timed_out {
217 (true, true) } else {
219 (!vr.passed, false) }
221 }
222 Err(_) => (true, false), };
224
225 if is_killed {
226 killed += 1;
227 } else {
228 survived += 1;
229 }
230 if is_timed_out {
231 timed_out_count += 1;
232 }
233
234 results.push(MutantResult {
235 mutant,
236 killed: is_killed,
237 timed_out: is_timed_out,
238 });
239 }
240
241 let score = if total > 0 {
242 (killed as f64 / total as f64) * 100.0
243 } else {
244 100.0
245 };
246
247 Ok(MutationReport {
248 total,
249 killed,
250 survived,
251 timed_out: timed_out_count,
252 score,
253 results,
254 })
255}
256
257#[derive(Debug)]
263pub struct DiffHunk {
264 pub file: PathBuf,
266 pub added_lines: Vec<usize>,
268}
269
270pub fn get_diff_hunks(project_root: &Path, base_ref: &str) -> Result<Vec<DiffHunk>> {
275 let output = Command::new("git")
277 .args(["diff", base_ref, "--unified=0", "--no-color"])
278 .current_dir(project_root)
279 .output()
280 .context("Failed to run git diff")?;
281
282 if !output.status.success() {
283 let output2 = Command::new("git")
285 .args(["diff", "--cached", "--unified=0", "--no-color"])
286 .current_dir(project_root)
287 .output()
288 .context("Failed to run git diff --cached")?;
289
290 if !output2.status.success() {
291 return Ok(vec![]);
292 }
293 return parse_unified_diff(&String::from_utf8_lossy(&output2.stdout));
294 }
295
296 parse_unified_diff(&String::from_utf8_lossy(&output.stdout))
297}
298
299fn parse_unified_diff(diff_text: &str) -> Result<Vec<DiffHunk>> {
301 let mut hunks: Vec<DiffHunk> = Vec::new();
302 let mut current_file: Option<PathBuf> = None;
303 let mut current_lines: Vec<usize> = Vec::new();
304 let mut new_line_num: usize = 0;
305
306 for line in diff_text.lines() {
307 if let Some(rest) = line.strip_prefix("+++ b/") {
308 if let Some(ref file) = current_file {
310 if !current_lines.is_empty() {
311 hunks.push(DiffHunk {
312 file: file.clone(),
313 added_lines: std::mem::take(&mut current_lines),
314 });
315 }
316 }
317 current_file = Some(PathBuf::from(rest));
318 } else if line.starts_with("@@ ") {
319 if let Some(new_range) = parse_hunk_header(line) {
321 new_line_num = new_range.0;
322 }
323 } else if let Some(added) = line.strip_prefix('+') {
324 if current_file.is_some() && !added.trim().is_empty() {
326 current_lines.push(new_line_num);
327 }
328 new_line_num += 1;
329 } else if !line.starts_with('-')
330 && !line.starts_with("diff ")
331 && !line.starts_with("index ")
332 && !line.starts_with("--- ")
333 {
334 new_line_num += 1;
336 }
337 }
339
340 if let Some(file) = current_file {
342 if !current_lines.is_empty() {
343 hunks.push(DiffHunk {
344 file,
345 added_lines: current_lines,
346 });
347 }
348 }
349
350 Ok(hunks)
351}
352
353fn parse_hunk_header(line: &str) -> Option<(usize, usize)> {
358 let plus_idx = line.find('+')?;
360 let rest = &line[plus_idx + 1..];
361 let end = rest.find(' ').unwrap_or(rest.len());
362 let range_str = &rest[..end];
363
364 if let Some((start_s, count_s)) = range_str.split_once(',') {
365 let start: usize = start_s.parse().ok()?;
366 let count: usize = count_s.parse().ok()?;
367 Some((start, count))
368 } else {
369 let start: usize = range_str.parse().ok()?;
370 Some((start, 1))
371 }
372}
373
374pub fn generate_mutations(file: &Path, line_number: usize, line: &str) -> Vec<Mutant> {
380 let mut mutants = Vec::new();
381 let trimmed = line.trim();
382
383 if trimmed.is_empty() || is_comment_line(trimmed) {
385 return mutants;
386 }
387
388 for (from, to) in COMPARISON_SWAPS {
390 if let Some(mutated) = try_replace_operator(line, from, to) {
391 mutants.push(Mutant {
392 file: file.to_path_buf(),
393 line_number,
394 original: line.to_string(),
395 mutated,
396 operator: MutationOperator::FlipComparison,
397 });
398 }
399 }
400
401 for (from, to) in LOGICAL_SWAPS {
403 if let Some(mutated) = try_replace_operator(line, from, to) {
404 mutants.push(Mutant {
405 file: file.to_path_buf(),
406 line_number,
407 original: line.to_string(),
408 mutated,
409 operator: MutationOperator::FlipLogical,
410 });
411 }
412 }
413
414 for (from, to) in BOOLEAN_SWAPS {
416 if let Some(mutated) = try_replace_word(line, from, to) {
417 mutants.push(Mutant {
418 file: file.to_path_buf(),
419 line_number,
420 original: line.to_string(),
421 mutated,
422 operator: MutationOperator::SwapBoolean,
423 });
424 }
425 }
426
427 for (from, to) in ARITHMETIC_SWAPS {
429 if let Some(mutated) = try_replace_arithmetic(line, from, to) {
430 mutants.push(Mutant {
431 file: file.to_path_buf(),
432 line_number,
433 original: line.to_string(),
434 mutated,
435 operator: MutationOperator::FlipArithmetic,
436 });
437 }
438 }
439
440 if is_deletable_line(trimmed) {
442 mutants.push(Mutant {
443 file: file.to_path_buf(),
444 line_number,
445 original: line.to_string(),
446 mutated: String::new(),
447 operator: MutationOperator::DeleteLine,
448 });
449 }
450
451 mutants
452}
453
454const COMPARISON_SWAPS: &[(&str, &str)] = &[
460 ("===", "!=="),
461 ("!==", "==="),
462 ("==", "!="),
463 ("!=", "=="),
464 (">=", "<"),
465 ("<=", ">"),
466 ];
468
469const LOGICAL_SWAPS: &[(&str, &str)] = &[
471 ("&&", "||"),
472 ("||", "&&"),
473 (" and ", " or "),
474 (" or ", " and "),
475];
476
477const BOOLEAN_SWAPS: &[(&str, &str)] = &[
479 ("true", "false"),
480 ("false", "true"),
481 ("True", "False"),
482 ("False", "True"),
483];
484
485const ARITHMETIC_SWAPS: &[(&str, &str)] = &[
487 (" + ", " - "),
488 (" - ", " + "),
489 (" * ", " / "),
490 (" / ", " * "),
491];
492
493fn try_replace_operator(line: &str, from: &str, to: &str) -> Option<String> {
499 if line.contains(from) {
500 Some(line.replacen(from, to, 1))
502 } else {
503 None
504 }
505}
506
507fn try_replace_word(line: &str, from: &str, to: &str) -> Option<String> {
510 let mut search_from = 0;
512 while let Some(pos) = line[search_from..].find(from) {
513 let abs_pos = search_from + pos;
514 let before_ok = abs_pos == 0
515 || !line.as_bytes()[abs_pos - 1].is_ascii_alphanumeric()
516 && line.as_bytes()[abs_pos - 1] != b'_';
517 let after_pos = abs_pos + from.len();
518 let after_ok = after_pos >= line.len()
519 || !line.as_bytes()[after_pos].is_ascii_alphanumeric()
520 && line.as_bytes()[after_pos] != b'_';
521
522 if before_ok && after_ok {
523 let mut result = String::with_capacity(line.len());
524 result.push_str(&line[..abs_pos]);
525 result.push_str(to);
526 result.push_str(&line[after_pos..]);
527 return Some(result);
528 }
529 search_from = abs_pos + from.len();
530 }
531 None
532}
533
534fn try_replace_arithmetic(line: &str, from: &str, to: &str) -> Option<String> {
537 let trimmed = line.trim();
538 if trimmed.starts_with("use ")
540 || trimmed.starts_with("import ")
541 || trimmed.starts_with("#include")
542 || trimmed.starts_with("require")
543 || trimmed.starts_with("from ")
544 {
545 return None;
546 }
547
548 if line.contains(from) {
549 Some(line.replacen(from, to, 1))
550 } else {
551 None
552 }
553}
554
555fn is_comment_line(trimmed: &str) -> bool {
557 trimmed.starts_with("//")
558 || trimmed.starts_with('#')
559 || trimmed.starts_with("/*")
560 || trimmed.starts_with('*')
561 || trimmed.starts_with("--")
562 || trimmed.starts_with(";;")
563 || trimmed.starts_with('%')
564}
565
566fn is_deletable_line(trimmed: &str) -> bool {
569 if trimmed == "{"
571 || trimmed == "}"
572 || trimmed == "};"
573 || trimmed == "("
574 || trimmed == ")"
575 || trimmed == ");"
576 || trimmed == "]"
577 || trimmed == "];"
578 || trimmed == "end"
579 || trimmed == "else"
580 || trimmed == "else {"
581 {
582 return false;
583 }
584
585 if trimmed.starts_with("use ")
587 || trimmed.starts_with("import ")
588 || trimmed.starts_with("#include")
589 || trimmed.starts_with("require")
590 || trimmed.starts_with("from ")
591 || trimmed.starts_with("mod ")
592 || trimmed.starts_with("pub mod ")
593 || trimmed.starts_with("pub use ")
594 {
595 return false;
596 }
597
598 trimmed.len() > 3
600}
601
602fn apply_line_mutation(content: &str, line_number: usize, replacement: &str) -> String {
608 let lines: Vec<&str> = content.lines().collect();
609 let mut result = String::with_capacity(content.len());
610 let has_trailing_newline = content.ends_with('\n');
611
612 for (i, line) in lines.iter().enumerate() {
613 if i + 1 == line_number {
614 if !replacement.is_empty() {
615 result.push_str(replacement);
616 result.push('\n');
617 }
618 } else {
620 result.push_str(line);
621 if i < lines.len() - 1 || has_trailing_newline {
622 result.push('\n');
623 }
624 }
625 }
626
627 result
628}
629
630#[cfg(test)]
635mod tests {
636 use super::*;
637 use std::fs;
638 use tempfile::TempDir;
639
640 #[test]
645 fn parse_hunk_header_with_count() {
646 let result = parse_hunk_header("@@ -10,5 +20,3 @@ fn foo()");
647 assert_eq!(result, Some((20, 3)));
648 }
649
650 #[test]
651 fn parse_hunk_header_single_line() {
652 let result = parse_hunk_header("@@ -10 +20 @@ fn foo()");
653 assert_eq!(result, Some((20, 1)));
654 }
655
656 #[test]
657 fn parse_hunk_header_no_plus() {
658 let result = parse_hunk_header("not a hunk header");
659 assert_eq!(result, None);
660 }
661
662 #[test]
667 fn generate_comparison_mutations() {
668 let mutants = generate_mutations(Path::new("test.rs"), 1, " if x == 5 {");
669 let comparison: Vec<_> = mutants
670 .iter()
671 .filter(|m| m.operator == MutationOperator::FlipComparison)
672 .collect();
673 assert!(!comparison.is_empty());
674 assert!(comparison[0].mutated.contains("!="));
675 }
676
677 #[test]
678 fn generate_logical_mutations() {
679 let mutants = generate_mutations(Path::new("test.rs"), 1, " if a && b {");
680 let logical: Vec<_> = mutants
681 .iter()
682 .filter(|m| m.operator == MutationOperator::FlipLogical)
683 .collect();
684 assert!(!logical.is_empty());
685 assert!(logical[0].mutated.contains("||"));
686 }
687
688 #[test]
689 fn generate_boolean_mutations() {
690 let mutants = generate_mutations(Path::new("test.rs"), 1, " let flag = true;");
691 let booleans: Vec<_> = mutants
692 .iter()
693 .filter(|m| m.operator == MutationOperator::SwapBoolean)
694 .collect();
695 assert!(!booleans.is_empty());
696 assert!(booleans[0].mutated.contains("false"));
697 }
698
699 #[test]
700 fn generate_boolean_word_boundary() {
701 let mutants = generate_mutations(Path::new("test.rs"), 1, " let truecolor = 1;");
703 let booleans: Vec<_> = mutants
704 .iter()
705 .filter(|m| m.operator == MutationOperator::SwapBoolean)
706 .collect();
707 assert!(booleans.is_empty());
708 }
709
710 #[test]
711 fn generate_arithmetic_mutations() {
712 let mutants = generate_mutations(Path::new("test.rs"), 1, " let total = a + b;");
713 let arith: Vec<_> = mutants
714 .iter()
715 .filter(|m| m.operator == MutationOperator::FlipArithmetic)
716 .collect();
717 assert!(!arith.is_empty());
718 assert!(arith[0].mutated.contains(" - "));
719 }
720
721 #[test]
722 fn generate_delete_mutations() {
723 let mutants = generate_mutations(Path::new("test.rs"), 1, " println!(\"hello\");");
724 let deletes: Vec<_> = mutants
725 .iter()
726 .filter(|m| m.operator == MutationOperator::DeleteLine)
727 .collect();
728 assert!(!deletes.is_empty());
729 assert!(deletes[0].mutated.is_empty());
730 }
731
732 #[test]
733 fn skip_comment_lines() {
734 let mutants = generate_mutations(Path::new("test.rs"), 1, " // if x == 5 {");
735 assert!(mutants.is_empty());
736 }
737
738 #[test]
739 fn skip_trivial_lines() {
740 let mutants_brace = generate_mutations(Path::new("test.rs"), 1, " }");
741 let deletes: Vec<_> = mutants_brace
742 .iter()
743 .filter(|m| m.operator == MutationOperator::DeleteLine)
744 .collect();
745 assert!(deletes.is_empty());
746 }
747
748 #[test]
749 fn skip_import_lines_for_arithmetic() {
750 let mutants = generate_mutations(Path::new("test.rs"), 1, "use std::ops::{Add + Sub};");
751 let arith: Vec<_> = mutants
752 .iter()
753 .filter(|m| m.operator == MutationOperator::FlipArithmetic)
754 .collect();
755 assert!(arith.is_empty());
756 }
757
758 #[test]
763 fn apply_mutation_replaces_line() {
764 let content = "line1\nline2\nline3\n";
765 let result = apply_line_mutation(content, 2, "MUTATED");
766 assert_eq!(result, "line1\nMUTATED\nline3\n");
767 }
768
769 #[test]
770 fn apply_mutation_deletes_line() {
771 let content = "line1\nline2\nline3\n";
772 let result = apply_line_mutation(content, 2, "");
773 assert_eq!(result, "line1\nline3\n");
774 }
775
776 #[test]
777 fn apply_mutation_first_line() {
778 let content = "line1\nline2\n";
779 let result = apply_line_mutation(content, 1, "FIRST");
780 assert_eq!(result, "FIRST\nline2\n");
781 }
782
783 #[test]
784 fn apply_mutation_last_line() {
785 let content = "line1\nline2\n";
786 let result = apply_line_mutation(content, 2, "LAST");
787 assert_eq!(result, "line1\nLAST\n");
788 }
789
790 #[test]
795 fn parse_unified_diff_basic() {
796 let diff = r#"diff --git a/src/main.rs b/src/main.rs
797index abc..def 100644
798--- a/src/main.rs
799+++ b/src/main.rs
800@@ -10,2 +10,3 @@ fn main() {
801 let x = 1;
802+ let y = 2;
803+ let z = x + y;
804 println!("{}", x);
805"#;
806 let hunks = parse_unified_diff(diff).unwrap();
807 assert_eq!(hunks.len(), 1);
808 assert_eq!(hunks[0].file, PathBuf::from("src/main.rs"));
809 assert_eq!(hunks[0].added_lines.len(), 2);
810 assert!(hunks[0].added_lines.contains(&11));
811 assert!(hunks[0].added_lines.contains(&12));
812 }
813
814 #[test]
815 fn parse_unified_diff_multiple_files() {
816 let diff = r#"diff --git a/a.rs b/a.rs
817--- a/a.rs
818+++ b/a.rs
819@@ -1,1 +1,2 @@
820 existing
821+new line in a
822diff --git a/b.rs b/b.rs
823--- a/b.rs
824+++ b/b.rs
825@@ -5,0 +6,1 @@
826+new line in b
827"#;
828 let hunks = parse_unified_diff(diff).unwrap();
829 assert_eq!(hunks.len(), 2);
830 assert_eq!(hunks[0].file, PathBuf::from("a.rs"));
831 assert_eq!(hunks[1].file, PathBuf::from("b.rs"));
832 }
833
834 #[test]
835 fn parse_unified_diff_empty() {
836 let hunks = parse_unified_diff("").unwrap();
837 assert!(hunks.is_empty());
838 }
839
840 fn setup_git_repo() -> (TempDir, PathBuf) {
845 let dir = TempDir::new().unwrap();
846 let root = dir.path().to_path_buf();
847
848 Command::new("git")
850 .args(["init"])
851 .current_dir(&root)
852 .stdout(std::process::Stdio::null())
853 .stderr(std::process::Stdio::null())
854 .status()
855 .unwrap();
856 Command::new("git")
857 .args(["config", "user.email", "test@test.com"])
858 .current_dir(&root)
859 .output()
860 .unwrap();
861 Command::new("git")
862 .args(["config", "user.name", "Test"])
863 .current_dir(&root)
864 .output()
865 .unwrap();
866
867 fs::write(
869 root.join("main.rs"),
870 "fn main() {\n println!(\"hello\");\n}\n",
871 )
872 .unwrap();
873 Command::new("git")
874 .args(["add", "-A"])
875 .current_dir(&root)
876 .output()
877 .unwrap();
878 Command::new("git")
879 .args(["commit", "-m", "initial"])
880 .current_dir(&root)
881 .output()
882 .unwrap();
883
884 (dir, root)
885 }
886
887 #[test]
888 fn mutation_test_kills_mutant() {
889 let (_dir, root) = setup_git_repo();
890
891 fs::write(
893 root.join("main.rs"),
894 "fn main() {\n let x = true;\n println!(\"hello\");\n}\n",
895 )
896 .unwrap();
897 Command::new("git")
898 .args(["add", "-A"])
899 .current_dir(&root)
900 .output()
901 .unwrap();
902
903 let report = run_mutation_test(
905 &root,
906 "grep -q 'true' main.rs",
907 &MutateOpts {
908 timeout_secs: Some(10),
909 ..Default::default()
910 },
911 )
912 .unwrap();
913
914 assert!(report.total > 0);
916 let bool_killed: Vec<_> = report
918 .results
919 .iter()
920 .filter(|r| r.mutant.operator == MutationOperator::SwapBoolean && r.killed)
921 .collect();
922 assert!(!bool_killed.is_empty(), "Boolean mutant should be killed");
923 }
924
925 #[test]
926 fn mutation_test_detects_survivor() {
927 let (_dir, root) = setup_git_repo();
928
929 fs::write(
931 root.join("main.rs"),
932 "fn main() {\n if 1 == 1 { println!(\"yes\"); }\n}\n",
933 )
934 .unwrap();
935 Command::new("git")
936 .args(["add", "-A"])
937 .current_dir(&root)
938 .output()
939 .unwrap();
940
941 let report = run_mutation_test(
943 &root,
944 "true",
945 &MutateOpts {
946 timeout_secs: Some(10),
947 ..Default::default()
948 },
949 )
950 .unwrap();
951
952 assert!(report.total > 0);
954 assert_eq!(report.killed, 0);
955 assert_eq!(report.survived, report.total);
956 }
957
958 #[test]
959 fn mutation_test_no_diff() {
960 let (_dir, root) = setup_git_repo();
961
962 let report = run_mutation_test(&root, "true", &MutateOpts::default()).unwrap();
964
965 assert_eq!(report.total, 0);
966 assert_eq!(report.score, 100.0);
967 }
968
969 #[test]
970 fn mutation_test_max_mutants() {
971 let (_dir, root) = setup_git_repo();
972
973 fs::write(
975 root.join("main.rs"),
976 "fn main() {\n if a == b && c != d {\n let x = a + b;\n let y = true;\n }\n}\n",
977 )
978 .unwrap();
979 Command::new("git")
980 .args(["add", "-A"])
981 .current_dir(&root)
982 .output()
983 .unwrap();
984
985 let report = run_mutation_test(
986 &root,
987 "true",
988 &MutateOpts {
989 max_mutants: 2,
990 timeout_secs: Some(10),
991 ..Default::default()
992 },
993 )
994 .unwrap();
995
996 assert!(report.total <= 2);
997 }
998}