1use crate::{
5 cargo_config::{CargoConfigs, DiscoveredConfig},
6 reporter::{displayer::formatters::DisplayBracketedHhMmSs, events::*, helpers::Styles},
7};
8use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
9use owo_colors::OwoColorize;
10use std::{
11 env, fmt,
12 io::{self, IsTerminal, Write},
13 time::Duration,
14};
15use swrite::{SWrite, swrite};
16use tracing::debug;
17
18#[derive(Default, Clone, Copy, Debug)]
20pub enum ShowProgress {
21 #[default]
23 Auto,
24
25 None,
27
28 Bar,
30
31 Counter,
33}
34
35#[derive(Debug)]
36pub(super) struct ProgressBarState {
37 bar: ProgressBar,
38 hidden_no_capture: bool,
48 hidden_run_paused: bool,
49 hidden_info_response: bool,
50 hidden_between_sub_runs: bool,
51}
52
53impl ProgressBarState {
54 pub(super) fn new(test_count: usize, progress_chars: &str) -> Self {
55 let bar = ProgressBar::new(test_count as u64);
56
57 let test_count_width = format!("{test_count}").len();
58 let template = format!(
63 "{{prefix:>12}} [{{elapsed_precise:>9}}] {{wide_bar}} \
64 {{pos:>{test_count_width}}}/{{len:{test_count_width}}}: {{msg}} "
65 );
66 bar.set_style(
67 ProgressStyle::default_bar()
68 .progress_chars(progress_chars)
69 .template(&template)
70 .expect("template is known to be valid"),
71 );
72
73 bar.set_draw_target(Self::stderr_target());
76 bar.enable_steady_tick(Duration::from_millis(100));
78
79 Self {
80 bar,
81 hidden_no_capture: false,
82 hidden_run_paused: false,
83 hidden_info_response: false,
84 hidden_between_sub_runs: false,
85 }
86 }
87
88 pub(super) fn update_progress_bar(&mut self, event: &TestEvent<'_>, styles: &Styles) {
89 let before_should_hide = self.should_hide();
90
91 match &event.kind {
92 TestEventKind::StressSubRunFinished { .. } => {
93 self.hidden_between_sub_runs = true;
96 }
97 TestEventKind::SetupScriptStarted { no_capture, .. } => {
98 if *no_capture {
100 self.hidden_no_capture = true;
101 }
102 self.hidden_between_sub_runs = false;
103 }
104 TestEventKind::SetupScriptFinished { no_capture, .. } => {
105 if *no_capture {
107 self.hidden_no_capture = false;
108 }
109 self.hidden_between_sub_runs = false;
110 }
111 TestEventKind::TestStarted {
112 current_stats,
113 running,
114 ..
115 }
116 | TestEventKind::TestFinished {
117 current_stats,
118 running,
119 ..
120 } => {
121 self.hidden_between_sub_runs = false;
122 self.bar.set_prefix(progress_bar_prefix(
123 current_stats,
124 current_stats.cancel_reason,
125 styles,
126 ));
127 self.bar
128 .set_message(progress_bar_msg(current_stats, *running, styles));
129 self.bar.set_length(current_stats.initial_run_count as u64);
132 self.bar.set_position(current_stats.finished_count as u64);
133 }
134 TestEventKind::InfoStarted { .. } => {
135 self.hidden_info_response = true;
138 }
139 TestEventKind::InfoFinished { .. } => {
140 self.hidden_info_response = false;
142 }
143 TestEventKind::RunPaused { .. } => {
144 self.hidden_run_paused = true;
147 }
148 TestEventKind::RunContinued { .. } => {
149 self.hidden_run_paused = false;
152 let bar = std::mem::replace(&mut self.bar, ProgressBar::hidden());
154 self.bar = bar.with_elapsed(event.elapsed);
155 }
156 TestEventKind::RunBeginCancel { current_stats, .. }
157 | TestEventKind::RunBeginKill { current_stats, .. } => {
158 self.bar.set_prefix(progress_bar_cancel_prefix(
159 current_stats.cancel_reason,
160 styles,
161 ));
162 }
163 _ => {}
164 }
165
166 let after_should_hide = self.should_hide();
167
168 match (before_should_hide, after_should_hide) {
169 (false, true) => self.bar.set_draw_target(Self::hidden_target()),
170 (true, false) => self.bar.set_draw_target(Self::stderr_target()),
171 _ => {}
172 }
173 }
174
175 pub(super) fn write_buf(&self, buf: &[u8]) -> io::Result<()> {
176 self.bar.suspend(|| std::io::stderr().write_all(buf))
179 }
180
181 #[inline]
182 pub(super) fn finish_and_clear(&self) {
183 self.bar.finish_and_clear();
184 }
185
186 fn stderr_target() -> ProgressDrawTarget {
187 ProgressDrawTarget::stderr_with_hz(20)
191 }
192
193 fn hidden_target() -> ProgressDrawTarget {
194 ProgressDrawTarget::hidden()
195 }
196
197 fn should_hide(&self) -> bool {
198 self.hidden_no_capture
199 || self.hidden_run_paused
200 || self.hidden_info_response
201 || self.hidden_between_sub_runs
202 }
203
204 pub(super) fn is_hidden(&self) -> bool {
205 self.bar.is_hidden()
206 }
207}
208
209pub(super) struct TerminalProgress {}
211
212impl TerminalProgress {
213 const ENV: &str = "CARGO_TERM_PROGRESS_TERM_INTEGRATION";
214
215 pub(super) fn new(configs: &CargoConfigs, stream: &dyn IsTerminal) -> Option<Self> {
216 for config in configs.discovered_configs() {
218 match config {
219 DiscoveredConfig::CliOption { config, source } => {
220 if let Some(v) = config.term.progress.term_integration {
221 if v {
222 debug!("enabling terminal progress reporting based on {source:?}");
223 return Some(Self {});
224 } else {
225 debug!("disabling terminal progress reporting based on {source:?}");
226 return None;
227 }
228 }
229 }
230 DiscoveredConfig::Env => {
231 if let Some(v) = env::var_os(Self::ENV) {
232 if v == "true" {
233 debug!(
234 "enabling terminal progress reporting based on \
235 CARGO_TERM_PROGRESS_TERM_INTEGRATION environment variable"
236 );
237 return Some(Self {});
238 } else if v == "false" {
239 debug!(
240 "disabling terminal progress reporting based on \
241 CARGO_TERM_PROGRESS_TERM_INTEGRATION environment variable"
242 );
243 return None;
244 } else {
245 debug!(
246 "invalid value for CARGO_TERM_PROGRESS_TERM_INTEGRATION \
247 environment variable: {v:?}, ignoring"
248 );
249 }
250 }
251 }
252 DiscoveredConfig::File { config, source } => {
253 if let Some(v) = config.term.progress.term_integration {
254 if v {
255 debug!("enabling terminal progress reporting based on {source:?}");
256 return Some(Self {});
257 } else {
258 debug!("disabling terminal progress reporting based on {source:?}");
259 return None;
260 }
261 }
262 }
263 }
264 }
265
266 supports_osc_9_4(stream).then_some(TerminalProgress {})
267 }
268
269 pub(super) fn update_progress(
270 &self,
271 event: &TestEvent<'_>,
272 writer: &mut dyn Write,
273 ) -> Result<(), io::Error> {
274 let value = match &event.kind {
275 TestEventKind::RunStarted { .. }
276 | TestEventKind::StressSubRunStarted { .. }
277 | TestEventKind::StressSubRunFinished { .. }
278 | TestEventKind::SetupScriptStarted { .. }
279 | TestEventKind::SetupScriptSlow { .. }
280 | TestEventKind::SetupScriptFinished { .. } => TerminalProgressValue::None,
281 TestEventKind::TestStarted { current_stats, .. }
282 | TestEventKind::TestFinished { current_stats, .. } => {
283 let percentage = (current_stats.finished_count as f64
284 / current_stats.initial_run_count as f64)
285 * 100.0;
286 if current_stats.has_failures() || current_stats.cancel_reason.is_some() {
287 TerminalProgressValue::Error(percentage)
288 } else {
289 TerminalProgressValue::Value(percentage)
290 }
291 }
292 TestEventKind::TestSlow { .. }
293 | TestEventKind::TestAttemptFailedWillRetry { .. }
294 | TestEventKind::TestRetryStarted { .. }
295 | TestEventKind::TestSkipped { .. }
296 | TestEventKind::InfoStarted { .. }
297 | TestEventKind::InfoResponse { .. }
298 | TestEventKind::InfoFinished { .. }
299 | TestEventKind::InputEnter { .. } => TerminalProgressValue::None,
300 TestEventKind::RunBeginCancel { current_stats, .. }
301 | TestEventKind::RunBeginKill { current_stats, .. } => {
302 let percentage = (current_stats.finished_count as f64
304 / current_stats.initial_run_count as f64)
305 * 100.0;
306 TerminalProgressValue::Error(percentage)
307 }
308 TestEventKind::RunPaused { .. }
309 | TestEventKind::RunContinued { .. }
310 | TestEventKind::RunFinished { .. } => {
311 TerminalProgressValue::Remove
314 }
315 };
316
317 write!(writer, "{value}")
318 }
319}
320
321fn supports_osc_9_4(stream: &dyn IsTerminal) -> bool {
323 if !stream.is_terminal() {
324 debug!(
325 "autodetect terminal progress reporting: disabling since \
326 passed-in stream (usually stderr) is not a terminal"
327 );
328 return false;
329 }
330 if std::env::var("WT_SESSION").is_ok() {
331 debug!("autodetect terminal progress reporting: enabling since WT_SESSION is set");
332 return true;
333 };
334 if std::env::var("ConEmuANSI").ok() == Some("ON".into()) {
335 debug!("autodetect terminal progress reporting: enabling since ConEmuANSI is ON");
336 return true;
337 }
338 if std::env::var("TERM_PROGRAM").ok() == Some("WezTerm".into()) {
339 debug!("autodetect terminal progress reporting: enabling since TERM_PROGRAM is WezTerm");
340 return true;
341 }
342
343 false
344}
345
346#[derive(PartialEq, Debug)]
350enum TerminalProgressValue {
351 None,
353 Remove,
355 Value(f64),
357 #[expect(dead_code)]
361 Indeterminate,
362 Error(f64),
364}
365
366impl fmt::Display for TerminalProgressValue {
367 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368 let (state, progress) = match self {
376 Self::None => return Ok(()), Self::Remove => (0, 0.0),
378 Self::Value(v) => (1, *v),
379 Self::Indeterminate => (3, 0.0),
380 Self::Error(v) => (2, *v),
381 };
382 write!(f, "\x1b]9;4;{state};{progress:.0}\x1b\\")
383 }
384}
385
386pub(super) fn progress_str(
388 elapsed: Duration,
389 current_stats: &RunStats,
390 running: usize,
391 styles: &Styles,
392) -> String {
393 let mut s = progress_bar_prefix(current_stats, current_stats.cancel_reason, styles);
395
396 swrite!(
398 s,
399 " {}{}/{}: {}",
400 DisplayBracketedHhMmSs(elapsed),
401 current_stats.finished_count,
402 current_stats.initial_run_count,
403 progress_bar_msg(current_stats, running, styles)
404 );
405
406 s
407}
408
409pub(super) fn write_summary_str(run_stats: &RunStats, styles: &Styles, out: &mut String) {
410 let &RunStats {
412 initial_run_count: _,
413 finished_count: _,
414 setup_scripts_initial_count: _,
415 setup_scripts_finished_count: _,
416 setup_scripts_passed: _,
417 setup_scripts_failed: _,
418 setup_scripts_exec_failed: _,
419 setup_scripts_timed_out: _,
420 passed,
421 passed_slow,
422 flaky,
423 failed,
424 failed_slow: _,
425 timed_out,
426 leaky,
427 leaky_failed,
428 exec_failed,
429 skipped,
430 cancel_reason: _,
431 } = run_stats;
432
433 swrite!(
434 out,
435 "{} {}",
436 passed.style(styles.count),
437 "passed".style(styles.pass)
438 );
439
440 if passed_slow > 0 || flaky > 0 || leaky > 0 {
441 let mut text = Vec::with_capacity(3);
442 if passed_slow > 0 {
443 text.push(format!(
444 "{} {}",
445 passed_slow.style(styles.count),
446 "slow".style(styles.skip),
447 ));
448 }
449 if flaky > 0 {
450 text.push(format!(
451 "{} {}",
452 flaky.style(styles.count),
453 "flaky".style(styles.skip),
454 ));
455 }
456 if leaky > 0 {
457 text.push(format!(
458 "{} {}",
459 leaky.style(styles.count),
460 "leaky".style(styles.skip),
461 ));
462 }
463 swrite!(out, " ({})", text.join(", "));
464 }
465 swrite!(out, ", ");
466
467 if failed > 0 {
468 swrite!(
469 out,
470 "{} {}",
471 failed.style(styles.count),
472 "failed".style(styles.fail),
473 );
474 if leaky_failed > 0 {
475 swrite!(
476 out,
477 " ({} due to being {})",
478 leaky_failed.style(styles.count),
479 "leaky".style(styles.fail),
480 );
481 }
482 swrite!(out, ", ");
483 }
484
485 if exec_failed > 0 {
486 swrite!(
487 out,
488 "{} {}, ",
489 exec_failed.style(styles.count),
490 "exec failed".style(styles.fail),
491 );
492 }
493
494 if timed_out > 0 {
495 swrite!(
496 out,
497 "{} {}, ",
498 timed_out.style(styles.count),
499 "timed out".style(styles.fail),
500 );
501 }
502
503 swrite!(
504 out,
505 "{} {}",
506 skipped.style(styles.count),
507 "skipped".style(styles.skip),
508 );
509}
510
511fn progress_bar_cancel_prefix(reason: Option<CancelReason>, styles: &Styles) -> String {
512 let status = match reason {
513 Some(CancelReason::SetupScriptFailure)
514 | Some(CancelReason::TestFailure)
515 | Some(CancelReason::ReportError)
516 | Some(CancelReason::GlobalTimeout)
517 | Some(CancelReason::Signal)
518 | Some(CancelReason::Interrupt)
519 | None => "Cancelling",
520 Some(CancelReason::SecondSignal) => "Killing",
521 };
522 format!("{:>12}", status.style(styles.fail))
523}
524
525fn progress_bar_prefix(
526 run_stats: &RunStats,
527 cancel_reason: Option<CancelReason>,
528 styles: &Styles,
529) -> String {
530 if let Some(reason) = cancel_reason {
531 return progress_bar_cancel_prefix(Some(reason), styles);
532 }
533
534 let style = if run_stats.has_failures() {
535 styles.fail
536 } else {
537 styles.pass
538 };
539
540 format!("{:>12}", "Running".style(style))
541}
542
543pub(super) fn progress_bar_msg(
544 current_stats: &RunStats,
545 running: usize,
546 styles: &Styles,
547) -> String {
548 let mut s = format!("{} running, ", running.style(styles.count));
549 write_summary_str(current_stats, styles, &mut s);
550 s
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
558 fn test_progress_bar_prefix() {
559 let mut styles = Styles::default();
560 styles.colorize();
561
562 for (name, stats) in run_stats_test_failure_examples() {
563 let prefix = progress_bar_prefix(&stats, Some(CancelReason::TestFailure), &styles);
564 assert_eq!(
565 prefix,
566 " Cancelling".style(styles.fail).to_string(),
567 "{name} matches"
568 );
569 }
570 for (name, stats) in run_stats_setup_script_failure_examples() {
571 let prefix =
572 progress_bar_prefix(&stats, Some(CancelReason::SetupScriptFailure), &styles);
573 assert_eq!(
574 prefix,
575 " Cancelling".style(styles.fail).to_string(),
576 "{name} matches"
577 );
578 }
579
580 let prefix = progress_bar_prefix(&RunStats::default(), Some(CancelReason::Signal), &styles);
581 assert_eq!(prefix, " Cancelling".style(styles.fail).to_string());
582
583 let prefix = progress_bar_prefix(&RunStats::default(), None, &styles);
584 assert_eq!(prefix, " Running".style(styles.pass).to_string());
585
586 for (name, stats) in run_stats_test_failure_examples() {
587 let prefix = progress_bar_prefix(&stats, None, &styles);
588 assert_eq!(
589 prefix,
590 " Running".style(styles.fail).to_string(),
591 "{name} matches"
592 );
593 }
594 for (name, stats) in run_stats_setup_script_failure_examples() {
595 let prefix = progress_bar_prefix(&stats, None, &styles);
596 assert_eq!(
597 prefix,
598 " Running".style(styles.fail).to_string(),
599 "{name} matches"
600 );
601 }
602 }
603
604 #[test]
605 fn progress_str_snapshots() {
606 let mut styles = Styles::default();
607 styles.colorize();
608
609 let elapsed = Duration::from_secs(123456);
611 let running = 10;
612
613 for (name, stats) in run_stats_test_failure_examples() {
614 let s = progress_str(elapsed, &stats, running, &styles);
615 insta::assert_snapshot!(format!("{name}_with_cancel_reason"), s);
616
617 let mut stats = stats;
618 stats.cancel_reason = None;
619 let s = progress_str(elapsed, &stats, running, &styles);
620 insta::assert_snapshot!(format!("{name}_without_cancel_reason"), s);
621 }
622
623 for (name, stats) in run_stats_setup_script_failure_examples() {
624 let s = progress_str(elapsed, &stats, running, &styles);
625 insta::assert_snapshot!(format!("{name}_with_cancel_reason"), s);
626
627 let mut stats = stats;
628 stats.cancel_reason = None;
629 let s = progress_str(elapsed, &stats, running, &styles);
630 insta::assert_snapshot!(format!("{name}_without_cancel_reason"), s);
631 }
632 }
633
634 fn run_stats_test_failure_examples() -> Vec<(&'static str, RunStats)> {
635 vec![
636 (
637 "one_failed",
638 RunStats {
639 initial_run_count: 20,
640 finished_count: 1,
641 failed: 1,
642 cancel_reason: Some(CancelReason::TestFailure),
643 ..RunStats::default()
644 },
645 ),
646 (
647 "one_failed_one_passed",
648 RunStats {
649 initial_run_count: 20,
650 finished_count: 2,
651 failed: 1,
652 passed: 1,
653 cancel_reason: Some(CancelReason::TestFailure),
654 ..RunStats::default()
655 },
656 ),
657 (
658 "one_exec_failed",
659 RunStats {
660 initial_run_count: 20,
661 finished_count: 10,
662 exec_failed: 1,
663 cancel_reason: Some(CancelReason::TestFailure),
664 ..RunStats::default()
665 },
666 ),
667 (
668 "one_timed_out",
669 RunStats {
670 initial_run_count: 20,
671 finished_count: 10,
672 timed_out: 1,
673 cancel_reason: Some(CancelReason::TestFailure),
674 ..RunStats::default()
675 },
676 ),
677 ]
678 }
679
680 fn run_stats_setup_script_failure_examples() -> Vec<(&'static str, RunStats)> {
681 vec![
682 (
683 "one_setup_script_failed",
684 RunStats {
685 initial_run_count: 30,
686 setup_scripts_failed: 1,
687 cancel_reason: Some(CancelReason::SetupScriptFailure),
688 ..RunStats::default()
689 },
690 ),
691 (
692 "one_setup_script_exec_failed",
693 RunStats {
694 initial_run_count: 35,
695 setup_scripts_exec_failed: 1,
696 cancel_reason: Some(CancelReason::SetupScriptFailure),
697 ..RunStats::default()
698 },
699 ),
700 (
701 "one_setup_script_timed_out",
702 RunStats {
703 initial_run_count: 40,
704 setup_scripts_timed_out: 1,
705 cancel_reason: Some(CancelReason::SetupScriptFailure),
706 ..RunStats::default()
707 },
708 ),
709 ]
710 }
711}