use std::io::Write as _;
use super::styler::Styler;
use super::text::quantity;
use crate::Result;
use crate::commands::Host;
use crate::discover::Plan;
use crate::exec::Session;
use crate::model::{Mutant, Outcome, Summary};
use crate::report::encode_controls;
#[cfg(test)]
use crate::{
HashMap,
exec::{OrderingHints, Phases},
};
#[derive(Debug, Clone, Copy, Default)]
pub struct Listings {
pub killed: bool,
pub unviable: bool,
pub announced: bool,
}
fn excluded(plan: &Plan) -> String {
let mut parts: Vec<String> = Vec::new();
if plan.sharded_out > 0 {
parts.push(format!("{} outside this shard", plan.sharded_out));
}
if plan.settled_out > 0 {
parts.push(format!("{} already settled", plan.settled_out));
}
if parts.is_empty() {
return String::new();
}
format!(", {}", parts.join(", "))
}
fn flakes(count: u32) -> String {
if count == 0 {
return String::new();
}
format!(", {} never judged", quantity(count as usize, "flaky mutant"))
}
pub fn skipped<H: Host>(host: &mut H, plan: &Plan, styler: Styler) -> Result<()> {
if plan.skipped.is_empty() {
return Ok(());
}
let mut stream = host.error();
writeln!(
stream,
"{} {} could not be analyzed:",
styler.warning(),
quantity(plan.skipped.len(), "file")
)?;
for note in &plan.skipped {
writeln!(stream, " {}", encode_controls(note))?;
}
Ok(())
}
pub fn summarize<H: Host>(host: &mut H, plan: &Plan, styler: Styler, listings: Listings) -> Result<()> {
skipped(host, plan, styler)?;
let summary = Summary::of(&plan.mutants);
let heading = styler.verb("Summary");
let survivors: Vec<&Mutant> = plan.mutants.iter().filter(|mutant| mutant.outcome == Outcome::Survived).collect();
let mut blocks: Vec<(String, Vec<String>)> = Vec::new();
if !survivors.is_empty() && !listings.announced {
blocks.push((
styler.outcome(Outcome::Survived),
survivors.iter().map(|mutant| mutant.describe()).collect(),
));
}
for outcome in [Outcome::Timeout, Outcome::OutOfMemory, Outcome::Flaky] {
if listings.announced && outcome != Outcome::Flaky {
continue;
}
let lines: Vec<String> = plan
.mutants
.iter()
.filter(|mutant| mutant.outcome == outcome)
.map(|mutant| {
mutant
.note
.as_deref()
.map_or_else(|| mutant.describe(), |note| format!("{}: {note}", mutant.describe()))
})
.collect();
if !lines.is_empty() {
blocks.push((styler.outcome(outcome), lines));
}
}
if listings.killed {
let killed: Vec<String> = plan
.mutants
.iter()
.filter(|mutant| mutant.outcome == Outcome::Killed)
.map(Mutant::describe)
.collect();
if !killed.is_empty() {
blocks.push((styler.outcome(Outcome::Killed), killed));
}
}
if listings.unviable {
let unviable: Vec<String> = plan
.mutants
.iter()
.filter(|mutant| mutant.outcome == Outcome::CompileError)
.map(Mutant::describe)
.collect();
if !unviable.is_empty() {
blocks.push((styler.outcome(Outcome::CompileError), unviable));
}
}
let mut stream = host.output();
for (label, lines) in &blocks {
writeln!(stream)?;
for line in lines {
writeln!(stream, "{label} {}", encode_controls(line))?;
}
}
if !blocks.is_empty() {
writeln!(stream)?;
}
if summary.valid() > 0 {
writeln!(
stream,
"{heading} {} ({} killed, {} survived, {} timed out, {} out of memory, {} uncovered => {}%){}{}",
quantity(summary.valid() as usize, "mutant"),
summary.killed,
summary.survived,
summary.timeout,
summary.out_of_memory,
summary.uncovered,
crate::report::score(summary.score(), summary.detected() as usize, summary.valid() as usize),
excluded(plan),
flakes(summary.flaky)
)?;
} else {
writeln!(
stream,
"{heading} {} in {}, none tested{}",
quantity(plan.mutants.len(), "mutant"),
quantity(plan.files.len(), "file"),
excluded(plan)
)?;
}
Ok(())
}
pub fn session_notes<H: Host>(
host: &mut H,
session: &Session,
hints_missing: bool,
has_suppressible_mutants: bool,
styler: Styler,
) -> Result<()> {
let mut stream = host.error();
if session.widened {
writeln!(
stream,
"{} the narrowed build did not compile, so the whole workspace was built; \
a test target needing a feature another package enables cannot be built alone",
styler.note("Scope")
)?;
}
if session.filtered > 0 {
writeln!(
stream,
"{} {} not consulted, so a survivor here may be one they would have caught",
styler.note("Oracle"),
quantity(session.filtered, "test target")
)?;
}
if hints_missing {
writeln!(
stream,
"{} run `cargo gamma hints` to create `gamma-hints.json` to speed up subsequent runs",
styler.note("Hint")
)?;
}
if has_suppressible_mutants {
writeln!(
stream,
"{} run `cargo gamma suppress` to automatically suppress timed-out and out-of-memory mutants",
styler.note("Hint")
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use core::time::Duration;
use camino::Utf8PathBuf;
use super::*;
use crate::discover::TargetFile;
use crate::fixtures;
use crate::testing::{Sink, fails_at_every_line};
fn mutant(line: usize, outcome: Outcome) -> Mutant {
Mutant {
id: format!("m{line}").into(),
ordinal: u32::try_from(line).unwrap_or(0),
file: (Utf8PathBuf::from("src/a.rs")).into(),
line,
column: 5,
mutator: ("relational.gt_to_ge".to_owned()).into(),
item_path: ("subject::f".to_owned()).into(),
original: "a > b".to_owned().into(),
replacement: "a >= b".to_owned().into(),
outcome,
..fixtures::mutant()
}
}
fn plan() -> Plan {
Plan {
skipped: Vec::new(),
digests: HashMap::default(),
root: Utf8PathBuf::from("/w"),
files: vec![TargetFile {
path: Utf8PathBuf::from("src/a.rs"),
absolute: Utf8PathBuf::from("/w/src/a.rs"),
package: "subject".to_owned(),
}],
mutants: vec![
mutant(1, Outcome::Killed),
mutant(2, Outcome::Survived),
mutant(3, Outcome::Timeout),
],
suppressed: 0,
idle: Vec::new(),
sharded_out: 0,
settled_out: 0,
reach: HashMap::default(),
specs: HashMap::default(),
}
}
fn summary(announced: bool) -> String {
rendered(&plan(), announced)
}
fn rendered(plan: &Plan, announced: bool) -> String {
let mut host = Sink::default();
let listings = Listings {
killed: false,
unviable: false,
announced,
};
summarize(&mut host, plan, Styler::new(false), listings).expect("summarize");
String::from_utf8(host.out).expect("utf-8")
}
fn rendered_with(plan: &Plan, listings: Listings) -> String {
let mut host = Sink::default();
summarize(&mut host, plan, Styler::new(false), listings).expect("summarize");
String::from_utf8(host.out).expect("utf-8")
}
fn session(widened: bool) -> Session {
Session {
ordering: OrderingHints::default(),
census: Vec::new(),
baseline: Duration::from_secs(1),
baseline_wall: Duration::from_secs(1),
tests: None,
quiet: Duration::ZERO,
stall: None,
build: Duration::from_secs(3),
metered: false,
unbounded: None,
withdrawn: 0,
rounds: 1,
rounds_taken: Vec::new(),
binaries: Vec::new(),
peak: None,
scratch: Utf8PathBuf::new(),
filtered: 0,
widened,
phases: Phases::default(),
}
}
#[test]
fn an_oracle_that_lost_test_targets_says_how_many() {
let mut host = Sink::default();
let narrowed = Session {
filtered: 3,
..session(false)
};
session_notes(&mut host, &narrowed, false, false, Styler::new(false)).expect("notes");
let printed = String::from_utf8(host.err).expect("utf-8");
assert!(printed.contains("3 test targets not consulted"), "{printed}");
}
#[test]
fn an_oracle_that_kept_every_target_says_nothing_about_it() {
assert!(!notes(false).contains("Oracle"), "{}", notes(false));
}
fn notes(widened: bool) -> String {
let mut host = Sink::default();
session_notes(&mut host, &session(widened), false, false, Styler::new(false)).expect("notes");
String::from_utf8(host.err).expect("utf-8")
}
#[test]
fn a_build_that_had_to_widen_says_so() {
assert!(notes(true).contains("the whole workspace was built"), "{}", notes(true));
}
#[test]
fn a_build_that_kept_its_scope_says_nothing_about_it() {
assert!(!notes(false).contains("whole workspace"), "{}", notes(false));
}
#[test]
fn a_missing_hints_file_suggests_creating_one() {
let mut host = Sink::default();
session_notes(&mut host, &session(false), true, false, Styler::new(false)).expect("notes");
let text = String::from_utf8(host.err).expect("UTF-8");
assert_eq!(
text,
" Hint run `cargo gamma hints` to create `gamma-hints.json` to speed up subsequent runs\n"
);
}
#[test]
fn suppressible_mutants_suggest_the_suppress_command() {
let mut host = Sink::default();
session_notes(&mut host, &session(false), false, true, Styler::new(false)).expect("notes");
let text = String::from_utf8(host.err).expect("UTF-8");
assert_eq!(
text,
" Hint run `cargo gamma suppress` to automatically suppress timed-out and out-of-memory mutants\n"
);
}
#[test]
fn results_are_listed_when_nothing_announced_them() {
let text = summary(false);
assert!(text.contains("SURVIVED src/a.rs:2"), "{text}");
assert!(text.contains("TIMEOUT src/a.rs:3"), "{text}");
}
#[test]
fn results_the_live_display_already_named_are_not_repeated() {
let text = summary(true);
assert!(!text.contains("SURVIVED"), "{text}");
assert!(!text.contains("TIMEOUT"), "{text}");
assert!(
text.contains("3 mutants (1 killed, 1 survived, 1 timed out, 0 out of memory, 0 uncovered => 33.3%)"),
"{text}"
);
}
#[test]
fn memory_exhaustion_is_listed_separately_from_a_killed_mutant() {
let mut plan = plan();
plan.mutants = vec![mutant(1, Outcome::Killed), mutant(2, Outcome::OutOfMemory)];
plan.mutants[1].note = Some("`suite` reached 200 MB, past the 150 MB this run allowed it".to_owned());
let text = rendered(&plan, false);
assert!(text.contains("OUTOFMEM"), "{text}");
assert!(text.contains("past the 150 MB"), "{text}");
assert!(text.contains("1 killed, 0 survived, 0 timed out, 1 out of memory"), "{text}");
}
#[test]
fn a_flake_is_named_apart_from_the_survivors_and_scores_as_neither() {
let mut plan = plan();
plan.mutants = vec![mutant(1, Outcome::Killed), mutant(2, Outcome::Flaky)];
plan.mutants[1].note = Some("test `a::b` in `suite` fails with no mutant active as well as with one".to_owned());
let text = rendered(&plan, false);
assert!(text.contains("FLAKY"), "{text}");
assert!(text.contains("test `a::b`"), "the test to fix has to be named, {text}");
assert!(text.contains("1 flaky mutant never judged"), "{text}");
assert!(text.contains("1 mutant (1 killed, 0 survived"), "{text}");
assert!(text.contains("100.0%"), "{text}");
}
#[test]
fn a_flake_remains_visible_when_live_results_were_announced() {
let mut plan = plan();
plan.mutants = vec![mutant(1, Outcome::Killed), mutant(2, Outcome::Flaky)];
plan.mutants[1].note = Some("test `a::b` also fails without a mutant".to_owned());
let text = rendered(&plan, true);
assert!(text.contains("FLAKY"), "{text}");
assert!(text.contains("test `a::b`"), "{text}");
}
#[test]
fn inexact_boundary_scores_do_not_render_as_exact_boundaries() {
let mut plan = plan();
plan.mutants = (0..10_000)
.map(|index| mutant(index + 1, if index == 9_999 { Outcome::Survived } else { Outcome::Killed }))
.collect();
let text = rendered(&plan, true);
assert!(text.contains("99.99%"), "{text}");
assert!(!text.contains("=> 100.0%"), "{text}");
plan.mutants = (0..10_000)
.map(|index| mutant(index + 1, if index == 0 { Outcome::Killed } else { Outcome::Survived }))
.collect();
let text = rendered(&plan, true);
assert!(text.contains("0.01%"), "{text}");
assert!(!text.contains("=> 0.0%"), "{text}");
}
#[test]
fn a_run_without_flakes_does_not_mention_them() {
let mut plan = plan();
plan.mutants = vec![mutant(1, Outcome::Killed)];
let text = rendered(&plan, false);
assert!(!text.contains("flaky"), "{text}");
}
#[test]
fn a_run_that_could_not_bound_memory_does_not_say_so_on_the_console() {
let mut host = Sink::default();
let mut settled = session(false);
settled.unbounded = Some("no cgroup delegation".to_owned());
session_notes(&mut host, &settled, false, false, Styler::new(false)).expect("notes");
let text = String::from_utf8(host.err).expect("utf-8");
assert!(!text.contains("not bounded on this host"), "{text}");
}
#[test]
fn the_test_sink_exposes_both_streams_without_claiming_a_terminal() {
let mut host = Sink::default();
let _ = host.output().write_all(b"out");
let _ = host.error().write_all(b"err");
assert!(!host.is_terminal());
assert_eq!(host.terminal_width(), None);
assert_eq!(String::from_utf8(host.out).expect("utf-8"), "out");
assert_eq!(String::from_utf8(host.err).expect("utf-8"), "err");
}
#[test]
fn the_summary_still_names_mutants_already_settled_out_of_the_run() {
let mut plan = plan();
plan.settled_out = 5;
let text = rendered(&plan, false);
assert!(text.contains("5 already settled"), "{text}");
}
#[test]
fn killed_mutants_are_listed_only_when_requested() {
let listings = Listings {
killed: true,
unviable: false,
announced: true,
};
let text = rendered_with(&plan(), listings);
assert!(text.contains("killed src/a.rs:1"), "{text}");
assert!(!text.contains("SURVIVED src/a.rs:2"), "{text}");
}
#[test]
fn killed_listing_excludes_timeout_and_memory_limit_outcomes() {
let mut population = plan();
population.mutants = vec![
mutant(1, Outcome::Killed),
mutant(2, Outcome::Timeout),
mutant(3, Outcome::OutOfMemory),
];
let text = rendered_with(
&population,
Listings {
killed: true,
unviable: false,
announced: false,
},
);
assert!(text.contains("killed src/a.rs:1"), "{text}");
assert!(!text.contains("killed src/a.rs:2"), "{text}");
assert!(!text.contains("killed src/a.rs:3"), "{text}");
assert!(text.contains("TIMEOUT src/a.rs:2"), "{text}");
assert!(text.contains("OUTOFMEM src/a.rs:3"), "{text}");
}
#[test]
fn requesting_killed_mutants_when_none_were_killed_adds_no_empty_block() {
let mut population = plan();
population.mutants = vec![mutant(2, Outcome::Survived)];
let listings = Listings {
killed: true,
unviable: false,
announced: true,
};
let text = rendered_with(&population, listings);
assert!(!text.contains("killed src/"), "{text}");
}
#[test]
fn unviable_mutants_are_listed_only_when_requested() {
let mut population = plan();
population.mutants.push(mutant(4, Outcome::CompileError));
let listings = Listings {
killed: false,
unviable: true,
announced: true,
};
let text = rendered_with(&population, listings);
assert!(text.contains("unviable src/a.rs:4"), "{text}");
assert!(!text.contains("SURVIVED src/a.rs:2"), "{text}");
}
#[test]
fn an_empty_population_reports_files_instead_of_a_score() {
let mut plan = plan();
plan.mutants.clear();
let text = rendered(&plan, false);
assert!(text.contains("0 mutants in 1 file, none tested"), "{text}");
}
#[test]
fn a_timeout_is_not_told_it_timed_out_twice() {
let mut plan = plan();
plan.mutants = vec![mutant(7, Outcome::Timeout)];
let text = rendered(&plan, false);
assert!(text.contains("src/a.rs:7"), "{text}");
assert!(!text.contains("ran out its budget"), "{text}");
assert!(!text.contains("[relational.gt_to_ge]:"), "{text}");
}
#[test]
fn a_stalled_mutant_still_names_the_test_it_hung_in() {
let mut hung = mutant(7, Outcome::Timeout);
hung.note = Some("stalled, last test named was `t_slow`".to_owned());
let mut plan = plan();
plan.mutants = vec![hung];
let text = rendered(&plan, false);
assert!(text.contains("stalled, last test named was `t_slow`"), "{text}");
}
#[test]
fn the_summary_does_not_count_mutants_the_compiler_rejected() {
let mut plan = plan();
plan.mutants.push(mutant(4, Outcome::CompileError));
let text = rendered(&plan, false);
assert!(!text.contains("unviable"), "{text}");
}
#[test]
fn the_summary_names_mutants_a_shard_left_to_another_run() {
let mut plan = plan();
plan.suppressed = 3;
plan.sharded_out = 9;
let text = rendered(&plan, false);
assert!(!text.contains("suppressed"), "{text}");
assert!(text.contains("9 outside this shard"), "{text}");
}
#[test]
fn a_closed_results_stream_fails_the_summary() {
let listings = Listings {
killed: true,
unviable: true,
announced: true,
};
fails_at_every_line(4, |host| summarize(host, &plan(), Styler::new(false), listings));
}
#[test]
fn a_closed_results_stream_fails_the_untested_summary() {
let mut plan = plan();
plan.mutants.clear();
let listings = Listings {
killed: false,
unviable: false,
announced: false,
};
fails_at_every_line(1, |host| summarize(host, &plan, Styler::new(false), listings));
}
#[test]
fn a_closed_diagnostic_stream_fails_the_session_notes() {
fails_at_every_line(1, |host| session_notes(host, &session(true), false, false, Styler::new(false)));
}
#[test]
fn a_pipe_that_closes_partway_through_fails_whichever_note_was_writing() {
let session = Session {
ordering: OrderingHints::default(),
census: Vec::new(),
baseline: Duration::from_secs(1),
baseline_wall: Duration::from_secs(1),
tests: None,
quiet: Duration::ZERO,
stall: None,
build: Duration::from_secs(3),
metered: false,
unbounded: Some("no cgroup delegation".to_owned()),
withdrawn: 0,
rounds: 1,
rounds_taken: Vec::new(),
binaries: Vec::new(),
peak: None,
scratch: Utf8PathBuf::new(),
filtered: 2,
widened: true,
phases: Phases::default(),
};
fails_at_every_line(4, |host| session_notes(host, &session, true, true, Styler::new(false)));
}
}