Skip to main content

forge/mutation/
progress.rs

1//! Progress display for mutation testing.
2
3use std::{
4    collections::HashMap,
5    sync::{
6        Arc,
7        atomic::{AtomicBool, AtomicUsize, Ordering},
8    },
9    time::{Duration, Instant},
10};
11
12use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
13use parking_lot::Mutex;
14use yansi::Paint;
15
16use crate::mutation::mutant::{Mutant, MutationResult};
17
18/// Live tally of mutant outcomes, rendered into the overall progress bar.
19#[derive(Debug, Default, Clone, Copy)]
20struct LiveCounts {
21    killed: usize,
22    survived: usize,
23    invalid: usize,
24    skipped: usize,
25    timed_out: usize,
26}
27
28impl LiveCounts {
29    const fn record(&mut self, result: &MutationResult) {
30        match result {
31            MutationResult::Dead => self.killed += 1,
32            MutationResult::Alive => self.survived += 1,
33            MutationResult::Invalid => self.invalid += 1,
34            MutationResult::Skipped => self.skipped += 1,
35            MutationResult::TimedOut => self.timed_out += 1,
36        }
37    }
38}
39
40/// State stored per active mutant so we can show per-mutant elapsed time and
41/// remove the correct row when a mutant completes (rather than FIFO which
42/// breaks under parallel completion).
43#[derive(Debug)]
44struct ActiveMutant {
45    pb: ProgressBar,
46    started_at: Instant,
47}
48
49/// State for mutation testing progress display.
50#[derive(Debug)]
51pub struct MutationProgressState {
52    multi: MultiProgress,
53    overall_progress: ProgressBar,
54    /// Active mutant progress bars keyed by a stable identifier (path + span +
55    /// mutation string) so completion correctly removes the right row.
56    active_mutants: HashMap<String, ActiveMutant>,
57    /// Running per-result counts displayed on the overall bar.
58    counts: LiveCounts,
59    /// Optional per-mutant timeout (seconds), shown next to each active row.
60    timeout_secs: Option<u32>,
61    /// Number of parallel workers, used in the prefix.
62    num_workers: usize,
63}
64
65impl MutationProgressState {
66    pub fn new(total_mutants: usize, num_workers: usize) -> Self {
67        Self::with_timeout(total_mutants, num_workers, None)
68    }
69
70    pub fn with_timeout(
71        total_mutants: usize,
72        num_workers: usize,
73        timeout_secs: Option<u32>,
74    ) -> Self {
75        let multi = MultiProgress::new();
76
77        // Overall progress bar: includes elapsed wall-clock plus a running
78        // tally (killed / survived / invalid / timed-out / skipped).
79        let overall_progress = multi.add(ProgressBar::new(total_mutants as u64));
80        overall_progress.set_style(
81            ProgressStyle::with_template(
82                "{bar:40.cyan/blue} {pos:>4}/{len:4} mutants ({prefix} jobs) [{elapsed_precise}] {wide_msg}",
83            )
84            .unwrap()
85            .progress_chars("##-"),
86        );
87        overall_progress.set_prefix(num_workers.to_string());
88        overall_progress.enable_steady_tick(Duration::from_millis(100));
89
90        Self {
91            multi,
92            overall_progress,
93            active_mutants: HashMap::with_capacity(num_workers),
94            counts: LiveCounts::default(),
95            timeout_secs,
96            num_workers,
97        }
98    }
99
100    /// Set the current file being tested. Renders as part of the overall
101    /// bar's message together with the running tally.
102    pub fn set_current_file(&mut self, file: &str) {
103        // Re-emit message so the file is reflected immediately.
104        let msg = self.format_message(file);
105        self.overall_progress.set_message(msg);
106    }
107
108    fn format_message(&self, file: &str) -> String {
109        let counts = self.counts;
110        let timeout_suffix = match self.timeout_secs {
111            Some(t) => format!(" · timeout {t}s/mutant"),
112            None => String::new(),
113        };
114        format!(
115            "k:{} s:{} i:{} t:{} sk:{}{} · {}",
116            counts.killed,
117            counts.survived,
118            counts.invalid,
119            counts.timed_out,
120            counts.skipped,
121            timeout_suffix,
122            file,
123        )
124    }
125
126    fn refresh_message(&self) {
127        // Preserve the file segment from the last-rendered message if any.
128        let current = self.overall_progress.message();
129        let file = current.rsplit(" · ").next().unwrap_or("");
130        self.overall_progress.set_message(self.format_message(file));
131    }
132
133    /// Stable identifier for a mutant — used as the key in `active_mutants`.
134    fn mutant_key(mutant: &Mutant) -> String {
135        format!(
136            "{}:{}-{}:{}",
137            mutant.path.display(),
138            mutant.span.lo().0,
139            mutant.span.hi().0,
140            mutant.mutation,
141        )
142    }
143
144    /// Add a mutant being tested
145    pub fn add_mutant_progress(&mut self, mutant: &Mutant) {
146        let pb = self.multi.add(ProgressBar::new_spinner());
147        pb.set_style(
148            ProgressStyle::with_template("  {spinner} {wide_msg}").unwrap().tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "),
149        );
150        pb.enable_steady_tick(Duration::from_millis(100));
151
152        let display = format!(
153            "line {}: `{}` → `{}`",
154            mutant.line_number,
155            truncate_str(&mutant.original, 40),
156            truncate_str(&mutant.mutation.to_string(), 40),
157        );
158        pb.set_message(display);
159
160        self.active_mutants
161            .insert(Self::mutant_key(mutant), ActiveMutant { pb, started_at: Instant::now() });
162    }
163
164    /// Complete a mutant and show result. Prints a one-line summary above the
165    /// bars (via `multi.suspend`) before clearing that mutant's spinner.
166    pub fn complete_mutant(&mut self, mutant: &Mutant, result: &MutationResult) {
167        self.counts.record(result);
168        self.overall_progress.inc(1);
169
170        let elapsed = self
171            .active_mutants
172            .remove(&Self::mutant_key(mutant))
173            .map(|am| {
174                let el = am.started_at.elapsed();
175                am.pb.finish_and_clear();
176                el
177            })
178            .unwrap_or_default();
179
180        // Only emit per-result completion lines for things the user cares
181        // about (kills, survivors, timeouts). Invalid and skipped are noisy.
182        // Pad the raw label *before* applying color so ANSI escapes don't
183        // throw off alignment.
184        let raw_label = format!("{:9}", result.label());
185        let label = match result {
186            MutationResult::Dead => Paint::green(&raw_label).bold().to_string(),
187            MutationResult::Alive => Paint::red(&raw_label).bold().to_string(),
188            MutationResult::TimedOut => Paint::yellow(&raw_label).bold().to_string(),
189            MutationResult::Invalid | MutationResult::Skipped => {
190                self.refresh_message();
191                return;
192            }
193        };
194
195        let line = format!(
196            "  {label} line {ln}: `{orig}` → `{mut_}` ({elapsed:.1?})",
197            ln = mutant.line_number,
198            orig = truncate_str(&mutant.original, 40),
199            mut_ = truncate_str(&mutant.mutation.to_string(), 40),
200            elapsed = elapsed,
201        );
202        self.multi.suspend(|| {
203            let _ = foundry_common::sh_println!("{line}");
204        });
205        self.refresh_message();
206    }
207
208    /// Clear all progress bars
209    pub fn clear(&mut self) {
210        for (_, am) in self.active_mutants.drain() {
211            am.pb.finish_and_clear();
212        }
213        self.overall_progress.finish_and_clear();
214        let _ = self.multi.clear();
215    }
216
217    /// Finish with a message
218    pub fn finish(&mut self, message: &str) {
219        for (_, am) in self.active_mutants.drain() {
220            am.pb.finish_and_clear();
221        }
222        self.overall_progress.finish_with_message(message.to_string());
223    }
224
225    /// Used for tests / introspection.
226    #[allow(dead_code)]
227    pub const fn num_workers(&self) -> usize {
228        self.num_workers
229    }
230}
231
232/// Thread-safe wrapper for mutation progress
233#[derive(Debug, Clone)]
234pub struct MutationProgress {
235    pub inner: Arc<Mutex<MutationProgressState>>,
236    pub cancelled: Arc<AtomicBool>,
237    pub completed: Arc<AtomicUsize>,
238    pub total: usize,
239}
240
241impl MutationProgress {
242    pub fn new(total_mutants: usize, num_workers: usize) -> Self {
243        Self::with_timeout(total_mutants, num_workers, None)
244    }
245
246    pub fn with_timeout(
247        total_mutants: usize,
248        num_workers: usize,
249        timeout_secs: Option<u32>,
250    ) -> Self {
251        Self {
252            inner: Arc::new(Mutex::new(MutationProgressState::with_timeout(
253                total_mutants,
254                num_workers,
255                timeout_secs,
256            ))),
257            cancelled: Arc::new(AtomicBool::new(false)),
258            completed: Arc::new(AtomicUsize::new(0)),
259            total: total_mutants,
260        }
261    }
262
263    /// Check if testing was cancelled (Ctrl+C)
264    pub fn is_cancelled(&self) -> bool {
265        self.cancelled.load(Ordering::SeqCst)
266    }
267
268    /// Signal cancellation
269    pub fn cancel(&self) {
270        self.cancelled.store(true, Ordering::SeqCst);
271    }
272
273    /// Set the current file
274    pub fn set_current_file(&self, file: &str) {
275        self.inner.lock().set_current_file(file);
276    }
277
278    /// Record a mutant starting
279    pub fn start_mutant(&self, mutant: &Mutant) {
280        self.inner.lock().add_mutant_progress(mutant);
281    }
282
283    /// Record a mutant completing
284    pub fn complete_mutant(&self, mutant: &Mutant, result: &MutationResult) -> usize {
285        let completed = self.completed.fetch_add(1, Ordering::SeqCst) + 1;
286        self.inner.lock().complete_mutant(mutant, result);
287        completed
288    }
289
290    /// Clear progress display
291    pub fn clear(&self) {
292        MutationProgressState::clear(&mut self.inner.lock());
293    }
294
295    /// Finish with message
296    pub fn finish(&self, message: &str) {
297        self.inner.lock().finish(message);
298    }
299}
300
301/// Truncate a string to max length, centering around the middle (where the operator typically is)
302fn truncate_str(s: &str, max_len: usize) -> String {
303    let s = s.trim();
304    if s.len() <= max_len {
305        return s.to_string();
306    }
307
308    // Center the truncation around the middle of the string
309    let half = max_len.saturating_sub(3) / 2; // Leave room for "..."
310    let mid = s.len() / 2;
311    let start = mid.saturating_sub(half);
312    let end = (start + max_len.saturating_sub(3)).min(s.len());
313
314    if start == 0 {
315        format!("{}...", &s[..end])
316    } else if end == s.len() {
317        format!("...{}", &s[start..])
318    } else {
319        format!("...{}...", &s[start..end])
320    }
321}