1#![cfg_attr(docsrs, feature(doc_cfg))]
45#![warn(missing_docs)]
46#![warn(rust_2018_idioms)]
47
48use std::collections::BTreeMap;
49use std::path::PathBuf;
50use std::time::Duration;
51
52use dev_report::{CheckResult, Evidence, Severity};
53use serde::{Deserialize, Serialize};
54
55mod producer;
56mod runner;
57
58pub use producer::MutateProducer;
59
60#[derive(Debug, Clone)]
81pub struct MutateRun {
82 name: String,
83 version: String,
84 workdir: Option<PathBuf>,
85 workspace: bool,
86 jobs: Option<u32>,
87 timeout: Option<Duration>,
88 exclude_re: Vec<String>,
89 file_filters: Vec<String>,
90 allow_list: Vec<String>,
91}
92
93impl MutateRun {
94 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
99 Self {
100 name: name.into(),
101 version: version.into(),
102 workdir: None,
103 workspace: false,
104 jobs: None,
105 timeout: None,
106 exclude_re: Vec::new(),
107 file_filters: Vec::new(),
108 allow_list: Vec::new(),
109 }
110 }
111
112 pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
114 self.workdir = Some(dir.into());
115 self
116 }
117
118 pub fn workspace(mut self) -> Self {
120 self.workspace = true;
121 self
122 }
123
124 pub fn jobs(mut self, n: u32) -> Self {
127 self.jobs = Some(n);
128 self
129 }
130
131 pub fn timeout(mut self, d: Duration) -> Self {
133 self.timeout = Some(d);
134 self
135 }
136
137 pub fn exclude_re(mut self, pattern: impl Into<String>) -> Self {
140 self.exclude_re.push(pattern.into());
141 self
142 }
143
144 pub fn file(mut self, pattern: impl Into<String>) -> Self {
147 self.file_filters.push(pattern.into());
148 self
149 }
150
151 pub fn allow(mut self, description: impl Into<String>) -> Self {
154 self.allow_list.push(description.into());
155 self
156 }
157
158 pub fn allow_all<I, S>(mut self, descriptions: I) -> Self
160 where
161 I: IntoIterator<Item = S>,
162 S: Into<String>,
163 {
164 self.allow_list
165 .extend(descriptions.into_iter().map(Into::into));
166 self
167 }
168
169 pub fn subject(&self) -> &str {
171 &self.name
172 }
173
174 pub fn subject_version(&self) -> &str {
176 &self.version
177 }
178
179 pub fn execute(&self) -> Result<MutateResult, MutateError> {
185 runner::run(self)
186 }
187
188 pub(crate) fn workdir_path(&self) -> Option<&std::path::Path> {
189 self.workdir.as_deref()
190 }
191
192 pub(crate) fn workspace_flag(&self) -> bool {
193 self.workspace
194 }
195
196 pub(crate) fn jobs_value(&self) -> Option<u32> {
197 self.jobs
198 }
199
200 pub(crate) fn timeout_value(&self) -> Option<Duration> {
201 self.timeout
202 }
203
204 pub(crate) fn exclude_re_view(&self) -> &[String] {
205 &self.exclude_re
206 }
207
208 pub(crate) fn file_filters_view(&self) -> &[String] {
209 &self.file_filters
210 }
211
212 pub(crate) fn allow_list_view(&self) -> &[String] {
213 &self.allow_list
214 }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct SurvivingMutant {
224 pub file: String,
226 pub line: u32,
228 pub description: String,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub function: Option<String>,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct FileBreakdown {
242 pub file: String,
244 pub killed: u64,
246 pub survived: u64,
248 pub timeout: u64,
250}
251
252impl FileBreakdown {
253 pub fn kill_pct(&self) -> f64 {
255 let counted = self.killed + self.survived;
256 if counted == 0 {
257 return 0.0;
258 }
259 (self.killed as f64 / counted as f64) * 100.0
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct MutateResult {
270 pub name: String,
272 pub version: String,
274 pub mutants_total: u64,
276 pub mutants_killed: u64,
278 pub mutants_survived: u64,
280 pub mutants_timeout: u64,
282 pub survivors: Vec<SurvivingMutant>,
284 #[serde(default, skip_serializing_if = "Vec::is_empty")]
286 pub files: Vec<FileBreakdown>,
287}
288
289impl MutateResult {
290 pub fn kill_pct(&self) -> f64 {
294 let counted = self.mutants_killed + self.mutants_survived;
295 if counted == 0 {
296 return 0.0;
297 }
298 (self.mutants_killed as f64 / counted as f64) * 100.0
299 }
300
301 pub fn meets(&self, threshold: MutateThreshold) -> bool {
306 match threshold {
307 MutateThreshold::MinKillPct(target) => self.kill_pct() >= target,
308 }
309 }
310
311 pub fn weakest_files(&self, n: usize) -> Vec<&FileBreakdown> {
314 let mut refs: Vec<&FileBreakdown> = self.files.iter().collect();
315 refs.sort_by(|a, b| {
316 a.kill_pct()
317 .partial_cmp(&b.kill_pct())
318 .unwrap_or(std::cmp::Ordering::Equal)
319 });
320 refs.into_iter().take(n).collect()
321 }
322
323 pub fn into_check_result(self, threshold: MutateThreshold) -> CheckResult {
332 let name = format!("mutate::{}", self.name);
333 let kill_pct = self.kill_pct();
334 let MutateThreshold::MinKillPct(target) = threshold;
335 let detail = format!(
336 "kill rate {:.2}% ({}/{}; {} timeouts; {} survivors)",
337 kill_pct,
338 self.mutants_killed,
339 self.mutants_killed + self.mutants_survived,
340 self.mutants_timeout,
341 self.mutants_survived
342 );
343 let mut check = if kill_pct < target {
344 CheckResult::fail(name, Severity::Warning).with_detail(detail)
345 } else {
346 CheckResult::pass(name).with_detail(detail)
347 };
348 check = check
349 .with_tag("mutate")
350 .with_evidence(Evidence::numeric("kill_pct", kill_pct))
351 .with_evidence(Evidence::numeric("kill_pct_threshold", target))
352 .with_evidence(Evidence::numeric_int(
353 "mutants_killed",
354 self.mutants_killed as i64,
355 ))
356 .with_evidence(Evidence::numeric_int(
357 "mutants_survived",
358 self.mutants_survived as i64,
359 ))
360 .with_evidence(Evidence::numeric_int(
361 "mutants_timeout",
362 self.mutants_timeout as i64,
363 ));
364 if let Some(first) = self.survivors.first() {
365 check = check.with_evidence(Evidence::file_ref_lines(
366 "first_survivor",
367 first.file.clone(),
368 first.line.max(1),
369 first.line.max(1),
370 ));
371 }
372 check
373 }
374}
375
376#[derive(Debug, Clone, Copy)]
382pub enum MutateThreshold {
383 MinKillPct(f64),
385}
386
387impl MutateThreshold {
388 pub fn min_kill_pct(pct: f64) -> Self {
390 Self::MinKillPct(pct)
391 }
392}
393
394#[derive(Debug)]
400pub enum MutateError {
401 ToolNotInstalled,
403 SubprocessFailed(String),
405 ParseError(String),
407}
408
409impl std::fmt::Display for MutateError {
410 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411 match self {
412 Self::ToolNotInstalled => write!(
413 f,
414 "cargo-mutants is not installed; run `cargo install cargo-mutants`"
415 ),
416 Self::SubprocessFailed(s) => write!(f, "cargo mutants failed: {s}"),
417 Self::ParseError(s) => write!(f, "could not parse cargo mutants output: {s}"),
418 }
419 }
420}
421
422impl std::error::Error for MutateError {}
423
424pub(crate) fn aggregate_breakdown(
429 by_file_killed: &BTreeMap<String, u64>,
430 by_file_survived: &BTreeMap<String, u64>,
431 by_file_timeout: &BTreeMap<String, u64>,
432) -> Vec<FileBreakdown> {
433 let mut all_files: BTreeMap<String, FileBreakdown> = BTreeMap::new();
434 for (file, count) in by_file_killed {
435 all_files
436 .entry(file.clone())
437 .or_insert(FileBreakdown {
438 file: file.clone(),
439 killed: 0,
440 survived: 0,
441 timeout: 0,
442 })
443 .killed = *count;
444 }
445 for (file, count) in by_file_survived {
446 all_files
447 .entry(file.clone())
448 .or_insert(FileBreakdown {
449 file: file.clone(),
450 killed: 0,
451 survived: 0,
452 timeout: 0,
453 })
454 .survived = *count;
455 }
456 for (file, count) in by_file_timeout {
457 all_files
458 .entry(file.clone())
459 .or_insert(FileBreakdown {
460 file: file.clone(),
461 killed: 0,
462 survived: 0,
463 timeout: 0,
464 })
465 .timeout = *count;
466 }
467 all_files.into_values().collect()
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use dev_report::Verdict;
474
475 fn sample(killed: u64, survived: u64, timeout: u64) -> MutateResult {
476 MutateResult {
477 name: "x".into(),
478 version: "0.1.0".into(),
479 mutants_total: killed + survived + timeout,
480 mutants_killed: killed,
481 mutants_survived: survived,
482 mutants_timeout: timeout,
483 survivors: Vec::new(),
484 files: Vec::new(),
485 }
486 }
487
488 #[test]
489 fn kill_pct_excludes_timeouts() {
490 let r = sample(80, 20, 10);
492 assert!((r.kill_pct() - 80.0).abs() < 1e-9);
493 }
494
495 #[test]
496 fn kill_pct_zero_when_nothing_counted() {
497 let r = sample(0, 0, 5);
498 assert_eq!(r.kill_pct(), 0.0);
499 }
500
501 #[test]
502 fn threshold_pass_when_above() {
503 let r = sample(85, 15, 0);
504 let c = r.into_check_result(MutateThreshold::min_kill_pct(80.0));
505 assert_eq!(c.verdict, Verdict::Pass);
506 assert!(c.has_tag("mutate"));
507 assert!(c.evidence.iter().any(|e| e.label == "kill_pct"));
508 assert!(c.evidence.iter().any(|e| e.label == "kill_pct_threshold"));
509 }
510
511 #[test]
512 fn threshold_fail_uses_warning_severity() {
513 let r = sample(50, 50, 0);
514 let c = r.into_check_result(MutateThreshold::min_kill_pct(80.0));
515 assert_eq!(c.verdict, Verdict::Fail);
516 assert_eq!(c.severity, Some(Severity::Warning));
517 }
518
519 #[test]
520 fn meets_helper_matches_into_check_result() {
521 let r = sample(85, 15, 0);
522 assert!(r.meets(MutateThreshold::min_kill_pct(80.0)));
523 assert!(!r.meets(MutateThreshold::min_kill_pct(95.0)));
524 }
525
526 #[test]
527 fn first_survivor_attached_as_evidence() {
528 let mut r = sample(80, 20, 0);
529 r.survivors = vec![SurvivingMutant {
530 file: "src/lib.rs".into(),
531 line: 42,
532 description: "replace + with -".into(),
533 function: Some("add".into()),
534 }];
535 let c = r.into_check_result(MutateThreshold::min_kill_pct(85.0));
536 assert!(c.evidence.iter().any(|e| e.label == "first_survivor"));
538 }
539
540 #[test]
541 fn weakest_files_sorts_ascending_by_kill_pct() {
542 let r = MutateResult {
543 name: "x".into(),
544 version: "0.1.0".into(),
545 mutants_total: 0,
546 mutants_killed: 0,
547 mutants_survived: 0,
548 mutants_timeout: 0,
549 survivors: Vec::new(),
550 files: vec![
551 FileBreakdown {
552 file: "a.rs".into(),
553 killed: 9,
554 survived: 1,
555 timeout: 0,
556 },
557 FileBreakdown {
558 file: "b.rs".into(),
559 killed: 5,
560 survived: 5,
561 timeout: 0,
562 },
563 FileBreakdown {
564 file: "c.rs".into(),
565 killed: 7,
566 survived: 3,
567 timeout: 0,
568 },
569 ],
570 };
571 let weakest = r.weakest_files(2);
572 assert_eq!(weakest.len(), 2);
573 assert_eq!(weakest[0].file, "b.rs"); assert_eq!(weakest[1].file, "c.rs"); }
576
577 #[test]
578 fn file_breakdown_kill_pct() {
579 let f = FileBreakdown {
580 file: "x".into(),
581 killed: 7,
582 survived: 3,
583 timeout: 0,
584 };
585 assert!((f.kill_pct() - 70.0).abs() < 1e-9);
586 }
587
588 #[test]
589 fn result_round_trips_through_json() {
590 let mut r = sample(80, 20, 0);
591 r.survivors.push(SurvivingMutant {
592 file: "src/lib.rs".into(),
593 line: 1,
594 description: "x".into(),
595 function: None,
596 });
597 let s = serde_json::to_string(&r).unwrap();
598 let back: MutateResult = serde_json::from_str(&s).unwrap();
599 assert_eq!(back.survivors.len(), 1);
600 }
601
602 #[test]
603 fn builder_chain_compiles() {
604 let r = MutateRun::new("x", "0.1.0")
605 .workspace()
606 .jobs(4)
607 .timeout(Duration::from_secs(120))
608 .exclude_re(r"^src/generated/")
609 .file("src/lib.rs")
610 .allow("replace + with -")
611 .allow_all(["a", "b"]);
612 assert_eq!(r.subject(), "x");
613 assert_eq!(r.subject_version(), "0.1.0");
614 assert!(r.workspace_flag());
615 assert_eq!(r.jobs_value(), Some(4));
616 assert_eq!(r.exclude_re_view().len(), 1);
617 assert_eq!(r.file_filters_view().len(), 1);
618 assert_eq!(r.allow_list_view().len(), 3);
619 }
620}