Skip to main content

pma_core/
progress.rs

1//! A one-line progress bar on stderr, for `pma scan`.
2//!
3//! Silent when stderr is not a terminal, so a pipe or a CI log holds the same
4//! bytes it held before.
5
6use std::io::{IsTerminal, Write};
7use std::sync::Mutex;
8
9/// Bar width in characters, inside `[]`.
10const WIDTH: usize = 20;
11
12/// The whole line, matching the 72-column limit the help is held to.
13const LINE: usize = 72;
14
15pub struct Bar {
16    total: usize,
17    /// The count of finished items, and the lock that serialises the writes;
18    /// `None` when there is no terminal to draw on.
19    done: Option<Mutex<usize>>,
20}
21
22impl Bar {
23    pub fn new(total: usize) -> Bar {
24        let bar = Bar {
25            total,
26            done: std::io::stderr().is_terminal().then(|| Mutex::new(0)),
27        };
28        if bar.done.is_some() {
29            draw(&line(0, total, ""));
30        }
31        bar
32    }
33
34    /// Records one finished item and redraws. Called from the scan threads.
35    pub fn done(&self, label: &str) {
36        let Some(done) = &self.done else { return };
37        let mut done = done.lock().unwrap();
38        *done += 1;
39        draw(&line(*done, self.total, label));
40    }
41
42    /// Clears the line, so what the command prints starts at column 0.
43    pub fn finish(&self) {
44        if self.done.is_some() {
45            draw("");
46        }
47    }
48}
49
50/// Returns to column 0, writes, and erases whatever the last line left.
51fn draw(text: &str) {
52    let mut err = std::io::stderr().lock();
53    let _ = write!(err, "\r{text}\x1b[K");
54    let _ = err.flush();
55}
56
57/// `[####----------------] 12/95 alpha`.
58fn line(done: usize, total: usize, label: &str) -> String {
59    let filled = (done * WIDTH).checked_div(total).unwrap_or(WIDTH);
60    let counter = format!("{done}/{total}");
61    let bar = format!(
62        "[{}{}] {counter}",
63        "#".repeat(filled),
64        "-".repeat(WIDTH - filled)
65    );
66    if label.is_empty() {
67        return bar;
68    }
69    format!(
70        "{bar} {}",
71        crate::report::truncate(label, LINE.saturating_sub(bar.len() + 1))
72    )
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn the_bar_fills_with_the_count() {
81        assert_eq!(line(0, 4, ""), "[--------------------] 0/4");
82        assert_eq!(line(1, 4, "a"), "[#####---------------] 1/4 a");
83        assert_eq!(line(4, 4, "d"), "[####################] 4/4 d");
84    }
85
86    /// Redrawing at column 0 means a line wider than the terminal wraps and
87    /// leaves the earlier line behind, so the label is cut instead.
88    #[test]
89    fn a_long_label_is_cut_to_the_line() {
90        let long = "x".repeat(200);
91        assert_eq!(line(7, 95, &long).len(), LINE);
92    }
93
94    #[test]
95    fn nothing_to_scan_is_a_full_bar() {
96        assert_eq!(line(0, 0, ""), "[####################] 0/0");
97    }
98}