Skip to main content

bcore_mutation/
analyze.rs

1use crate::commands::{self, ProjectCommands};
2use crate::db::Database;
3use crate::error::{MutationError, Result};
4use crate::project::Project;
5use crate::report::generate_report;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9use tempfile::NamedTempFile;
10use tokio::process::Command as TokioCommand;
11use tokio::time::timeout;
12use walkdir::WalkDir;
13
14/// Killed/total counts produced by an analysis pass. Used to compute the
15/// mutation score and apply the optional `--min-score` CI gate.
16#[derive(Default, Clone, Copy)]
17pub struct ScoreSummary {
18    pub killed: u64,
19    pub total: u64,
20}
21
22impl ScoreSummary {
23    /// Mutation score as a fraction in `[0.0, 1.0]` (killed / total).
24    /// Returns `0.0` when no mutants were analyzed.
25    pub fn score(&self) -> f64 {
26        if self.total == 0 {
27            0.0
28        } else {
29            self.killed as f64 / self.total as f64
30        }
31    }
32
33    /// Accumulate another pass's counts (used to aggregate across folders).
34    fn add(&mut self, other: ScoreSummary) {
35        self.killed += other.killed;
36        self.total += other.total;
37    }
38}
39
40/// Fail (return an error) when `min_score` is set and the achieved mutation
41/// score is below it. This is the CI gate: an `Err` here propagates out of
42/// `main` and exits the process with a non-zero status.
43fn enforce_min_score(summary: ScoreSummary, min_score: Option<f64>) -> Result<()> {
44    let Some(min) = min_score else {
45        return Ok(());
46    };
47
48    let score = summary.score();
49    println!(
50        "\nOverall mutation score: {:.2}% ({}/{} killed); required minimum: {:.2}%",
51        score * 100.0,
52        summary.killed,
53        summary.total,
54        min * 100.0
55    );
56
57    if score < min {
58        return Err(MutationError::ScoreBelowThreshold(format!(
59            "mutation score {:.2}% is below the required minimum of {:.2}%",
60            score * 100.0,
61            min * 100.0
62        )));
63    }
64
65    println!("Mutation score meets the required minimum ✅");
66    Ok(())
67}
68
69pub async fn run_analysis(
70    project: Project,
71    folder: Option<PathBuf>,
72    command: Option<String>,
73    jobs: u32,
74    timeout_secs: u64,
75    survival_threshold: f64,
76    min_score: Option<f64>,
77    sqlite_path: Option<PathBuf>,
78    run_id: Option<i64>,
79    file_path: Option<String>,
80    survivors_only: bool,
81) -> Result<()> {
82    println!("Analyzing mutants for project: {}", project.db_name());
83
84    // DB-based analysis mode: read mutants from DB and test them.
85    if let (Some(ref path), Some(rid)) = (sqlite_path.as_ref(), run_id) {
86        let command = command.ok_or_else(|| {
87            MutationError::InvalidInput(
88                "--command is required when using --sqlite with --run_id".to_string(),
89            )
90        })?;
91        let db = Database::open(path)?;
92        db.ensure_schema()?;
93        db.seed_projects()?;
94        let summary = run_db_analysis(
95            &db,
96            rid,
97            &command,
98            timeout_secs,
99            file_path.as_deref(),
100            survivors_only,
101        )
102        .await?;
103        return enforce_min_score(summary, min_score);
104    }
105
106    // Folder-based analysis mode (existing behaviour).
107    let folders = if let Some(folder_path) = folder {
108        vec![folder_path]
109    } else {
110        // Find all folders starting with "muts"
111        find_mutation_folders()?
112    };
113
114    let project_commands = commands::for_project(project);
115
116    // When we derive the test command ourselves (no --command), do the one-time
117    // clean build up front rather than once per folder. Each mutant still
118    // triggers an incremental rebuild inside its test command.
119    if command.is_none() && !folders.is_empty() {
120        run_build_command(project_commands.as_ref()).await?;
121    }
122
123    let mut overall = ScoreSummary::default();
124    for folder_path in folders {
125        let summary = analyze_folder(
126            &folder_path,
127            command.clone(),
128            jobs,
129            timeout_secs,
130            survival_threshold,
131            project_commands.as_ref(),
132        )
133        .await?;
134        overall.add(summary);
135    }
136
137    enforce_min_score(overall, min_score)
138}
139
140/// Test all pending mutants in `run_id` from the database, optionally filtered by `file_path`.
141/// When `survivors_only` is true, only previously survived mutants are analyzed.
142async fn run_db_analysis(
143    db: &Database,
144    run_id: i64,
145    command: &str,
146    timeout_secs: u64,
147    file_path: Option<&str>,
148    survivors_only: bool,
149) -> Result<ScoreSummary> {
150    let mutants = db.get_mutants_for_run(run_id, file_path, survivors_only)?;
151    let total = mutants.len();
152
153    match (file_path, survivors_only) {
154        (Some(fp), true) => println!(
155            "* {} SURVIVING MUTANTS in run_id={} (file: {}) *",
156            total, run_id, fp
157        ),
158        (Some(fp), false) => println!("* {} MUTANTS in run_id={} (file: {}) *", total, run_id, fp),
159        (None, true) => println!("* {} SURVIVING MUTANTS in run_id={} *", total, run_id),
160        (None, false) => println!("* {} MUTANTS in run_id={} *", total, run_id),
161    }
162
163    if total == 0 {
164        return Err(MutationError::InvalidInput(format!(
165            "No mutants found for run_id={}",
166            run_id
167        )));
168    }
169
170    let mut num_killed: u64 = 0;
171    let mut num_survived: u64 = 0;
172
173    for (i, mutant) in mutants.iter().enumerate() {
174        println!("[{}/{}] Analyzing mutant id={}", i + 1, total, mutant.id);
175
176        // Determine the file path to restore later.
177        let file_path = mutant.file_path.as_deref().unwrap_or("");
178
179        // Ensure the file is at HEAD before applying the mutant diff.
180        // A previous mutant may have been left applied if restore silently failed.
181        if !file_path.is_empty() {
182            if let Err(e) = restore_file(file_path).await {
183                eprintln!("  Warning: pre-restore failed for {}: {}", file_path, e);
184            }
185        }
186
187        // Update status to 'running' and record the command.
188        db.update_mutant_status(mutant.id, "running", command)?;
189
190        // Write the patch to a temp file and apply it with `git apply`.
191        let apply_result = apply_diff(&mutant.diff).await;
192        if let Err(ref e) = apply_result {
193            eprintln!("  Failed to apply diff for mutant {}: {}", mutant.id, e);
194            db.update_mutant_status(mutant.id, "error", command)?;
195            continue;
196        }
197
198        // Run the test command.
199        let killed = !run_command(command, timeout_secs).await?;
200
201        let new_status = if killed {
202            println!("  KILLED ✅");
203            num_killed += 1;
204            "killed"
205        } else {
206            println!("  NOT KILLED ❌");
207            num_survived += 1;
208            "survived"
209        };
210
211        db.update_mutant_status(mutant.id, new_status, command)?;
212
213        // Restore the modified file.
214        if !file_path.is_empty() {
215            restore_file(file_path).await?;
216        }
217    }
218
219    let score = if total > 0 {
220        num_killed as f64 / total as f64
221    } else {
222        0.0
223    };
224    println!(
225        "\nMUTATION SCORE: {:.2}% ({} killed / {} total)",
226        score * 100.0,
227        num_killed,
228        total
229    );
230    println!("Survived: {}", num_survived);
231
232    Ok(ScoreSummary {
233        killed: num_killed,
234        total: total as u64,
235    })
236}
237
238/// Apply a unified diff patch using `git apply`.
239async fn apply_diff(diff: &str) -> Result<()> {
240    use std::io::Write;
241
242    let mut tmp = NamedTempFile::new()?;
243    tmp.write_all(diff.as_bytes())?;
244    tmp.flush()?;
245
246    let tmp_path = tmp.path().to_path_buf();
247    // Keep `tmp` alive until after the command runs.
248    let output = TokioCommand::new("git")
249        .args(["apply", "--whitespace=nowarn", tmp_path.to_str().unwrap()])
250        .output()
251        .await
252        .map_err(|e| MutationError::Git(format!("git apply failed: {}", e)))?;
253
254    if !output.status.success() {
255        let stderr = String::from_utf8_lossy(&output.stderr);
256        return Err(MutationError::Git(format!(
257            "git apply error: {}",
258            stderr.trim()
259        )));
260    }
261
262    Ok(())
263}
264
265fn find_mutation_folders() -> Result<Vec<PathBuf>> {
266    let mut folders = Vec::new();
267
268    for entry in WalkDir::new(".").max_depth(1) {
269        let entry = entry?;
270        if entry.file_type().is_dir() {
271            if let Some(name) = entry.file_name().to_str() {
272                if name.starts_with("muts") {
273                    folders.push(entry.path().to_path_buf());
274                }
275            }
276        }
277    }
278
279    Ok(folders)
280}
281
282pub async fn analyze_folder(
283    folder_path: &Path,
284    command: Option<String>,
285    jobs: u32,
286    timeout_secs: u64,
287    survival_threshold: f64,
288    project_commands: &dyn ProjectCommands,
289) -> Result<ScoreSummary> {
290    let mut num_killed: u64 = 0;
291    let mut not_killed = Vec::new();
292
293    // Read target file path
294    let original_file_path = folder_path.join("original_file.txt");
295    let target_file_path = fs::read_to_string(original_file_path)?;
296    let target_file_path = target_file_path.trim();
297
298    // Derive the test command when one isn't provided. The clean build is done
299    // once by the caller (run_analysis); here we only build the per-file test
300    // command, whose incremental `cmake --build` picks up each mutant.
301    let test_command = if let Some(cmd) = command {
302        cmd
303    } else {
304        project_commands.test_command(&target_file_path, jobs)?
305    };
306
307    // Get list of mutant files
308    let mut mutant_files = Vec::new();
309    for entry in fs::read_dir(folder_path)? {
310        let entry = entry?;
311        let path = entry.path();
312        if path.is_file() && !path.extension().map_or(true, |ext| ext == "txt") {
313            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
314                mutant_files.push(name.to_string());
315            }
316        }
317    }
318
319    let total_mutants = mutant_files.len();
320    println!("* {} MUTANTS *", total_mutants);
321
322    if total_mutants == 0 {
323        return Err(MutationError::InvalidInput(format!(
324            "No mutants in the provided folder path ({})",
325            folder_path.display()
326        )));
327    }
328
329    for (i, file_name) in mutant_files.iter().enumerate() {
330        let current_survival_rate = not_killed.len() as f64 / total_mutants as f64;
331        if current_survival_rate > survival_threshold {
332            println!(
333                "\nTerminating early: {:.2}% mutants surviving after {} iterations",
334                current_survival_rate * 100.0,
335                i + 1
336            );
337            println!(
338                "Survival rate exceeds threshold of {:.0}%",
339                survival_threshold * 100.0
340            );
341            break;
342        }
343
344        println!("[{}/{}] Analyzing {}", i + 1, total_mutants, file_name);
345
346        let file_path = folder_path.join(file_name);
347
348        // Read and apply mutant
349        let mutant_content = fs::read_to_string(&file_path)?;
350        fs::write(&target_file_path, &mutant_content)?;
351
352        //println!("Running: {}", test_command);
353        let result = run_command(&test_command, timeout_secs).await?;
354
355        if result {
356            println!("NOT KILLED ❌");
357            not_killed.push(file_name.clone());
358        } else {
359            println!("KILLED ✅");
360            num_killed += 1
361        }
362    }
363
364    // Generate report
365    let score = num_killed as f64 / total_mutants as f64;
366    println!("\nMUTATION SCORE: {:.2}%", score * 100.0);
367
368    generate_report(
369        &not_killed,
370        folder_path.to_str().unwrap(),
371        &target_file_path,
372        score,
373    )
374    .await?;
375
376    // Restore the original file
377    restore_file(&target_file_path).await?;
378
379    Ok(ScoreSummary {
380        killed: num_killed,
381        total: total_mutants as u64,
382    })
383}
384
385async fn run_command(command: &str, timeout_secs: u64) -> Result<bool> {
386    use std::process::Stdio;
387
388    // Split command into shell and arguments for better cross-platform support
389    let (shell, shell_arg) = if cfg!(target_os = "windows") {
390        ("cmd", "/C")
391    } else {
392        ("sh", "-c")
393    };
394
395    println!("Executing command: {}", command);
396
397    let mut cmd = TokioCommand::new(shell);
398    cmd.arg(shell_arg)
399        .arg(command)
400        .stdout(Stdio::piped())
401        .stderr(Stdio::piped())
402        .kill_on_drop(true); // Ensure child process is killed if parent dies
403
404    let timeout_duration = Duration::from_secs(timeout_secs);
405
406    match timeout(timeout_duration, cmd.output()).await {
407        Ok(Ok(output)) => {
408            let stdout = String::from_utf8_lossy(&output.stdout);
409            let stderr = String::from_utf8_lossy(&output.stderr);
410
411            println!("Command exit code: {}", output.status.code().unwrap_or(-1));
412
413            if !stdout.is_empty() {
414                println!("STDOUT:\n{}", stdout);
415            }
416
417            if !stderr.is_empty() {
418                println!("STDERR:\n{}", stderr);
419            }
420
421            Ok(output.status.success())
422        }
423        Ok(Err(e)) => {
424            println!("Command execution failed: {}", e);
425            Ok(false)
426        }
427        Err(_) => {
428            println!("Command timed out after {} seconds", timeout_secs);
429            Ok(false)
430        }
431    }
432}
433
434async fn run_build_command(project_commands: &dyn ProjectCommands) -> Result<()> {
435    let build_command = project_commands.build_command();
436
437    let success = run_command(&build_command, project_commands.build_timeout_secs()).await?;
438    if !success {
439        return Err(MutationError::Command("Build command failed".to_string()));
440    }
441
442    Ok(())
443}
444
445async fn restore_file(target_file_path: &str) -> Result<()> {
446    let restore_command = format!("git restore {}", target_file_path);
447    let success = run_command(&restore_command, 30).await?;
448    if !success {
449        return Err(MutationError::Git(format!(
450            "git restore failed for {}",
451            target_file_path
452        )));
453    }
454    Ok(())
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use std::fs;
461    use tempfile::tempdir;
462
463    #[test]
464    fn test_enforce_min_score() {
465        // No gate configured: always Ok regardless of score.
466        assert!(enforce_min_score(ScoreSummary { killed: 0, total: 10 }, None).is_ok());
467
468        // Above threshold passes.
469        assert!(enforce_min_score(ScoreSummary { killed: 9, total: 10 }, Some(0.8)).is_ok());
470
471        // Exactly at threshold passes (gate uses `<`, so >= passes).
472        assert!(enforce_min_score(ScoreSummary { killed: 8, total: 10 }, Some(0.8)).is_ok());
473
474        // Below threshold fails with the dedicated error variant.
475        let result = enforce_min_score(ScoreSummary { killed: 5, total: 10 }, Some(0.8));
476        assert!(matches!(
477            result,
478            Err(MutationError::ScoreBelowThreshold(_))
479        ));
480    }
481
482    #[test]
483    fn test_score_summary_aggregates() {
484        let mut overall = ScoreSummary::default();
485        overall.add(ScoreSummary { killed: 3, total: 4 });
486        overall.add(ScoreSummary { killed: 1, total: 6 });
487        assert_eq!(overall.killed, 4);
488        assert_eq!(overall.total, 10);
489        assert!((overall.score() - 0.4).abs() < f64::EPSILON);
490    }
491
492    #[tokio::test]
493    async fn test_run_command() {
494        // Test successful command
495        let result = run_command("echo 'test'", 5).await.unwrap();
496        assert!(result);
497
498        // Test failing command
499        let result = run_command("false", 5).await.unwrap();
500        assert!(!result);
501
502        // Test command that should timeout (note: this might be flaky in CI)
503        let result = run_command("sleep 10", 1).await.unwrap();
504        assert!(!result);
505    }
506
507    #[test]
508    fn test_find_mutation_folders() {
509        let temp_dir = tempdir().unwrap();
510        std::env::set_current_dir(&temp_dir).unwrap();
511
512        // Create some test directories
513        fs::create_dir("muts-test-1").unwrap();
514        fs::create_dir("muts-test-2").unwrap();
515        fs::create_dir("not-muts").unwrap();
516        fs::create_dir("another-dir").unwrap();
517
518        let folders = find_mutation_folders().unwrap();
519        assert_eq!(folders.len(), 2);
520
521        let folder_names: Vec<String> = folders
522            .iter()
523            .filter_map(|p| p.file_name().and_then(|n| n.to_str()))
524            .map(|s| s.to_string())
525            .collect();
526
527        assert!(folder_names.contains(&"muts-test-1".to_string()));
528        assert!(folder_names.contains(&"muts-test-2".to_string()));
529        assert!(!folder_names.contains(&"not-muts".to_string()));
530    }
531}