Skip to main content

nextest_runner/list/
progress.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::{
5    helpers::{
6        ThemeCharacters, decimal_char_width, plural,
7        progress::{
8            PROGRESS_REFRESH_RATE_HZ, ShowTerminalProgress, TerminalProgress, progress_bar_style,
9            term_progress_percent,
10        },
11    },
12    reporter::ShowProgress,
13};
14use anstyle_progress::TermProgress;
15use indicatif::{ProgressBar, ProgressDrawTarget};
16use owo_colors::{OwoColorize, Style};
17use std::{
18    env,
19    io::{self, IsTerminal},
20    time::{Duration, Instant},
21};
22
23const LIST_PROGRESS_REVEAL_DELAY: Duration = Duration::from_secs(2);
24
25const LIST_PROGRESS_TICK_INTERVAL: Duration = Duration::from_millis(100);
26
27const LIST_PROGRESS_DELAY_ENV: &str = "__NEXTEST_LIST_PROGRESS_DELAY_MS";
28
29pub(crate) enum ListProgressEvent {
30    Tick,
31    BinaryProcessed,
32}
33
34/// Options for the list progress reporter.
35#[derive(Debug)]
36pub struct ListProgressOptions {
37    show_progress: ShowProgress,
38    show_terminal_progress: ShowTerminalProgress,
39    theme_characters: ThemeCharacters,
40    should_colorize: bool,
41}
42
43impl ListProgressOptions {
44    /// Creates a new set of list progress reporter options.
45    pub fn new(
46        show_progress: ShowProgress,
47        show_terminal_progress: ShowTerminalProgress,
48        theme_characters: ThemeCharacters,
49        should_colorize: bool,
50    ) -> Self {
51        Self {
52            show_progress,
53            show_terminal_progress,
54            theme_characters,
55            should_colorize,
56        }
57    }
58}
59
60pub(crate) struct ListProgressReporter {
61    bar: Option<ProgressBar>,
62    term_progress: Option<TerminalProgress>,
63    total: usize,
64    listed: usize,
65    state: RevealState,
66}
67
68#[derive(Clone, Copy, Debug)]
69enum RevealState {
70    Hidden { start: Instant, delay: Duration },
71    Revealed,
72    Finished,
73}
74
75impl ListProgressReporter {
76    pub(crate) fn new(total: usize, options: &ListProgressOptions) -> Self {
77        let is_terminal = io::stderr().is_terminal();
78        let is_ci = is_ci::uncached();
79        let bar = list_bar_enabled(options.show_progress, is_terminal, is_ci).then(|| {
80            make_bar(
81                total,
82                options.theme_characters.progress_chars(),
83                options.should_colorize,
84            )
85        });
86        let term_progress = TerminalProgress::new(options.show_terminal_progress);
87        Self::from_parts(total, bar, term_progress, reveal_delay_from_env())
88    }
89
90    fn from_parts(
91        total: usize,
92        bar: Option<ProgressBar>,
93        term_progress: Option<TerminalProgress>,
94        reveal_delay: Duration,
95    ) -> Self {
96        Self {
97            bar,
98            term_progress,
99            total,
100            listed: 0,
101            state: RevealState::Hidden {
102                start: Instant::now(),
103                delay: reveal_delay,
104            },
105        }
106    }
107
108    pub(crate) fn tick_interval(&self) -> Duration {
109        LIST_PROGRESS_TICK_INTERVAL
110    }
111
112    pub(crate) fn handle_event(&mut self, event: ListProgressEvent) {
113        match event {
114            ListProgressEvent::BinaryProcessed => {
115                self.listed += 1;
116                debug_assert!(
117                    self.listed <= self.total,
118                    "at most {} binaries can be processed, but saw {}",
119                    self.total,
120                    self.listed,
121                );
122                if let Some(bar) = &self.bar {
123                    bar.inc(1);
124                }
125                if self.is_revealed() {
126                    // Terminal progress doesn't have a timer, so re-emit it
127                    // only when we learn that a new binary has been listed.
128                    //
129                    // Worth noting that we do not install a signal handler
130                    // during this phase, so in case of a Ctrl-C we don't clear
131                    // terminal progress. A signal handler is quite tricky here
132                    // and adds complexity for minimal benefit (unlike at
133                    // runtime, where signals are part of the test lifecycle
134                    // state machine).
135                    let value = self.current_term_progress();
136                    self.emit_terminal_progress(value);
137                }
138            }
139            ListProgressEvent::Tick => {
140                if let RevealState::Hidden { start, delay } = self.state
141                    && start.elapsed() >= delay
142                {
143                    self.reveal();
144                }
145                if self.is_revealed()
146                    && let Some(bar) = &self.bar
147                {
148                    // Force a redraw each tick so the elapsed timer advances.
149                    bar.force_draw();
150                }
151            }
152        }
153    }
154
155    fn finish(&mut self) {
156        let was_revealed = match self.state {
157            RevealState::Hidden { .. } => false,
158            RevealState::Revealed => true,
159            RevealState::Finished => return,
160        };
161        self.state = RevealState::Finished;
162        if let Some(bar) = &self.bar {
163            bar.finish_and_clear();
164        }
165        if was_revealed {
166            self.emit_terminal_progress(TermProgress::remove());
167        }
168    }
169
170    fn reveal(&mut self) {
171        self.state = RevealState::Revealed;
172        if let Some(bar) = &self.bar {
173            bar.set_draw_target(ProgressDrawTarget::stderr_with_hz(PROGRESS_REFRESH_RATE_HZ));
174        }
175        let value = self.current_term_progress();
176        self.emit_terminal_progress(value);
177    }
178
179    fn emit_terminal_progress(&mut self, value: TermProgress) {
180        if let Some(term_progress) = &mut self.term_progress {
181            term_progress.set(value);
182            term_progress.emit();
183        }
184    }
185
186    fn current_term_progress(&self) -> TermProgress {
187        TermProgress::start().percent(term_progress_percent(self.listed, self.total))
188    }
189
190    fn is_revealed(&self) -> bool {
191        match self.state {
192            RevealState::Revealed => true,
193            RevealState::Hidden { .. } | RevealState::Finished => false,
194        }
195    }
196}
197
198impl Drop for ListProgressReporter {
199    fn drop(&mut self) {
200        self.finish();
201    }
202}
203
204fn list_bar_enabled(show_progress: ShowProgress, is_terminal: bool, is_ci: bool) -> bool {
205    match show_progress {
206        ShowProgress::Auto { .. } | ShowProgress::Running => is_terminal && !is_ci,
207        ShowProgress::Counter | ShowProgress::None => false,
208    }
209}
210
211fn make_bar(total: usize, progress_chars: &str, should_colorize: bool) -> ProgressBar {
212    let styles = ListProgressStyles::new(should_colorize);
213    let bar = ProgressBar::new(total as u64);
214    // We start with the progress bar being hidden, and only reveal it after the
215    // delay. This makes fast listings be silent while showing progress for
216    // slower ones.
217    bar.set_draw_target(ProgressDrawTarget::hidden());
218    let count_width = decimal_char_width(total);
219    let suffix = format!("{{pos:>{count_width}}}/{{len:{count_width}}} {{msg}}");
220    bar.set_style(progress_bar_style(progress_chars, &suffix));
221    bar.set_prefix("Listing".style(styles.label).to_string());
222    bar.set_message(plural::binaries_str(total).style(styles.label).to_string());
223    bar
224}
225
226struct ListProgressStyles {
227    label: Style,
228}
229
230impl ListProgressStyles {
231    fn new(should_colorize: bool) -> Self {
232        let label = if should_colorize {
233            Style::new().green().bold()
234        } else {
235            Style::new()
236        };
237        Self { label }
238    }
239}
240
241fn reveal_delay_from_env() -> Duration {
242    let Some(raw) = env::var_os(LIST_PROGRESS_DELAY_ENV) else {
243        return LIST_PROGRESS_REVEAL_DELAY;
244    };
245    let raw = raw.to_str().unwrap_or_else(|| {
246        panic!("{LIST_PROGRESS_DELAY_ENV} contains non-UTF-8 bytes");
247    });
248    let ms: u64 = raw.parse().unwrap_or_else(|err| {
249        panic!(
250            "{LIST_PROGRESS_DELAY_ENV}={raw:?} is not a valid number of \
251             milliseconds: {err}"
252        )
253    });
254    Duration::from_millis(ms)
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn enabled_reporter(total: usize, reveal_delay: Duration) -> ListProgressReporter {
262        ListProgressReporter::from_parts(
263            total,
264            Some(make_bar(total, "=> ", false)),
265            None,
266            reveal_delay,
267        )
268    }
269
270    #[test]
271    fn list_bar_enabled_gating() {
272        let bar_modes = [
273            ShowProgress::Auto {
274                suppress_success: false,
275            },
276            ShowProgress::Auto {
277                suppress_success: true,
278            },
279            ShowProgress::Running,
280        ];
281        for mode in bar_modes {
282            assert!(
283                list_bar_enabled(mode, true, false),
284                "{mode:?}: interactive terminal shows a bar"
285            );
286            assert!(
287                !list_bar_enabled(mode, false, false),
288                "{mode:?}: piped output shows no bar"
289            );
290            assert!(
291                !list_bar_enabled(mode, true, true),
292                "{mode:?}: CI shows no bar"
293            );
294            assert!(
295                !list_bar_enabled(mode, false, true),
296                "{mode:?}: piped in CI shows no bar"
297            );
298        }
299
300        for mode in [ShowProgress::Counter, ShowProgress::None] {
301            for is_terminal in [true, false] {
302                for is_ci in [true, false] {
303                    assert!(
304                        !list_bar_enabled(mode, is_terminal, is_ci),
305                        "{mode:?} never shows a list bar"
306                    );
307                }
308            }
309        }
310    }
311
312    #[test]
313    fn bar_hidden_until_reveal_delay() {
314        let mut reporter = enabled_reporter(10, Duration::MAX);
315        assert!(!reporter.is_revealed(), "the bar starts unrevealed");
316
317        reporter.handle_event(ListProgressEvent::Tick);
318        assert!(
319            !reporter.is_revealed(),
320            "the bar is not revealed before the reveal delay elapses"
321        );
322
323        let mut reporter = enabled_reporter(10, Duration::ZERO);
324        assert!(!reporter.is_revealed(), "the bar starts unrevealed");
325
326        reporter.handle_event(ListProgressEvent::Tick);
327        assert!(
328            reporter.is_revealed(),
329            "the bar is revealed once the reveal delay elapses"
330        );
331    }
332
333    #[test]
334    fn binary_processed_advances_position() {
335        let mut reporter = enabled_reporter(4, Duration::MAX);
336        let bar = reporter.bar.as_ref().expect("bar is enabled").clone();
337        assert_eq!(bar.position(), 0);
338
339        reporter.handle_event(ListProgressEvent::BinaryProcessed);
340        reporter.handle_event(ListProgressEvent::BinaryProcessed);
341
342        assert_eq!(bar.position(), 2);
343        assert_eq!(reporter.listed, 2);
344    }
345
346    fn terminal_only_reporter(total: usize, reveal_delay: Duration) -> ListProgressReporter {
347        let term_progress = TerminalProgress::new(ShowTerminalProgress::Yes)
348            .expect("Yes yields a terminal progress reporter");
349        ListProgressReporter::from_parts(total, None, Some(term_progress), reveal_delay)
350    }
351
352    fn last_osc(reporter: &ListProgressReporter) -> String {
353        reporter
354            .term_progress
355            .as_ref()
356            .expect("terminal progress is enabled")
357            .last_value()
358            .to_string()
359    }
360
361    #[test]
362    fn terminal_progress_silent_until_revealed() {
363        let mut reporter = terminal_only_reporter(4, Duration::MAX);
364
365        reporter.handle_event(ListProgressEvent::BinaryProcessed);
366        reporter.handle_event(ListProgressEvent::BinaryProcessed);
367        reporter.handle_event(ListProgressEvent::Tick);
368        assert!(
369            last_osc(&reporter).is_empty(),
370            "no OSC value is emitted before the reveal delay"
371        );
372
373        reporter.finish();
374        assert!(
375            last_osc(&reporter).is_empty(),
376            "a listing that never revealed emits no Remove"
377        );
378    }
379
380    #[test]
381    fn terminal_progress_emits_after_reveal() {
382        let mut reporter = terminal_only_reporter(4, Duration::ZERO);
383
384        reporter.handle_event(ListProgressEvent::BinaryProcessed);
385        reporter.handle_event(ListProgressEvent::BinaryProcessed);
386        assert!(
387            last_osc(&reporter).is_empty(),
388            "no OSC value is emitted before the first tick reveals"
389        );
390
391        reporter.handle_event(ListProgressEvent::Tick);
392        assert!(reporter.is_revealed());
393        assert_eq!(
394            last_osc(&reporter),
395            TermProgress::start().percent(50).to_string(),
396            "reveal emits the current percentage"
397        );
398
399        reporter.handle_event(ListProgressEvent::BinaryProcessed);
400        assert_eq!(
401            last_osc(&reporter),
402            TermProgress::start().percent(75).to_string(),
403            "a subsequent binary re-emits the updated percentage"
404        );
405
406        reporter.finish();
407        assert_eq!(
408            last_osc(&reporter),
409            TermProgress::remove().to_string(),
410            "finish emits Remove once revealed"
411        );
412    }
413
414    #[test]
415    fn reveal_delay_from_env_values() {
416        unsafe { env::remove_var(LIST_PROGRESS_DELAY_ENV) };
417        assert_eq!(
418            reveal_delay_from_env(),
419            LIST_PROGRESS_REVEAL_DELAY,
420            "an unset variable falls back to the default delay"
421        );
422
423        unsafe { env::set_var(LIST_PROGRESS_DELAY_ENV, "250") };
424        assert_eq!(
425            reveal_delay_from_env(),
426            Duration::from_millis(250),
427            "a valid value is parsed as milliseconds"
428        );
429    }
430
431    #[test]
432    #[should_panic(expected = "is not a valid number of milliseconds")]
433    fn reveal_delay_from_env_panics_on_malformed_value() {
434        unsafe { env::set_var(LIST_PROGRESS_DELAY_ENV, "0ms") };
435        reveal_delay_from_env();
436    }
437}