Skip to main content

mana/commands/
mutate.rs

1use std::path::Path;
2
3use anyhow::{anyhow, Context, Result};
4
5use crate::discovery::find_unit_file;
6use crate::unit::Unit;
7use mana_core::config::Config;
8use mana_core::ops::mutate::{run_mutation_test, MutateOpts, MutationReport};
9
10/// Arguments for the mutate command.
11pub struct MutateArgs {
12    /// Unit ID to mutation-test.
13    pub id: String,
14    /// Maximum mutants to test (0 = all).
15    pub max_mutants: usize,
16    /// Timeout per verify run in seconds.
17    pub timeout: Option<u64>,
18    /// Git ref to diff against.
19    pub diff_base: String,
20    /// Output as JSON.
21    pub json: bool,
22}
23
24/// Run mutation testing against a unit's verify gate.
25///
26/// Loads the unit, runs its verify to confirm it passes first, then mutates
27/// the git diff and re-runs verify for each mutant. Reports surviving mutants.
28pub fn cmd_mutate(mana_dir: &Path, args: MutateArgs) -> Result<()> {
29    let unit_path =
30        find_unit_file(mana_dir, &args.id).map_err(|_| anyhow!("Unit not found: {}", args.id))?;
31    let unit =
32        Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", args.id))?;
33
34    let verify_cmd = unit
35        .verify
36        .as_deref()
37        .filter(|v| !v.trim().is_empty())
38        .ok_or_else(|| anyhow!("Unit {} has no verify command", args.id))?;
39
40    let project_root = mana_dir
41        .parent()
42        .ok_or_else(|| anyhow!("Cannot determine project root from .mana/ dir"))?;
43
44    // Determine timeout
45    let config = Config::load(mana_dir).ok();
46    let timeout = args
47        .timeout
48        .or_else(|| unit.effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout)));
49
50    // First, confirm verify passes on the clean code
51    eprintln!("Confirming verify passes on clean code...");
52    let baseline = mana_core::ops::verify::run_verify_command(verify_cmd, project_root, timeout)?;
53    if !baseline.passed {
54        eprintln!("✗ Verify does not pass on clean code. Fix it before mutation testing.");
55        eprintln!("  Command: {}", verify_cmd);
56        if let Some(code) = baseline.exit_code {
57            eprintln!("  Exit code: {}", code);
58        }
59        std::process::exit(1);
60    }
61    eprintln!("✓ Verify passes on clean code\n");
62
63    // Run mutation testing
64    eprintln!("Running mutation tests against: {}", verify_cmd);
65    eprintln!("Diff base: {}", args.diff_base);
66    if args.max_mutants > 0 {
67        eprintln!("Max mutants: {}", args.max_mutants);
68    }
69    eprintln!();
70
71    let opts = MutateOpts {
72        max_mutants: args.max_mutants,
73        timeout_secs: timeout,
74        diff_base: args.diff_base,
75    };
76
77    let report = run_mutation_test(project_root, verify_cmd, &opts)?;
78
79    if args.json {
80        print_json_report(&report, &args.id);
81    } else {
82        print_human_report(&report, &args.id);
83    }
84
85    // Exit with non-zero if any mutants survived
86    if report.survived > 0 {
87        std::process::exit(1);
88    }
89
90    Ok(())
91}
92
93fn print_human_report(report: &MutationReport, id: &str) {
94    if report.total == 0 {
95        println!("No mutants generated — no changed lines found in git diff.");
96        println!("Tip: Make sure you have uncommitted or staged changes.");
97        return;
98    }
99
100    println!("Mutation Testing Report — Unit {}", id);
101    println!("{}", "─".repeat(50));
102    println!(
103        "Total mutants: {}  |  Killed: {}  |  Survived: {}  |  Timed out: {}",
104        report.total, report.killed, report.survived, report.timed_out
105    );
106    println!("Mutation score: {:.1}%", report.score);
107    println!();
108
109    // Show surviving mutants (the actionable items)
110    let survivors: Vec<_> = report.results.iter().filter(|r| !r.killed).collect();
111    if !survivors.is_empty() {
112        println!("⚠ Surviving mutants (verify still passes with these changes):");
113        println!();
114        for (i, result) in survivors.iter().enumerate() {
115            let m = &result.mutant;
116            println!(
117                "  {}. {}:{} [{}]",
118                i + 1,
119                m.file.display(),
120                m.line_number,
121                m.operator,
122            );
123            println!("     original: {}", m.original.trim());
124            println!(
125                "     mutated:  {}",
126                if m.mutated.is_empty() {
127                    "<deleted>"
128                } else {
129                    m.mutated.trim()
130                }
131            );
132            println!();
133        }
134        println!("These mutations were NOT caught by the verify gate.");
135        println!("Consider strengthening the verify command to detect these changes.");
136    } else {
137        println!("✓ All mutants killed — verify gate is strong.");
138    }
139}
140
141fn print_json_report(report: &MutationReport, id: &str) {
142    let survivors: Vec<serde_json::Value> = report
143        .results
144        .iter()
145        .filter(|r| !r.killed)
146        .map(|r| {
147            serde_json::json!({
148                "file": r.mutant.file.display().to_string(),
149                "line": r.mutant.line_number,
150                "operator": r.mutant.operator.to_string(),
151                "original": r.mutant.original.trim(),
152                "mutated": if r.mutant.mutated.is_empty() { "<deleted>" } else { r.mutant.mutated.trim() },
153            })
154        })
155        .collect();
156
157    let json = serde_json::json!({
158        "id": id,
159        "total": report.total,
160        "killed": report.killed,
161        "survived": report.survived,
162        "timed_out": report.timed_out,
163        "score": report.score,
164        "survivors": survivors,
165    });
166
167    println!("{}", serde_json::to_string_pretty(&json).unwrap());
168}