1use 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#[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 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 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 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 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}