use std::io::IsTerminal;
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use fallow_api::MutationProgress;
const TERMINAL_INTERVAL: Duration = Duration::from_secs(5);
const CAPTURED_INTERVAL: Duration = Duration::from_secs(15);
const TICK: Duration = Duration::from_secs(1);
const PREFIX: &str = "fallow: mutation:";
#[derive(Debug)]
struct ProgressState {
started: Instant,
interval: Duration,
last_line: Instant,
phase: Option<MutationProgress>,
}
impl ProgressState {
fn new(now: Instant, interval: Duration) -> Self {
Self {
started: now,
interval,
last_line: now,
phase: None,
}
}
fn on_event(&mut self, event: MutationProgress, now: Instant) -> Option<String> {
self.phase = Some(event);
let prints = match event {
MutationProgress::Mutants { done, total }
| MutationProgress::Retries { done, total } => {
total > 0
&& (done == 0
|| done == total
|| now.duration_since(self.last_line) >= self.interval)
}
_ => true,
};
prints.then(|| self.line(event, now, false))
}
fn on_tick(&mut self, now: Instant) -> Option<String> {
let event = self.phase?;
let idle = matches!(
event,
MutationProgress::Mutants { total: 0, .. } | MutationProgress::Retries { total: 0, .. }
);
if idle || now.duration_since(self.last_line) < self.interval {
return None;
}
Some(self.line(event, now, true))
}
fn line(&mut self, event: MutationProgress, now: Instant, repeat: bool) -> String {
self.last_line = now;
let elapsed = format_elapsed(now.duration_since(self.started));
let text = match event {
MutationProgress::Setup { mutants } if repeat => {
format!("the runner setup for {mutants} {} continues", noun(mutants))
}
MutationProgress::Setup { mutants } => {
format!("runner setup for {mutants} {}", noun(mutants))
}
MutationProgress::Baseline { test_files } if repeat => format!(
"the baseline run of {test_files} test {} continues",
files(test_files)
),
MutationProgress::Baseline { test_files } => format!(
"baseline run of {test_files} test {} with no active mutant",
files(test_files)
),
MutationProgress::Mutants { done, total } => {
format!("{done}/{total} {} done", noun(total))
}
MutationProgress::Retries { done, total } => {
format!("{done}/{total} retries of survivors and timeouts done")
}
MutationProgress::Finalize if repeat => {
"the worktree check and the report continue".to_owned()
}
_ => "the tests stopped; worktree check and report".to_owned(),
};
format!("{PREFIX} {text} ({elapsed})")
}
}
fn noun(count: usize) -> &'static str {
if count == 1 { "mutant" } else { "mutants" }
}
fn files(count: usize) -> &'static str {
if count == 1 { "file" } else { "files" }
}
fn format_elapsed(elapsed: Duration) -> String {
let secs = elapsed.as_secs();
if secs < 60 {
format!("{secs}s")
} else {
format!("{}m{:02}s", secs / 60, secs % 60)
}
}
pub struct MutationProgressReporter {
state: Arc<Mutex<ProgressState>>,
stop: Option<Sender<()>>,
ticker: Option<JoinHandle<()>>,
}
impl MutationProgressReporter {
pub fn start() -> Self {
let interval = if std::io::stderr().is_terminal() {
TERMINAL_INTERVAL
} else {
CAPTURED_INTERVAL
};
let state = Arc::new(Mutex::new(ProgressState::new(Instant::now(), interval)));
let (stop, stopped) = mpsc::channel::<()>();
let ticker_state = Arc::clone(&state);
let ticker = std::thread::Builder::new()
.name("fallow-mutation-progress".to_owned())
.spawn(move || {
while stopped.recv_timeout(TICK) == Err(RecvTimeoutError::Timeout) {
let line = ticker_state
.lock()
.ok()
.and_then(|mut state| state.on_tick(Instant::now()));
if let Some(line) = line {
eprintln!("{line}");
}
}
})
.ok();
Self {
state,
stop: Some(stop),
ticker,
}
}
pub fn report(&self, event: MutationProgress) {
if let Ok(mut state) = self.state.lock()
&& let Some(line) = state.on_event(event, Instant::now())
{
eprintln!("{line}");
}
}
pub fn finish(mut self) {
self.stop_ticker();
}
fn stop_ticker(&mut self) {
drop(self.stop.take());
if let Some(ticker) = self.ticker.take() {
let _ = ticker.join();
}
}
}
impl Drop for MutationProgressReporter {
fn drop(&mut self) {
self.stop_ticker();
}
}
#[cfg(test)]
mod tests {
use super::*;
const INTERVAL: Duration = Duration::from_secs(10);
fn at(state: &ProgressState, secs: u64) -> Instant {
state.started + Duration::from_secs(secs)
}
#[test]
fn each_phase_start_prints_at_once_with_the_run_time() {
let start = Instant::now();
let mut state = ProgressState::new(start, INTERVAL);
let lines: Vec<String> = [
(MutationProgress::Setup { mutants: 20 }, 0),
(MutationProgress::Baseline { test_files: 1 }, 2),
(MutationProgress::Mutants { done: 0, total: 20 }, 3),
(MutationProgress::Retries { done: 0, total: 3 }, 65),
(MutationProgress::Finalize, 70),
]
.into_iter()
.filter_map(|(event, secs)| state.on_event(event, at(&state, secs)))
.collect();
assert_eq!(
lines,
[
"fallow: mutation: runner setup for 20 mutants (0s)",
"fallow: mutation: baseline run of 1 test file with no active mutant (2s)",
"fallow: mutation: 0/20 mutants done (3s)",
"fallow: mutation: 0/3 retries of survivors and timeouts done (1m05s)",
"fallow: mutation: the tests stopped; worktree check and report (1m10s)",
]
);
}
#[test]
fn a_mutant_count_prints_once_per_interval_and_at_the_end() {
let mut state = ProgressState::new(Instant::now(), INTERVAL);
let total = 4;
let mut printed = Vec::new();
for (done, secs) in [(0, 0), (1, 3), (2, 11), (3, 12), (4, 13)] {
let now = at(&state, secs);
if state
.on_event(MutationProgress::Mutants { done, total }, now)
.is_some()
{
printed.push(done);
}
}
assert_eq!(printed, [0, 2, 4]);
}
#[test]
fn a_silent_phase_prints_a_line_after_one_interval() {
let mut state = ProgressState::new(Instant::now(), INTERVAL);
assert_eq!(state.on_tick(at(&state, 30)), None, "no phase started yet");
state.on_event(MutationProgress::Baseline { test_files: 3 }, at(&state, 1));
assert_eq!(state.on_tick(at(&state, 5)), None);
assert_eq!(
state.on_tick(at(&state, 11)).as_deref(),
Some("fallow: mutation: the baseline run of 3 test files continues (11s)")
);
assert_eq!(
state.on_tick(at(&state, 12)),
None,
"the tick reset the interval"
);
state.on_event(
MutationProgress::Mutants { done: 1, total: 9 },
at(&state, 13),
);
assert_eq!(
state.on_tick(at(&state, 21)).as_deref(),
Some("fallow: mutation: 1/9 mutants done (21s)"),
"a held count prints on the tick"
);
}
#[test]
fn a_phase_with_no_mutant_job_prints_no_count() {
let mut state = ProgressState::new(Instant::now(), INTERVAL);
assert_eq!(
state.on_event(
MutationProgress::Mutants { done: 0, total: 0 },
at(&state, 0)
),
None
);
assert_eq!(state.on_tick(at(&state, 30)), None);
}
}