cargo/util/
progress.rs

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