Skip to main content

mana_core/ops/
mutate.rs

1//! Language-agnostic mutation testing for verify gates.
2//!
3//! After a unit passes its verify command, this module mutates the git diff
4//! (flips operators, swaps booleans, deletes lines) and re-runs verify.
5//! Surviving mutants indicate a weak verify gate.
6//!
7//! The approach is git-diff-scoped: only lines that were actually changed
8//! are candidates for mutation, keeping the mutation set focused and fast.
9
10use 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// ---------------------------------------------------------------------------
20// Public types
21// ---------------------------------------------------------------------------
22
23/// A single mutation applied to a source line.
24#[derive(Debug, Clone)]
25pub struct Mutant {
26    /// File path relative to project root.
27    pub file: PathBuf,
28    /// 1-based line number in the file.
29    pub line_number: usize,
30    /// The original line content.
31    pub original: String,
32    /// The mutated line content.
33    pub mutated: String,
34    /// Which operator was applied.
35    pub operator: MutationOperator,
36}
37
38/// The type of mutation applied.
39#[derive(Debug, Clone, PartialEq)]
40pub enum MutationOperator {
41    /// Flip comparison: `==` ↔ `!=`, `<` ↔ `>=`, `>` ↔ `<=`
42    FlipComparison,
43    /// Flip logical: `&&` ↔ `||`
44    FlipLogical,
45    /// Swap boolean: `true` ↔ `false`
46    SwapBoolean,
47    /// Flip arithmetic: `+` ↔ `-`, `*` ↔ `/`
48    FlipArithmetic,
49    /// Delete the entire line (replace with empty/comment).
50    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/// Result of testing a single mutant.
66#[derive(Debug, Clone)]
67pub struct MutantResult {
68    /// The mutant that was tested.
69    pub mutant: Mutant,
70    /// Whether the mutant was killed (verify failed = good).
71    pub killed: bool,
72    /// Whether the verify command timed out.
73    pub timed_out: bool,
74}
75
76/// Summary of a mutation testing run.
77#[derive(Debug)]
78pub struct MutationReport {
79    /// Total mutants generated and tested.
80    pub total: usize,
81    /// Mutants killed by the verify command (verify failed).
82    pub killed: usize,
83    /// Mutants that survived (verify still passed = weak gate).
84    pub survived: usize,
85    /// Mutants where verify timed out (counted as killed).
86    pub timed_out: usize,
87    /// Mutation score as a percentage (killed / total * 100).
88    pub score: f64,
89    /// Details of each mutant test.
90    pub results: Vec<MutantResult>,
91}
92
93/// Options for running mutation tests.
94pub struct MutateOpts {
95    /// Maximum number of mutants to test (0 = all).
96    pub max_mutants: usize,
97    /// Timeout per verify run in seconds.
98    pub timeout_secs: Option<u64>,
99    /// Git ref to diff against (default: HEAD).
100    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
113// ---------------------------------------------------------------------------
114// Core entry point
115// ---------------------------------------------------------------------------
116
117/// Run mutation testing for a unit's verify gate.
118///
119/// 1. Gets the git diff to identify changed lines
120/// 2. Generates mutations for each changed line
121/// 3. Applies each mutation, runs verify, and restores
122/// 4. Returns a report of surviving mutants
123pub fn run_mutation_test(
124    project_root: &Path,
125    verify_cmd: &str,
126    opts: &MutateOpts,
127) -> Result<MutationReport> {
128    // Step 1: Get the diff hunks
129    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    // Step 2: Generate all possible mutants
142    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    // Step 3: Cap mutants if requested
163    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    // Step 4: Test each mutant
180    // Group mutants by file to minimize re-reads
181    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    // Cache original file contents for restoration
187    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        // Read and cache original content
194        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        // Apply mutation
202        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        // Run verify
208        let verify_result = run_verify_command(verify_cmd, project_root, opts.timeout_secs);
209
210        // Restore original
211        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) // timeout = killed
218                } else {
219                    (!vr.passed, false) // verify failed = killed
220                }
221            }
222            Err(_) => (true, false), // error running verify = killed
223        };
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// ---------------------------------------------------------------------------
258// Diff parsing
259// ---------------------------------------------------------------------------
260
261/// A parsed diff hunk: file + which lines were added/changed.
262#[derive(Debug)]
263pub struct DiffHunk {
264    /// File path relative to project root.
265    pub file: PathBuf,
266    /// 1-based line numbers of added/changed lines in the new version.
267    pub added_lines: Vec<usize>,
268}
269
270/// Parse `git diff` output to extract changed lines per file.
271///
272/// Uses `--diff-filter=M` to only look at modified files (not deleted),
273/// and the unified diff format to identify added lines.
274pub fn get_diff_hunks(project_root: &Path, base_ref: &str) -> Result<Vec<DiffHunk>> {
275    // Get the unified diff
276    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        // Try diffing against empty tree (for initial commits)
284        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
299/// Parse unified diff output into DiffHunks.
300fn 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            // Flush previous file
309            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            // Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
320            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            // Added line — this is a mutation candidate
325            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            // Context line (no prefix) — advances line counter
335            new_line_num += 1;
336        }
337        // Lines starting with '-' are deleted — don't advance new line counter
338    }
339
340    // Flush last file
341    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
353/// Parse a unified diff hunk header to extract the new-file range.
354///
355/// Format: `@@ -old_start[,old_count] +new_start[,new_count] @@`
356/// Returns `(start, count)` for the new file side.
357fn parse_hunk_header(line: &str) -> Option<(usize, usize)> {
358    // Find the +N,M or +N part
359    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
374// ---------------------------------------------------------------------------
375// Mutation generation
376// ---------------------------------------------------------------------------
377
378/// Generate all possible mutations for a single source line.
379pub 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    // Skip empty lines, comments, and pure-whitespace
384    if trimmed.is_empty() || is_comment_line(trimmed) {
385        return mutants;
386    }
387
388    // Flip comparison operators
389    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    // Flip logical operators
402    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    // Swap booleans
415    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    // Flip arithmetic operators (only when not in comments/strings — best effort)
428    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    // Delete line — only for non-trivial lines
441    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
454// ---------------------------------------------------------------------------
455// Operator swap tables
456// ---------------------------------------------------------------------------
457
458/// Comparison operator swaps. Each pair is (from, to).
459const COMPARISON_SWAPS: &[(&str, &str)] = &[
460    ("===", "!=="),
461    ("!==", "==="),
462    ("==", "!="),
463    ("!=", "=="),
464    (">=", "<"),
465    ("<=", ">"),
466    // We handle > and < carefully to avoid matching >= and <=
467];
468
469/// Logical operator swaps.
470const LOGICAL_SWAPS: &[(&str, &str)] = &[
471    ("&&", "||"),
472    ("||", "&&"),
473    (" and ", " or "),
474    (" or ", " and "),
475];
476
477/// Boolean literal swaps.
478const BOOLEAN_SWAPS: &[(&str, &str)] = &[
479    ("true", "false"),
480    ("false", "true"),
481    ("True", "False"),
482    ("False", "True"),
483];
484
485/// Arithmetic operator swaps.
486const ARITHMETIC_SWAPS: &[(&str, &str)] = &[
487    (" + ", " - "),
488    (" - ", " + "),
489    (" * ", " / "),
490    (" / ", " * "),
491];
492
493// ---------------------------------------------------------------------------
494// Replacement helpers
495// ---------------------------------------------------------------------------
496
497/// Try to replace an operator in a line. Returns None if the operator isn't found.
498fn try_replace_operator(line: &str, from: &str, to: &str) -> Option<String> {
499    if line.contains(from) {
500        // Replace only the first occurrence to create one mutant per swap
501        Some(line.replacen(from, to, 1))
502    } else {
503        None
504    }
505}
506
507/// Replace a word-boundary-aware token (boolean literals).
508/// Avoids replacing "true" inside "truecolor" etc.
509fn try_replace_word(line: &str, from: &str, to: &str) -> Option<String> {
510    // Find the first occurrence and check word boundaries
511    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
534/// Replace arithmetic operators, avoiding common false positives.
535/// Skips lines that look like imports, includes, or string-heavy lines.
536fn try_replace_arithmetic(line: &str, from: &str, to: &str) -> Option<String> {
537    let trimmed = line.trim();
538    // Skip import/include/use/require lines
539    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
555/// Check if a line is a comment in common languages.
556fn 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
566/// Check if a line is worth deleting as a mutation.
567/// Skips trivial lines like braces, imports, blank-ish content.
568fn is_deletable_line(trimmed: &str) -> bool {
569    // Skip trivial structural lines
570    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    // Skip imports/includes
586    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    // Must have some actual content
599    trimmed.len() > 3
600}
601
602// ---------------------------------------------------------------------------
603// File manipulation
604// ---------------------------------------------------------------------------
605
606/// Replace a specific line in file content and return the new content.
607fn 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            // If replacement is empty, we skip the line (delete mutation)
619        } 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// ---------------------------------------------------------------------------
631// Tests
632// ---------------------------------------------------------------------------
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use std::fs;
638    use tempfile::TempDir;
639
640    // =====================================================================
641    // Hunk header parsing
642    // =====================================================================
643
644    #[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    // =====================================================================
663    // Mutation generation
664    // =====================================================================
665
666    #[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        // Should NOT mutate "true" inside "truecolor"
702        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    // =====================================================================
759    // apply_line_mutation
760    // =====================================================================
761
762    #[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    // =====================================================================
791    // Diff parsing
792    // =====================================================================
793
794    #[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    // =====================================================================
841    // Full integration test with git repo
842    // =====================================================================
843
844    fn setup_git_repo() -> (TempDir, PathBuf) {
845        let dir = TempDir::new().unwrap();
846        let root = dir.path().to_path_buf();
847
848        // Init git
849        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        // Initial commit
868        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        // Make a change that can be mutated
892        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        // Verify command checks for "true" — mutation to "false" will kill it
904        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        // Should have generated at least a boolean swap mutant
915        assert!(report.total > 0);
916        // The boolean swap (true→false) should be killed by grep
917        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        // Make a change with operators
930        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        // Weak verify: always passes
942        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        // All mutants should survive since verify always passes
953        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        // No changes — no mutants
963        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        // Make a change with many mutation candidates
974        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}