Skip to main content

cargo/util/
progress.rs

1//! Support for CLI progress bars.
2
3use std::cmp;
4use std::time::{Duration, Instant};
5
6use crate::util::context::ProgressWhen;
7use crate::util::{CargoResult, GlobalContext};
8use anstyle_progress::TermProgress;
9use cargo_util::is_ci;
10use cargo_util_terminal::Shell;
11use cargo_util_terminal::Verbosity;
12use unicode_width::UnicodeWidthChar;
13
14/// CLI progress bar.
15///
16/// The `Progress` object can be in an enabled or disabled state. When
17/// disabled, calling any of the methods to update it will not display
18/// anything. Disabling is typically done by the user with options such as
19/// `--quiet` or the `term.progress` config option.
20///
21/// There are several methods to update the progress bar and to cause it to
22/// update its display.
23///
24/// The bar will be removed from the display when the `Progress` object is
25/// dropped or [`Progress::clear`] is called.
26///
27/// The progress bar has built-in rate limiting to avoid updating the display
28/// too fast. It should usually be fine to call [`Progress::tick`] as often as
29/// needed, though be cautious if the tick rate is very high or it is
30/// expensive to compute the progress value.
31pub struct Progress<'gctx> {
32    gctx: &'gctx GlobalContext,
33    state: Option<State>,
34}
35
36impl<'gctx> Progress<'gctx> {
37    /// Creates a new `Progress` with the [`ProgressStyle::Percentage`] style.
38    ///
39    /// See [`Progress::with_style`] for more information.
40    pub fn new(name: &str, gctx: &'gctx GlobalContext) -> Progress<'gctx> {
41        Self::with_style(name, ProgressStyle::Percentage, gctx)
42    }
43
44    /// Creates a new progress bar.
45    ///
46    /// The first parameter is the text displayed to the left of the bar, such
47    /// as "Fetching".
48    ///
49    /// The progress bar is not displayed until explicitly updated with one if
50    /// its methods.
51    ///
52    /// The progress bar may be created in a disabled state if the user has
53    /// disabled progress display (such as with the `--quiet` option).
54    pub fn with_style(
55        name: &str,
56        style: ProgressStyle,
57        gctx: &'gctx GlobalContext,
58    ) -> Progress<'gctx> {
59        // report no progress when -q (for quiet) or TERM=dumb are set
60        // or if running on Continuous Integration service like Travis where the
61        // output logs get mangled.
62        let dumb = match gctx.get_env("TERM") {
63            Ok(term) => term == "dumb",
64            Err(_) => false,
65        };
66        let progress_config = gctx.progress_config();
67        match progress_config.when {
68            ProgressWhen::Always => return Progress::new_priv(name, style, gctx),
69            ProgressWhen::Never => return Progress { gctx, state: None },
70            ProgressWhen::Auto => {}
71        }
72        if gctx.shell().verbosity() == Verbosity::Quiet || dumb || is_ci() {
73            return Progress { gctx, state: None };
74        }
75        Progress::new_priv(name, style, gctx)
76    }
77
78    fn new_priv(name: &str, style: ProgressStyle, gctx: &'gctx GlobalContext) -> Progress<'gctx> {
79        let progress_config = gctx.progress_config();
80        let width = progress_config
81            .width
82            .or_else(|| gctx.shell().err_width().progress_max_width());
83
84        Progress {
85            gctx,
86            state: width.map(|n| State {
87                format: Format {
88                    style,
89                    max_width: n,
90                    // 50 gives some space for text after the progress bar,
91                    // even on narrow (e.g. 80 char) terminals.
92                    max_print: 50,
93                    term_integration: TerminalIntegration::from_config(gctx),
94                    unicode: gctx.shell().err_unicode(),
95                },
96                name: name.to_string(),
97                done: false,
98                throttle: Throttle::new(),
99                last_line: None,
100                fixed_width: progress_config.width,
101            }),
102        }
103    }
104
105    /// Disables the progress bar, ensuring it won't be displayed.
106    pub fn disable(&mut self) {
107        self.state = None;
108    }
109
110    /// Returns whether or not the progress bar is allowed to be displayed.
111    pub fn is_enabled(&self) -> bool {
112        self.state.is_some()
113    }
114
115    /// Updates the state of the progress bar.
116    ///
117    /// * `cur` should be how far along the progress is.
118    /// * `max` is the maximum value for the progress bar.
119    /// * `msg` is a small piece of text to display at the end of the progress
120    ///   bar. It will be truncated with `…` if it does not fit on the terminal.
121    ///
122    /// This may not actually update the display if `tick` is being called too
123    /// quickly.
124    pub fn tick(&mut self, cur: usize, max: usize, msg: &str) -> CargoResult<()> {
125        let Some(s) = &mut self.state else {
126            return Ok(());
127        };
128
129        let mut shell = self.gctx.shell();
130
131        // Don't update too often as it can cause excessive performance loss
132        // just putting stuff onto the terminal. We also want to avoid
133        // flickering by not drawing anything that goes away too quickly. As a
134        // result we've got two branches here:
135        //
136        // 1. If we haven't drawn anything, we wait for a period of time to
137        //    actually start drawing to the console. This ensures that
138        //    short-lived operations don't flicker on the console. Currently
139        //    there's a 500ms delay to when we first draw something.
140        // 2. If we've drawn something, then we rate limit ourselves to only
141        //    draw to the console every so often. Currently there's a 100ms
142        //    delay between updates.
143        if !s.throttle.allowed() {
144            return Ok(());
145        }
146
147        s.tick(cur, max, msg, &mut shell)
148    }
149
150    /// Updates the state of the progress bar.
151    ///
152    /// This is the same as [`Progress::tick`], but ignores rate throttling
153    /// and forces the display to be updated immediately.
154    ///
155    /// This may be useful for situations where you know you aren't calling
156    /// `tick` too fast, and accurate information is more important than
157    /// limiting the console update rate.
158    pub fn tick_now(&mut self, cur: usize, max: usize, msg: &str) -> CargoResult<()> {
159        let mut shell = self.gctx.shell();
160
161        match self.state {
162            Some(ref mut s) => s.tick(cur, max, msg, &mut shell),
163            None => Ok(()),
164        }
165    }
166
167    /// Returns whether or not updates are currently being throttled.
168    ///
169    /// This can be useful if computing the values for calling the
170    /// [`Progress::tick`] function may require some expensive work.
171    pub fn update_allowed(&mut self) -> bool {
172        match &mut self.state {
173            Some(s) => s.throttle.allowed(),
174            None => false,
175        }
176    }
177
178    /// Displays progress without a bar.
179    ///
180    /// The given `msg` is the text to display after the status message.
181    ///
182    /// Example: `Downloading 61 crates, remaining bytes: 28.0 MB`
183    ///
184    /// This does not have any rate limit throttling, so be careful about
185    /// calling it too often.
186    pub fn print_now(&mut self, msg: &str) -> CargoResult<()> {
187        let mut shell = self.gctx.shell();
188
189        match &mut self.state {
190            Some(s) => s.print(ProgressOutput::PrintNow, msg, &mut shell),
191            None => Ok(()),
192        }
193    }
194
195    /// Clears the progress bar from the console.
196    pub fn clear(&mut self) {
197        let mut shell = self.gctx.shell();
198
199        if let Some(ref mut s) = self.state {
200            s.clear(&mut shell);
201        }
202    }
203
204    /// Sets the progress reporter to the error state.
205    pub fn indicate_error(&mut self) {
206        if let Some(s) = &mut self.state {
207            s.format.term_integration.error()
208        }
209    }
210}
211
212impl<'gctx> Drop for Progress<'gctx> {
213    fn drop(&mut self) {
214        self.clear();
215    }
216}
217
218/// Indicates the style of information for displaying the amount of progress.
219///
220/// See also [`Progress::print_now`] for displaying progress without a bar.
221pub enum ProgressStyle {
222    /// Displays progress as a percentage.
223    ///
224    /// Example: `Fetch [=====================>   ]  88.15%`
225    ///
226    /// This is good for large values like number of bytes downloaded.
227    Percentage,
228    /// Displays progress as a ratio.
229    ///
230    /// Example: `Building [===>                      ] 35/222`
231    ///
232    /// This is good for smaller values where the exact number is useful to see.
233    Ratio,
234    /// Does not display an exact value of how far along it is.
235    ///
236    /// Example: `Fetch [===========>                     ]`
237    ///
238    /// This is good for situations where the exact value is an approximation,
239    /// and thus there isn't anything accurate to display to the user.
240    Indeterminate,
241}
242
243struct State {
244    format: Format,
245    name: String,
246    done: bool,
247    throttle: Throttle,
248    last_line: Option<String>,
249    fixed_width: Option<usize>,
250}
251
252impl State {
253    fn tick(&mut self, cur: usize, max: usize, msg: &str, shell: &mut Shell) -> CargoResult<()> {
254        if self.done {
255            write!(shell.err(), "{}", self.format.term_integration.remove())?;
256            return Ok(());
257        }
258
259        if max > 0 && cur == max {
260            self.done = true;
261        }
262
263        // Write out a pretty header, then the progress bar itself, and then
264        // return back to the beginning of the line for the next print.
265        self.try_update_max_width(shell);
266        if let Some(pbar) = self.format.progress(cur, max) {
267            self.print(pbar, msg, shell)?;
268        }
269        Ok(())
270    }
271
272    fn print(&mut self, progress: ProgressOutput, msg: &str, shell: &mut Shell) -> CargoResult<()> {
273        self.throttle.update();
274        self.try_update_max_width(shell);
275
276        let (mut line, report) = match progress {
277            ProgressOutput::PrintNow => (String::new(), None),
278            ProgressOutput::TextAndReport(prefix, report) => (prefix, Some(report)),
279            ProgressOutput::Report(report) => (String::new(), Some(report)),
280        };
281
282        // make sure we have enough room for the header
283        if self.format.max_width < 15 {
284            // even if we don't have space we can still output progress report
285            if let Some(tb) = report {
286                write!(shell.err(), "{tb}\r")?;
287            }
288            return Ok(());
289        }
290
291        self.format.render(&mut line, msg);
292        while line.len() < self.format.max_width - 15 {
293            line.push(' ');
294        }
295
296        // Only update if the line has changed.
297        if shell.is_cleared() || self.last_line.as_ref() != Some(&line) {
298            shell.set_needs_clear(false);
299            shell.transient_status(&self.name)?;
300            if let Some(tb) = report {
301                write!(shell.err(), "{line}{tb}\r")?;
302            } else {
303                write!(shell.err(), "{line}\r")?;
304            }
305            self.last_line = Some(line);
306            shell.set_needs_clear(true);
307        }
308
309        Ok(())
310    }
311
312    fn clear(&mut self, shell: &mut Shell) {
313        // Always clear the progress report
314        let _ = write!(shell.err(), "{}", self.format.term_integration.remove());
315        // No need to clear if the progress is not currently being displayed.
316        if self.last_line.is_some() && !shell.is_cleared() {
317            shell.err_erase_line();
318            self.last_line = None;
319        }
320    }
321
322    fn try_update_max_width(&mut self, shell: &mut Shell) {
323        if self.fixed_width.is_none() {
324            if let Some(n) = shell.err_width().progress_max_width() {
325                self.format.max_width = n;
326            }
327        }
328    }
329}
330
331struct Format {
332    style: ProgressStyle,
333    max_width: usize,
334    max_print: usize,
335    term_integration: TerminalIntegration,
336    unicode: bool,
337}
338
339impl Format {
340    fn progress(&self, cur: usize, max: usize) -> Option<ProgressOutput> {
341        assert!(cur <= max);
342        // Render the percentage at the far right and then figure how long the
343        // progress bar is
344        let pct = (cur as f64) / (max as f64);
345        let pct = if !pct.is_finite() { 0.0 } else { pct };
346        let stats = match self.style {
347            ProgressStyle::Percentage => format!(" {:6.02}%", pct * 100.0),
348            ProgressStyle::Ratio => format!(" {cur}/{max}"),
349            ProgressStyle::Indeterminate => String::new(),
350        };
351        let report = match self.style {
352            ProgressStyle::Percentage | ProgressStyle::Ratio => {
353                let pct = (pct * 100.0) as u8;
354                let pct = pct.clamp(0, 100);
355                self.term_integration.value(pct)
356            }
357            ProgressStyle::Indeterminate => self.term_integration.indeterminate(),
358        };
359
360        let extra_len = stats.len() + 2 /* [ and ] */ + 15 /* status header */;
361        let Some(display_width) = self.width().checked_sub(extra_len) else {
362            if self.term_integration.enabled {
363                return Some(ProgressOutput::Report(report));
364            }
365            return None;
366        };
367
368        let mut string = String::with_capacity(self.max_width);
369        string.push('[');
370        let hashes = display_width as f64 * pct;
371        let hashes = hashes as usize;
372
373        // Draw the `===>`
374        if hashes > 0 {
375            for _ in 0..hashes - 1 {
376                string.push('=');
377            }
378            if cur == max {
379                string.push('=');
380            } else {
381                string.push('>');
382            }
383        }
384
385        // Draw the empty space we have left to do
386        for _ in 0..(display_width - hashes) {
387            string.push(' ');
388        }
389        string.push(']');
390        string.push_str(&stats);
391
392        Some(ProgressOutput::TextAndReport(string, report))
393    }
394
395    fn render(&self, string: &mut String, msg: &str) {
396        let mut avail_msg_len = self.max_width - string.len() - 15;
397        let mut ellipsis_pos = 0;
398
399        let (ellipsis, ellipsis_width) = if self.unicode { ("…", 1) } else { ("...", 3) };
400
401        if avail_msg_len <= ellipsis_width {
402            return;
403        }
404        for c in msg.chars() {
405            let display_width = c.width().unwrap_or(0);
406            if avail_msg_len >= display_width {
407                avail_msg_len -= display_width;
408                string.push(c);
409                if avail_msg_len >= ellipsis_width {
410                    ellipsis_pos = string.len();
411                }
412            } else {
413                string.truncate(ellipsis_pos);
414                string.push_str(ellipsis);
415                break;
416            }
417        }
418    }
419
420    #[cfg(test)]
421    fn progress_status(&self, cur: usize, max: usize, msg: &str) -> Option<String> {
422        let mut ret = match self.progress(cur, max)? {
423            // Check only the variant that contains text.
424            ProgressOutput::TextAndReport(text, _) => text,
425            _ => return None,
426        };
427        self.render(&mut ret, msg);
428        Some(ret)
429    }
430
431    fn width(&self) -> usize {
432        cmp::min(self.max_width, self.max_print)
433    }
434}
435
436struct Throttle {
437    first: bool,
438    last_update: Instant,
439}
440
441impl Throttle {
442    fn new() -> Throttle {
443        Throttle {
444            first: true,
445            last_update: Instant::now(),
446        }
447    }
448
449    fn allowed(&mut self) -> bool {
450        if self.first {
451            let delay = Duration::from_millis(500);
452            if self.last_update.elapsed() < delay {
453                return false;
454            }
455        } else {
456            let interval = Duration::from_millis(100);
457            if self.last_update.elapsed() < interval {
458                return false;
459            }
460        }
461        self.update();
462        true
463    }
464
465    fn update(&mut self) {
466        self.first = false;
467        self.last_update = Instant::now();
468    }
469}
470
471/// Controls terminal progress integration via OSC sequences.
472struct TerminalIntegration {
473    enabled: bool,
474    error: bool,
475}
476
477impl TerminalIntegration {
478    #[cfg(test)]
479    fn new(enabled: bool) -> Self {
480        Self {
481            enabled,
482            error: false,
483        }
484    }
485
486    /// Creates a `TerminalIntegration` from Cargo's configuration.
487    /// Autodetect support if not explicitly enabled or disabled.
488    fn from_config(gctx: &GlobalContext) -> Self {
489        let enabled = gctx
490            .progress_config()
491            .term_integration
492            .unwrap_or_else(|| gctx.shell().is_err_term_integration_available());
493
494        Self {
495            enabled,
496            error: false,
497        }
498    }
499
500    fn progress_state(&self, value: StatusValue) -> StatusValue {
501        match (self.enabled, self.error) {
502            (true, false) => value,
503            (true, true) => match value {
504                StatusValue::Value(v) => StatusValue::Error(v),
505                _ => StatusValue::Error(100),
506            },
507            (false, _) => StatusValue::None,
508        }
509    }
510
511    pub fn remove(&self) -> StatusValue {
512        self.progress_state(StatusValue::Remove)
513    }
514
515    pub fn value(&self, percent: u8) -> StatusValue {
516        self.progress_state(StatusValue::Value(percent))
517    }
518
519    pub fn indeterminate(&self) -> StatusValue {
520        self.progress_state(StatusValue::Indeterminate)
521    }
522
523    pub fn error(&mut self) {
524        self.error = true;
525    }
526}
527
528enum ProgressOutput {
529    /// Print progress without a message
530    PrintNow,
531    /// Progress, message and progress report
532    TextAndReport(String, StatusValue),
533    /// Only progress report, no message and no text progress
534    Report(StatusValue),
535}
536
537/// A progress status value printable as an ANSI OSC 9;4 escape code.
538#[cfg_attr(test, derive(PartialEq, Debug))]
539enum StatusValue {
540    /// No output.
541    None,
542    /// Remove progress.
543    Remove,
544    /// Progress value (0-100).
545    Value(u8),
546    /// Indeterminate state (no bar, just animation)
547    Indeterminate,
548    /// Progress value in an error state (0-100).
549    Error(u8),
550}
551
552impl std::fmt::Display for StatusValue {
553    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554        let progress = match self {
555            Self::None => TermProgress::none(),
556            Self::Remove => TermProgress::remove(),
557            Self::Value(v) => TermProgress::start().percent(*v),
558            Self::Indeterminate => TermProgress::start(),
559            Self::Error(v) => TermProgress::error().percent(*v),
560        };
561
562        progress.fmt(f)
563    }
564}
565
566#[test]
567fn test_progress_status() {
568    let format = Format {
569        style: ProgressStyle::Ratio,
570        max_print: 40,
571        max_width: 60,
572        term_integration: TerminalIntegration::new(false),
573        unicode: true,
574    };
575    assert_eq!(
576        format.progress_status(0, 4, ""),
577        Some("[                   ] 0/4".to_string())
578    );
579    assert_eq!(
580        format.progress_status(1, 4, ""),
581        Some("[===>               ] 1/4".to_string())
582    );
583    assert_eq!(
584        format.progress_status(2, 4, ""),
585        Some("[========>          ] 2/4".to_string())
586    );
587    assert_eq!(
588        format.progress_status(3, 4, ""),
589        Some("[=============>     ] 3/4".to_string())
590    );
591    assert_eq!(
592        format.progress_status(4, 4, ""),
593        Some("[===================] 4/4".to_string())
594    );
595
596    assert_eq!(
597        format.progress_status(3999, 4000, ""),
598        Some("[===========> ] 3999/4000".to_string())
599    );
600    assert_eq!(
601        format.progress_status(4000, 4000, ""),
602        Some("[=============] 4000/4000".to_string())
603    );
604
605    assert_eq!(
606        format.progress_status(3, 4, ": short message"),
607        Some("[=============>     ] 3/4: short message".to_string())
608    );
609    assert_eq!(
610        format.progress_status(3, 4, ": msg thats just fit"),
611        Some("[=============>     ] 3/4: msg thats just fit".to_string())
612    );
613    assert_eq!(
614        format.progress_status(3, 4, ": msg that's just fit"),
615        Some("[=============>     ] 3/4: msg that's just f…".to_string())
616    );
617
618    // combining diacritics have width zero and thus can fit max_width.
619    let zalgo_msg = "z̸̧̢̗͉̝̦͍̱ͧͦͨ̑̅̌ͥ́͢a̢ͬͨ̽ͯ̅̑ͥ͋̏̑ͫ̄͢͏̫̝̪̤͎̱̣͍̭̞̙̱͙͍̘̭͚l̶̡̛̥̝̰̭̹̯̯̞̪͇̱̦͙͔̘̼͇͓̈ͨ͗ͧ̓͒ͦ̀̇ͣ̈ͭ͊͛̃̑͒̿̕͜g̸̷̢̩̻̻͚̠͓̞̥͐ͩ͌̑ͥ̊̽͋͐̐͌͛̐̇̑ͨ́ͅo͙̳̣͔̰̠̜͕͕̞̦̙̭̜̯̹̬̻̓͑ͦ͋̈̉͌̃ͯ̀̂͠ͅ ̸̡͎̦̲̖̤̺̜̮̱̰̥͔̯̅̏ͬ̂ͨ̋̃̽̈́̾̔̇ͣ̚͜͜h̡ͫ̐̅̿̍̀͜҉̛͇̭̹̰̠͙̞ẽ̶̙̹̳̖͉͎̦͂̋̓ͮ̔ͬ̐̀͂̌͑̒͆̚͜͠ ͓͓̟͍̮̬̝̝̰͓͎̼̻ͦ͐̾̔͒̃̓͟͟c̮̦͍̺͈͚̯͕̄̒͐̂͊̊͗͊ͤͣ̀͘̕͝͞o̶͍͚͍̣̮͌ͦ̽̑ͩ̅ͮ̐̽̏͗́͂̅ͪ͠m̷̧͖̻͔̥̪̭͉͉̤̻͖̩̤͖̘ͦ̂͌̆̂ͦ̒͊ͯͬ͊̉̌ͬ͝͡e̵̹̣͍̜̺̤̤̯̫̹̠̮͎͙̯͚̰̼͗͐̀̒͂̉̀̚͝͞s̵̲͍͙͖̪͓͓̺̱̭̩̣͖̣ͤͤ͂̎̈͗͆ͨͪ̆̈͗͝͠";
620    assert_eq!(
621        format.progress_status(3, 4, zalgo_msg),
622        Some("[=============>     ] 3/4".to_string() + zalgo_msg)
623    );
624
625    // some non-ASCII ellipsize test
626    assert_eq!(
627        format.progress_status(3, 4, "_123456789123456e\u{301}\u{301}8\u{301}90a"),
628        Some("[=============>     ] 3/4_123456789123456e\u{301}\u{301}8\u{301}9…".to_string())
629    );
630    assert_eq!(
631        format.progress_status(3, 4, ":每個漢字佔據了兩個字元"),
632        Some("[=============>     ] 3/4:每個漢字佔據了兩…".to_string())
633    );
634    assert_eq!(
635        // handle breaking at middle of character
636        format.progress_status(3, 4, ":-每個漢字佔據了兩個字元"),
637        Some("[=============>     ] 3/4:-每個漢字佔據了兩…".to_string())
638    );
639}
640
641#[test]
642fn test_progress_status_percentage() {
643    let format = Format {
644        style: ProgressStyle::Percentage,
645        max_print: 40,
646        max_width: 60,
647        term_integration: TerminalIntegration::new(false),
648        unicode: true,
649    };
650    assert_eq!(
651        format.progress_status(0, 77, ""),
652        Some("[               ]   0.00%".to_string())
653    );
654    assert_eq!(
655        format.progress_status(1, 77, ""),
656        Some("[               ]   1.30%".to_string())
657    );
658    assert_eq!(
659        format.progress_status(76, 77, ""),
660        Some("[=============> ]  98.70%".to_string())
661    );
662    assert_eq!(
663        format.progress_status(77, 77, ""),
664        Some("[===============] 100.00%".to_string())
665    );
666}
667
668#[test]
669fn test_progress_status_too_short() {
670    let format = Format {
671        style: ProgressStyle::Percentage,
672        max_print: 25,
673        max_width: 25,
674        term_integration: TerminalIntegration::new(false),
675        unicode: true,
676    };
677    assert_eq!(
678        format.progress_status(1, 1, ""),
679        Some("[] 100.00%".to_string())
680    );
681
682    let format = Format {
683        style: ProgressStyle::Percentage,
684        max_print: 24,
685        max_width: 24,
686        term_integration: TerminalIntegration::new(false),
687        unicode: true,
688    };
689    assert_eq!(format.progress_status(1, 1, ""), None);
690}
691
692#[test]
693fn test_term_integration_disabled() {
694    let report = TerminalIntegration::new(false);
695    let mut out = String::new();
696    out.push_str(&report.remove().to_string());
697    out.push_str(&report.value(10).to_string());
698    out.push_str(&report.indeterminate().to_string());
699    assert!(out.is_empty());
700}
701
702#[test]
703fn test_term_integration_error_state() {
704    let mut report = TerminalIntegration::new(true);
705    assert_eq!(report.value(10), StatusValue::Value(10));
706    report.error();
707    assert_eq!(report.value(50), StatusValue::Error(50));
708}