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#[derive(Default, Clone, Copy)]
17pub struct ScoreSummary {
18 pub killed: u64,
19 pub total: u64,
20}
21
22impl ScoreSummary {
23 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 fn add(&mut self, other: ScoreSummary) {
35 self.killed += other.killed;
36 self.total += other.total;
37 }
38}
39
40fn 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 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 let folders = if let Some(folder_path) = folder {
108 vec![folder_path]
109 } else {
110 find_mutation_folders()?
112 };
113
114 let project_commands = commands::for_project(project);
115
116 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
140async 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 let file_path = mutant.file_path.as_deref().unwrap_or("");
178
179 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 db.update_mutant_status(mutant.id, "running", command)?;
189
190 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 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 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
238async 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 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 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 let test_command = if let Some(cmd) = command {
302 cmd
303 } else {
304 project_commands.test_command(&target_file_path, jobs)?
305 };
306
307 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 let mutant_content = fs::read_to_string(&file_path)?;
350 fs::write(&target_file_path, &mutant_content)?;
351
352 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 let score = num_killed as f64 / total_mutants as f64;
366 println!("\nMUTATION SCORE: {:.2}%", score * 100.0);
367
368 generate_report(
369 ¬_killed,
370 folder_path.to_str().unwrap(),
371 &target_file_path,
372 score,
373 )
374 .await?;
375
376 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 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); 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 assert!(enforce_min_score(ScoreSummary { killed: 0, total: 10 }, None).is_ok());
467
468 assert!(enforce_min_score(ScoreSummary { killed: 9, total: 10 }, Some(0.8)).is_ok());
470
471 assert!(enforce_min_score(ScoreSummary { killed: 8, total: 10 }, Some(0.8)).is_ok());
473
474 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 let result = run_command("echo 'test'", 5).await.unwrap();
496 assert!(result);
497
498 let result = run_command("false", 5).await.unwrap();
500 assert!(!result);
501
502 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 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}