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