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}