use core::time::Duration;
use std::io::Write;
use std::time::Instant;
use super::Styler;
use super::text::{VERB_WIDTH, continuation, quantity};
use crate::advise::human;
use crate::commands::Host;
use crate::model::Outcome;
use crate::report::{encode_controls, encode_preserving_color};
const REDRAW_INTERVAL: Duration = Duration::from_millis(100);
const MIN_ETA_SAMPLES: usize = 3;
const BAR_WIDTH: usize = 25;
#[derive(Debug)]
pub struct Progress {
enabled: bool,
open: bool,
pending: Option<(String, String, String)>,
shown: Option<String>,
styler: Styler,
width: usize,
last_draw: Option<Instant>,
dirty: bool,
total: usize,
done: usize,
survived: usize,
timeouts: usize,
out_of_memory: usize,
started: Option<Instant>,
}
impl Progress {
#[must_use]
pub fn new(enabled: bool, styler: Styler, width: Option<u16>) -> Self {
Self {
enabled,
open: false,
pending: None,
shown: None,
styler,
width: width.map_or(80, |value| usize::from(value).max(20)),
last_draw: None,
dirty: false,
total: 0,
done: 0,
survived: 0,
timeouts: 0,
out_of_memory: 0,
started: None,
}
}
pub fn set_total(&mut self, total: usize) {
self.total = total;
self.started = Some(Instant::now());
self.dirty = true;
}
pub const fn record(&mut self, outcome: Outcome) {
self.done += 1;
match outcome {
Outcome::Survived => self.survived += 1,
Outcome::Timeout => self.timeouts += 1,
Outcome::OutOfMemory => self.out_of_memory += 1,
_ => {}
}
self.dirty = true;
}
#[must_use]
pub fn fraction(&self) -> f64 {
if self.total == 0 {
return 0.0;
}
#[expect(clippy::cast_precision_loss, reason = "a mutant count far exceeds any plausible workspace")]
let fraction = self.done as f64 / self.total as f64;
fraction.clamp(0.0, 1.0)
}
fn remaining(&self) -> Option<Duration> {
let started = self.started?;
if self.done < MIN_ETA_SAMPLES || self.done >= self.total {
return None;
}
#[expect(clippy::cast_precision_loss, reason = "a mutant count far exceeds any plausible workspace")]
let (done, left) = (self.done as f64, (self.total - self.done) as f64);
Duration::try_from_secs_f64(started.elapsed().as_secs_f64() / done * left).ok()
}
pub fn status<H: Host>(&mut self, host: &mut H, verb: &str, subject: &str) {
let label = self.styler.verb(verb);
self.line(host, &label, &encode_controls(subject));
}
pub fn begin<H: Host>(&mut self, host: &mut H, active: &str, completed: &str, subject: &str) {
if !self.enabled {
return;
}
self.clear(host);
let active = self.styler.verb(active);
let completed = self.styler.verb(completed);
let subject = encode_controls(subject).into_owned();
paint(host, &format!("{active} {subject}"));
self.open = true;
self.dirty = true;
self.pending = Some((active, completed, subject));
}
pub fn end<H: Host>(&mut self, host: &mut H, subject: &str) {
self.close(host, subject, true);
}
pub fn complete<H: Host>(&mut self, host: &mut H, subject: &str) {
self.close(host, subject, false);
}
fn close<H: Host>(&mut self, host: &mut H, subject: &str, extend: bool) {
if !self.enabled {
return;
}
let encoded = encode_controls(subject);
let subject = encoded.as_ref();
let pending = self.pending.take();
if !self.open {
match pending {
Some((_, completed, opening)) => {
let completed_subject = if extend {
format!("{opening}{subject}")
} else {
subject.to_owned()
};
self.line(host, &completed, &completed_subject);
}
None => self.line(host, &continuation(), subject.trim_start().trim_start_matches(',').trim_start()),
}
return;
}
let Some((_, completed, opening)) = pending else {
paint(host, &format!("{subject}\n"));
self.open = false;
self.dirty = true;
return;
};
let completed_subject = if extend {
format!("{opening}{subject}")
} else {
subject.to_owned()
};
paint(host, &format!("\r\x1b[2K{completed} {completed_subject}\n"));
self.open = false;
self.dirty = true;
}
pub fn abandon<H: Host>(&mut self, host: &mut H) {
if !self.open {
if let Some((active, _, subject)) = self.pending.take() {
self.clear(host);
paint(host, &format!("{active} {subject}\n"));
self.dirty = true;
}
return;
}
paint(host, "\n");
self.open = false;
self.dirty = true;
}
fn retract(&mut self) {
if !self.open {
return;
}
self.open = false;
self.shown = None;
}
pub fn restore<H: Host>(&mut self, host: &mut H) {
if !self.enabled || self.open {
return;
}
let Some((active, _, subject)) = self.pending.as_ref() else {
self.clear(host);
return;
};
paint(host, &format!("\r\x1b[2K{active} {subject}"));
self.last_draw = None;
self.shown = None;
self.open = true;
self.dirty = true;
}
pub fn phase_progress<H: Host>(&mut self, host: &mut H, completed: usize, total: usize, unit: &str) {
if total == 0 {
return;
}
let Some((active, _, _subject)) = self.pending.as_ref() else {
return;
};
let filled = completed.saturating_mul(BAR_WIDTH).checked_div(total).unwrap_or(0).min(BAR_WIDTH);
let mut bar = String::with_capacity(BAR_WIDTH);
if filled > 0 {
for _ in 0..filled - 1 {
bar.push('=');
}
bar.push(if filled == BAR_WIDTH { '=' } else { '>' });
}
for _ in filled..BAR_WIDTH {
bar.push(' ');
}
let line = format!("{active} [{bar}] {completed}/{total} {}", encode_controls(unit));
self.draw_borrowed(host, &line);
}
pub fn labelled<H: Host>(&mut self, host: &mut H, label: &str, subject: &str) {
self.line(host, label, &encode_controls(subject));
}
pub fn insist<H: Host>(&mut self, host: &mut H, label: &str, subject: &str) {
self.insist_encoded(host, label, &encode_controls(subject));
}
pub fn relay<H: Host>(&mut self, host: &mut H, label: &str, line: &str) {
self.insist_encoded(host, label, &encode_preserving_color(line));
}
fn insist_encoded<H: Host>(&mut self, host: &mut H, label: &str, subject: &str) {
if self.enabled {
self.line(host, label, subject);
return;
}
paint(host, &format!("{label} {subject}\n"));
}
fn line<H: Host>(&mut self, host: &mut H, label: &str, subject: &str) {
if !self.enabled {
return;
}
self.abandon(host);
self.clear(host);
paint(host, &format!("{label} {subject}\n"));
self.dirty = true;
}
#[must_use]
pub const fn is_enabled(&self) -> bool {
self.enabled
}
pub fn tick<H: Host>(&mut self, host: &mut H) {
if !self.enabled || !self.dirty {
return;
}
let now = Instant::now();
if self.last_draw.is_some_and(|last| now.duration_since(last) < REDRAW_INTERVAL) {
return;
}
self.last_draw = Some(now);
self.dirty = false;
self.shown = None;
let line = self.render();
paint(host, &format!("\r\x1b[2K{line}"));
}
pub fn clear<H: Host>(&mut self, host: &mut H) {
if !self.enabled || self.last_draw.is_none() {
return;
}
self.last_draw = None;
self.shown = None;
paint(host, "\r\x1b[2K");
}
pub fn borrowed<H: Host>(&mut self, host: &mut H, line: &str) {
if !self.enabled {
return;
}
if self
.last_draw
.is_some_and(|last| Instant::now().duration_since(last) < REDRAW_INTERVAL)
{
return;
}
self.draw_borrowed(host, &encode_preserving_color(line));
}
fn draw_borrowed<H: Host>(&mut self, host: &mut H, line: &str) {
if !self.enabled {
return;
}
let now = Instant::now();
if self.last_draw.is_some_and(|last| now.duration_since(last) < REDRAW_INTERVAL) {
return;
}
let line = truncate(line, self.width);
if self.shown.as_deref() == Some(line.as_str()) && !self.open {
return;
}
self.retract();
self.last_draw = Some(now);
paint(host, &format!("\r\x1b[2K{line}"));
self.shown = Some(line);
}
pub fn finish<H: Host>(&mut self, host: &mut H) {
self.clear(host);
self.dirty = false;
}
#[must_use]
pub fn render(&self) -> String {
let estimate = self
.remaining()
.map_or_else(String::new, |remaining| format!(", ETA ~{}", human(remaining)));
#[expect(clippy::cast_precision_loss, reason = "the operand is a bar width")]
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the value is bounded by the bar width"
)]
let filled = (self.fraction() * BAR_WIDTH as f64) as usize;
let filled = filled.min(BAR_WIDTH);
let mut bar = String::with_capacity(BAR_WIDTH);
if filled > 0 {
for _ in 0..filled - 1 {
bar.push('=');
}
bar.push(if filled == BAR_WIDTH { '=' } else { '>' });
}
for _ in filled..BAR_WIDTH {
bar.push(' ');
}
let room = self.width.saturating_sub(VERB_WIDTH + 1);
let mut findings = Vec::with_capacity(3);
if self.survived > 0 {
findings.push(format!("{} survived", self.survived));
}
if self.timeouts > 0 {
findings.push(quantity(self.timeouts, "timeout"));
}
if self.out_of_memory > 0 {
findings.push(format!("{} out of memory", self.out_of_memory));
}
let verdicts = if findings.is_empty() {
String::new()
} else {
format!(" ({})", findings.join(", "))
};
let counted = format!("[{bar}] {}/{} mutants evaluated", self.done, self.total);
let full = format!("{counted}{verdicts}{estimate}");
let body = if full.chars().count() <= room {
full
} else {
let shorter = format!("{counted}{estimate}");
if shorter.chars().count() <= room {
shorter
} else {
truncate(&shorter, room)
}
};
format!("{} {body}", self.styler.verb("Testing"))
}
}
fn truncate(text: &str, width: usize) -> String {
if visible_width(text) <= width {
return text.to_owned();
}
let keep = width.saturating_sub(3);
let mut kept = String::with_capacity(text.len());
let mut shown = 0;
let mut styled = false;
let mut characters = text.chars();
while let Some(character) = characters.next() {
if character == '\u{1b}' {
styled = true;
kept.push(character);
if let Some(next) = characters.next() {
kept.push(next);
if next == '[' {
for byte in characters.by_ref() {
kept.push(byte);
if matches!(byte, '\u{40}'..='\u{7e}') {
break;
}
}
}
}
continue;
}
if shown == keep {
break;
}
kept.push(character);
shown += 1;
}
kept.push_str("...");
if styled {
kept.push_str("\u{1b}[0m");
}
kept
}
fn paint<H: Host>(host: &mut H, update: &str) {
let mut stream = host.error();
let _ = stream.write_all(update.as_bytes());
let _ = stream.flush();
}
fn visible_width(text: &str) -> usize {
crate::report::unstyled(text).chars().count()
}
#[cfg(test)]
mod tests {
use std::io;
use super::*;
use crate::testing::Sink;
#[derive(Default)]
struct Counted {
bytes: Vec<u8>,
writes: usize,
}
impl Write for &mut Counted {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.writes += 1;
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[derive(Default)]
struct CountingHost {
err: Counted,
out: Vec<u8>,
}
impl Host for CountingHost {
fn output(&mut self) -> impl Write {
&mut self.out
}
fn error(&mut self) -> impl Write {
&mut self.err
}
fn is_terminal(&self) -> bool {
true
}
fn terminal_width(&self) -> Option<u16> {
Some(80)
}
}
fn bar(done: usize, width: u16) -> String {
let mut progress = Progress::new(true, Styler::new(false), Some(width));
progress.set_total(100);
for _ in 0..done {
progress.record(Outcome::Killed);
}
progress.render()
}
#[test]
fn an_empty_bar_has_no_arrowhead() {
let rendered = bar(0, 80);
assert!(rendered.contains("[ "), "{rendered}");
assert!(!rendered.contains('>'), "{rendered}");
}
#[test]
fn a_partial_bar_ends_in_an_arrowhead() {
let rendered = bar(50, 80);
assert!(rendered.contains("=>"), "{rendered}");
}
#[test]
fn a_full_bar_has_no_arrowhead() {
let rendered = bar(100, 80);
assert!(!rendered.contains('>'), "{rendered}");
assert!(rendered.contains("==="), "{rendered}");
}
fn gauge(rendered: &str) -> String {
rendered
.split_once('[')
.and_then(|(_, tail)| tail.split_once(']'))
.map(|(bar, _)| bar.to_owned())
.expect("the bar is bracketed")
}
#[test]
fn the_arrowhead_is_counted_inside_the_filled_run() {
let empty = gauge(&bar(0, 80));
let half = gauge(&bar(50, 80));
let full = gauge(&bar(100, 80));
assert_eq!(empty.chars().count(), half.chars().count());
assert_eq!(half.chars().count(), full.chars().count());
}
#[test]
fn a_narrow_terminal_drops_the_verdict_counts_rather_than_cutting_the_time_remaining() {
let render = |width| {
let mut progress = Progress::new(true, Styler::new(false), Some(width));
progress.set_total(100);
for _ in 0..49 {
progress.record(Outcome::Killed);
}
progress.record(Outcome::Survived);
progress.render()
};
let wide = render(140);
let narrow = render(80);
assert!(wide.contains("survived"), "{wide}");
assert!(!narrow.contains("survived"), "{narrow}");
assert!(narrow.contains("ETA"), "{narrow}");
assert!(!narrow.contains('…'), "{narrow}");
}
#[test]
fn the_time_remaining_is_marked_approximate_rather_than_spelled_out() {
let rendered = bar(50, 140);
assert!(rendered.contains('~'), "{rendered}");
assert!(!rendered.contains("estimating"), "{rendered}");
}
#[test]
fn the_bar_never_exceeds_the_terminal_width() {
for width in [20_u16, 40, 80, 200] {
let rendered = bar(50, width);
assert!(rendered.chars().count() <= usize::from(width));
}
}
#[test]
fn a_narrow_terminal_still_renders_something() {
let rendered = bar(50, 20);
assert!(!rendered.is_empty());
}
#[test]
fn the_fraction_is_clamped() {
let mut progress = Progress::new(true, Styler::new(false), Some(80));
progress.set_total(2);
for _ in 0..10 {
progress.record(Outcome::Killed);
}
assert!((progress.fraction() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn no_total_means_no_progress() {
let progress = Progress::new(true, Styler::new(false), Some(80));
assert!(progress.fraction().abs() < f64::EPSILON);
}
#[test]
fn the_caption_counts_evaluated_mutants_and_what_they_found() {
let mut progress = Progress::new(true, Styler::new(false), Some(200));
progress.set_total(10);
progress.record(Outcome::Killed);
progress.record(Outcome::Survived);
progress.record(Outcome::Timeout);
progress.record(Outcome::Timeout);
progress.record(Outcome::OutOfMemory);
let rendered = progress.render();
assert!(
rendered.contains("5/10 mutants evaluated (1 survived, 2 timeouts, 1 out of memory)"),
"{rendered}"
);
}
#[test]
fn zero_verdict_counts_are_omitted() {
let mut progress = Progress::new(true, Styler::new(false), Some(200));
progress.set_total(10);
progress.record(Outcome::Timeout);
let rendered = progress.render();
assert!(rendered.contains("(1 timeout)"), "{rendered}");
assert!(!rendered.contains("survived"), "{rendered}");
assert!(!rendered.contains("out of memory"), "{rendered}");
}
#[test]
fn an_out_of_memory_verdict_is_counted() {
let mut progress = Progress::new(true, Styler::new(false), Some(200));
progress.set_total(10);
progress.record(Outcome::OutOfMemory);
assert!(progress.render().contains("(1 out of memory)"), "{}", progress.render());
}
#[test]
fn a_clean_run_has_no_verdict_section() {
let mut progress = Progress::new(true, Styler::new(false), Some(200));
progress.set_total(10);
progress.record(Outcome::Killed);
let rendered = progress.render();
assert!(!rendered.contains('('), "{rendered}");
}
#[test]
fn the_gauge_keeps_its_width_however_long_the_caption_grows() {
let mut progress = Progress::new(true, Styler::new(false), Some(200));
progress.set_total(1_000_000);
let empty = progress.render();
for _ in 0..10 {
progress.record(Outcome::Survived);
}
let busy = progress.render();
assert!(empty.contains(&format!("[{}]", " ".repeat(BAR_WIDTH))), "{empty}");
assert!(busy.contains(&format!("[{}]", " ".repeat(BAR_WIDTH))), "{busy}");
}
#[test]
fn a_time_estimate_appears_once_there_is_something_to_extrapolate_from() {
let mut progress = Progress::new(true, Styler::new(false), Some(200));
progress.set_total(10);
assert!(!progress.render().contains("ETA"), "{}", progress.render());
for _ in 0..MIN_ETA_SAMPLES {
progress.record(Outcome::Killed);
}
assert!(progress.render().contains("ETA"), "{}", progress.render());
}
#[test]
fn a_finished_run_has_no_time_left_to_report() {
let mut progress = Progress::new(true, Styler::new(false), Some(200));
progress.set_total(1);
progress.record(Outcome::Killed);
assert!(!progress.render().contains("ETA"), "{}", progress.render());
}
#[test]
fn truncation_appends_an_ellipsis() {
assert_eq!(truncate("abcdefghij", 6), "abc...");
assert_eq!(truncate("abc", 6), "abc");
}
#[test]
fn truncation_counts_characters_not_bytes() {
assert_eq!(truncate("ééééé", 5).chars().count(), 5);
}
#[test]
fn styling_costs_no_columns_and_a_cut_line_stops_styling() {
let styled = "\u{1b}[1;36mBuilding\u{1b}[0m";
assert_eq!(truncate(styled, 8), styled);
let cut = truncate(&format!("{styled} [==> ]"), 10);
assert_eq!(crate::report::unstyled(&cut), "Buildin...");
assert!(cut.ends_with("\u{1b}[0m"), "{cut:?}");
}
fn written(steps: impl FnOnce(&mut Progress, &mut Sink)) -> String {
let mut host = Sink::default();
let mut progress = Progress::new(true, Styler::new(false), Some(80));
steps(&mut progress, &mut host);
host.err()
}
fn expire(progress: &mut Progress) {
progress.last_draw = Instant::now().checked_sub(REDRAW_INTERVAL + Duration::from_millis(10));
}
fn visible(stream: &str) -> String {
stream
.split('\n')
.map(|line| line.rsplit("\r\u{1b}[2K").next().unwrap_or(line).to_owned())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn a_borrowed_line_takes_the_phase_line_off_the_screen_rather_than_committing_it() {
let screen = visible(&written(|progress, host| {
progress.begin(host, "Mutating", "Mutated", "the workspace");
progress.borrowed(host, "Building [==> ] 2/9: syn");
}));
assert_eq!(
screen, "Building [==> ] 2/9: syn",
"the opening was committed and will be written again"
);
}
#[test]
fn phase_progress_counts_completed_units_on_the_active_phase_line() {
let screen = visible(&written(|progress, host| {
progress.begin(host, "Optimizing", "Optimized", "4 test binaries");
progress.phase_progress(host, 0, 4, "test binaries");
expire(progress);
progress.phase_progress(host, 2, 4, "test binaries");
}));
assert_eq!(screen, format!(" Optimizing [{}> ] 2/4 test binaries", "=".repeat(11)));
}
#[test]
fn completed_phase_progress_fills_the_gauge() {
let screen = visible(&written(|progress, host| {
progress.begin(host, "Optimizing", "Optimized", "1 test binary");
progress.phase_progress(host, 1, 1, "test binary");
}));
assert_eq!(screen, format!(" Optimizing [{}] 1/1 test binary", "=".repeat(BAR_WIDTH)));
}
#[test]
fn baseline_phase_progress_contains_only_the_binary_count() {
let screen = visible(&written(|progress, host| {
progress.begin(host, "Baselining", "Baseline", "building the test binaries and running the suite");
progress.phase_progress(host, 2, 4, "test binaries");
}));
assert!(screen.contains("2/4 test binaries"), "{screen}");
assert!(!screen.contains("elapsed"), "{screen}");
assert!(!screen.contains("ETA"), "{screen}");
}
#[test]
fn a_phase_interrupted_by_a_build_repeats_its_opening_with_the_result_attached() {
let screen = visible(&written(|progress, host| {
progress.begin(host, "Mutating", "Mutated", "the workspace");
progress.borrowed(host, "Building [==> ] 2/9: syn");
progress.end(host, ", 14 viable mutants");
}));
assert_eq!(screen, " Mutated the workspace, 14 viable mutants\n");
}
#[test]
fn a_phase_interrupted_by_a_build_that_then_failed_gets_its_opening_back() {
let screen = visible(&written(|progress, host| {
progress.begin(host, "Mutating", "Mutated", "the workspace");
progress.borrowed(host, "Building [==> ] 2/9: syn");
progress.abandon(host);
}));
assert_eq!(screen, " Mutating the workspace\n");
}
#[test]
fn an_ending_with_no_phase_behind_it_is_written_under_the_status_column() {
let output = written(|progress, host| {
progress.end(host, ", 14 viable mutants");
});
assert!(output.ends_with("14 viable mutants\n"), "{output:?}");
assert!(
!output.contains(", 14 viable"),
"the comma joined a sentence that is not there: {output:?}"
);
}
#[test]
fn a_throttled_borrowed_line_leaves_the_previous_one_on_screen() {
let output = written(|progress, host| {
progress.borrowed(host, "Building [==> ] 2/9: syn");
progress.borrowed(host, "Building [===> ] 3/9: serde");
progress.borrowed(host, "Building [====>] 4/9: clap");
});
assert_eq!(
visible(&output),
"Building [==> ] 2/9: syn",
"a throttled call blanked the line instead of leaving the bar up: {output:?}"
);
assert!(
!output.ends_with("\r\u{1b}[2K"),
"the display was left erased rather than showing a bar: {output:?}"
);
}
#[test]
fn an_unchanged_borrowed_line_is_not_repainted() {
let output = written(|progress, host| {
progress.borrowed(host, "Building [==> ] 2/9: syn");
expire(progress);
progress.borrowed(host, "Building [==> ] 2/9: syn");
});
assert_eq!(output.matches("Building").count(), 1, "the same line was painted twice: {output:?}");
}
#[test]
fn a_changed_borrowed_line_is_drawn_once_the_interval_has_passed() {
let output = written(|progress, host| {
progress.borrowed(host, "Building [==> ] 2/9: syn");
expire(progress);
progress.borrowed(host, "Building [===> ] 3/9: serde");
});
assert_eq!(visible(&output), "Building [===> ] 3/9: serde", "{output:?}");
}
#[test]
fn a_borrowed_line_is_not_drawn_when_the_display_is_off() {
let mut host = Sink::default();
let mut progress = Progress::new(false, Styler::new(false), Some(80));
progress.borrowed(&mut host, "Building [==> ] 2/9: syn");
assert_eq!(host.err(), "");
}
#[test]
fn a_redraw_reaches_the_terminal_as_a_single_write() {
let mut host = CountingHost::default();
let mut progress = Progress::new(true, Styler::new(false), Some(80));
progress.borrowed(&mut host, "Building [==> ] 2/9: syn");
assert_eq!(
host.err.writes, 1,
"the erase and the text reached the console separately, which is the flicker"
);
assert!(
String::from_utf8_lossy(&host.err.bytes).ends_with("Building [==> ] 2/9: syn"),
"the update did not carry its text"
);
}
#[test]
fn the_first_build_bar_replaces_the_phase_in_one_write() {
let mut host = CountingHost::default();
let mut progress = Progress::new(true, Styler::new(false), Some(80));
progress.begin(&mut host, "Mutating", "Mutated", "the workspace");
host.err.writes = 0;
host.err.bytes.clear();
progress.borrowed(&mut host, "Building [==> ] 2/9: syn");
assert_eq!(host.err.writes, 1, "the phase was erased separately from drawing the build bar");
assert!(
String::from_utf8_lossy(&host.err.bytes).ends_with("Building [==> ] 2/9: syn"),
"the replacement did not carry the build bar"
);
}
#[test]
fn finishing_a_build_restores_the_active_phase() {
let screen = visible(&written(|progress, host| {
progress.begin(host, "Baselining", "Baseline", "running the suite");
progress.borrowed(host, "Building [==> ] 2/9: syn");
progress.restore(host);
}));
assert_eq!(screen, " Baselining running the suite");
}
#[test]
fn the_mutant_bar_also_redraws_in_a_single_write() {
let mut host = CountingHost::default();
let mut progress = Progress::new(true, Styler::new(false), Some(80));
progress.set_total(10);
progress.record(Outcome::Killed);
progress.tick(&mut host);
assert_eq!(host.err.writes, 1, "the bar was painted in pieces");
}
#[test]
fn an_insisted_line_is_written_whether_the_display_is_on_or_off() {
let mut host = Sink::default();
let mut progress = Progress::new(false, Styler::new(false), None);
progress.insist(&mut host, " Compiling", "serde v1.0.229");
assert!(host.err().contains("serde v1.0.229"), "{}", host.err());
let shown = written(|progress, host| progress.insist(host, " Compiling", "serde v1.0.229"));
assert!(shown.contains("serde v1.0.229"), "{shown}");
}
#[test]
fn a_line_printed_mid_phase_does_not_land_on_the_phase_line() {
let output = written(|progress, host| {
progress.begin(host, "Baselining", "Baseline", "building the test binaries");
progress.labelled(host, " Compiling", "serde v1.0.229");
});
assert!(output.contains("Baselining building the test binaries\n"), "{output:?}");
}
#[test]
fn progress_writes_to_the_diagnostic_stream_and_never_to_the_result_stream() {
let mut host = Sink::default();
let mut progress = Progress::new(true, Styler::new(false), Some(80));
progress.begin(&mut host, "Baselining", "Baseline", "building the test binaries");
progress.end(&mut host, ", done");
assert!(host.out().is_empty(), "{}", host.out());
assert!(host.err().contains("Baseline"), "{}", host.err());
}
#[test]
fn a_completed_result_can_replace_the_in_progress_subject() {
let screen = visible(&written(|progress, host| {
progress.begin(host, "Baselining", "Baseline", "building the test binaries and running the suite");
progress.complete(host, "42 tests ran in 1.2s");
}));
assert_eq!(screen, " Baseline 42 tests ran in 1.2s\n");
}
#[test]
fn an_abandoned_phase_line_is_closed_so_what_follows_starts_on_its_own_line() {
let text = written(|progress, host| {
progress.begin(host, "Baselining", "Baseline", "building the test binaries");
progress.abandon(host);
});
assert!(text.ends_with('\n'), "{text:?}");
}
#[test]
fn abandoning_a_line_that_was_already_closed_writes_nothing_extra() {
let closed = written(|progress, host| {
progress.begin(host, "Baselining", "Baseline", "building");
progress.end(host, ", done");
});
let abandoned = written(|progress, host| {
progress.begin(host, "Baselining", "Baseline", "building");
progress.end(host, ", done");
progress.abandon(host);
});
assert_eq!(closed, abandoned);
}
#[test]
fn abandoning_without_a_phase_at_all_writes_nothing() {
assert!(written(Progress::abandon).is_empty());
}
#[test]
fn a_disabled_display_writes_nothing() {
let mut host = Sink::default();
let mut progress = Progress::new(false, Styler::new(false), None);
progress.set_total(10);
progress.record(Outcome::Killed);
progress.tick(&mut host);
progress.finish(&mut host);
assert!(host.err().is_empty(), "{}", host.err());
assert!(!host.is_terminal());
assert_eq!(host.terminal_width(), None);
}
#[test]
fn every_subject_entry_point_encodes_terminal_control_sequences() {
type EntryPoint = (&'static str, fn(&mut Progress, &mut Sink));
const HOSTILE: &str = "src/\r\u{1b}[2K\u{9b}31mforged\u{1b}]8;;https://evil.test\u{7}link\n.rs";
let entries: Vec<EntryPoint> = vec![
("status", |progress, host| progress.status(host, "Testing", HOSTILE)),
("begin", |progress, host| progress.begin(host, "Testing", "Tested", HOSTILE)),
("end", |progress, host| {
progress.begin(host, "Testing", "Tested", "one file");
progress.end(host, HOSTILE);
}),
("complete", |progress, host| {
progress.begin(host, "Testing", "Tested", "one file");
progress.complete(host, HOSTILE);
}),
("labelled", |progress, host| progress.labelled(host, " SURVIVED", HOSTILE)),
("insist", |progress, host| progress.insist(host, " warning", HOSTILE)),
("relay", |progress, host| progress.relay(host, " ", HOSTILE)),
("borrowed", |progress, host| {
expire(progress);
progress.borrowed(host, HOSTILE);
}),
("phase_progress", |progress, host| {
progress.begin(host, "Testing", "Tested", "one file");
expire(progress);
progress.phase_progress(host, 1, 2, HOSTILE);
}),
];
for (name, steps) in entries {
let text = written(steps);
let subject = text.replace("\r\u{1b}[2K", "");
assert!(!subject.contains('\r'), "{name} relayed a carriage return: {subject:?}");
assert!(!subject.contains("\u{1b}["), "{name} relayed a control sequence: {subject:?}");
assert!(
!subject.contains("\u{1b}]"),
"{name} relayed an operating-system command: {subject:?}"
);
assert!(
!subject.contains('\u{9b}'),
"{name} relayed a C1 control sequence introducer: {subject:?}"
);
assert!(!subject.contains('\u{7}'), "{name} relayed a bell: {subject:?}");
assert!(
subject.matches('\n').count() <= 1,
"{name} let a subject forge a second line: {subject:?}"
);
assert!(subject.contains("\\r\\e[2K"), "{name} did not show what it refused: {subject:?}");
}
}
#[test]
fn a_held_open_subject_is_encoded_in_every_later_repaint() {
let restored = written(|progress, host| {
progress.begin(host, "Building", "Built", "pkg\r\u{1b}[2Kforged");
expire(progress);
progress.borrowed(host, "cargo bar");
progress.restore(host);
});
let abandoned = written(|progress, host| {
progress.begin(host, "Building", "Built", "pkg\r\u{1b}[2Kforged");
expire(progress);
progress.borrowed(host, "cargo bar");
progress.abandon(host);
});
for text in [restored, abandoned] {
let subject = text.replace("\r\u{1b}[2K", "");
assert!(!subject.contains('\r'), "{subject:?}");
assert!(subject.contains("pkg\\r\\e[2Kforged"), "{subject:?}");
}
}
#[test]
fn relayed_tool_output_keeps_colour_and_loses_everything_else() {
let text = written(|progress, host| {
progress.relay(host, " ", "\u{1b}[1;31merror\u{1b}[0m: \u{1b}[2Kwiped");
});
assert!(text.contains("\u{1b}[1;31merror\u{1b}[0m"), "{text:?}");
assert!(text.contains("\\e[2Kwiped"), "{text:?}");
}
#[test]
fn ordinary_subjects_are_rendered_exactly_as_before() {
let screen = visible(&written(|progress, host| {
progress.status(host, "Testing", "42 mutants in src/lib.rs");
}));
assert_eq!(screen, " Testing 42 mutants in src/lib.rs\n");
}
}