Skip to main content

ytcli/render/
progress.rs

1//! Saying that a long walk is still walking.
2//!
3//! `--all` fetches pages until there are none left, and twenty pages of silence
4//! is indistinguishable from a hang. Three rules keep the cure from being worse
5//! than the disease:
6//!
7//! 1. **Always stderr.** stdout is a data channel; a progress line in the middle
8//!    of a result set would corrupt whatever is parsing it.
9//! 2. **Only when stderr is a terminal**, not when stdout is. `find --all > out`
10//!    run by a person has a piped stdout and a watching human — that is exactly
11//!    the case progress is for. A command whose stderr is captured has no
12//!    watcher, so it gets nothing.
13//! 3. **Nothing is left behind.** The bar clears itself; the tally on stdout is
14//!    the record of what happened.
15
16use std::io::IsTerminal;
17use std::time::Duration;
18
19use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
20
21/// A progress indicator, or nothing at all.
22#[derive(Debug)]
23pub struct Walk(Option<ProgressBar>);
24
25impl Walk {
26    /// Start reporting, if there is anyone to report to.
27    #[must_use]
28    pub fn start(what: &str) -> Self {
29        if !std::io::stderr().is_terminal() {
30            return Self(None);
31        }
32
33        let bar = ProgressBar::new_spinner();
34        bar.set_draw_target(ProgressDrawTarget::stderr());
35        if let Ok(style) = ProgressStyle::with_template("{spinner} {msg}") {
36            bar.set_style(style);
37        }
38        bar.set_message(what.to_owned());
39        // Without a steady tick the spinner only moves when a page arrives,
40        // which is precisely when the caller is not wondering whether it hung.
41        bar.enable_steady_tick(Duration::from_millis(120));
42        Self(Some(bar))
43    }
44
45    /// Report what has been collected so far.
46    pub fn page(&self, page: u32, collected: usize, total: Option<u64>) {
47        let Some(bar) = &self.0 else { return };
48        let of = total.map_or_else(|| "unknown total".to_owned(), |total| total.to_string());
49        bar.set_message(format!("page {page}: {collected} of {of}"));
50    }
51
52    /// Take the indicator down, leaving the terminal as it was found.
53    pub fn finish(self) {
54        if let Some(bar) = self.0 {
55            bar.finish_and_clear();
56        }
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    /// The test environment has no terminal, which is the case that matters:
65    /// nothing is drawn, and every call is still safe to make.
66    #[test]
67    fn a_captured_stderr_gets_no_progress() {
68        let walk = Walk::start("searching");
69        assert!(walk.0.is_none());
70        walk.page(2, 50, Some(340));
71        walk.finish();
72    }
73}