Skip to main content

scope_cli/cli/
progress.rs

1//! # Progress Indicators
2//!
3//! Shared progress display utilities for long-running CLI operations.
4//! Uses `indicatif` spinners and progress bars, respecting `--no-color`
5//! and non-TTY contexts (e.g. pipes).
6//!
7//! ## Usage
8//!
9//! ```rust,ignore
10//! use scope_cli::cli::{output::Output, progress::Spinner};
11//!
12//! let out = Output::stdio();
13//! let sp = Spinner::new("Fetching address data...", &out);
14//! // ... do work ...
15//! sp.finish("Address data loaded.");
16//! ```
17//!
18//! ## Prompts
19//!
20//! A bar that draws on the terminal is registered here. `paused` stops
21//! every registered bar while it runs a closure, so an interactive prompt
22//! (`crate::cli::prompt::ask`) is never drawn over. Lines a command
23//! prints next to a bar ([`Spinner::println`]) go through its [`Output`],
24//! so the TUI keeps a copy; the bar's own finish line is progress and is
25//! not copied.
26
27use crate::cli::output::Output;
28use crate::note;
29use indicatif::{ProgressBar, ProgressStyle, WeakProgressBar};
30use scope::display::theme::color_enabled_stderr;
31use std::sync::Mutex;
32use std::time::Duration;
33
34/// The bars that draw on the terminal now. Weak: a dropped bar leaves.
35static DRAWING: Mutex<Vec<WeakProgressBar>> = Mutex::new(Vec::new());
36
37/// Adds a drawing bar to [`DRAWING`] and drops the ones that ended.
38fn register(bar: &ProgressBar) {
39    let mut bars = DRAWING.lock().unwrap_or_else(|e| e.into_inner());
40    bars.retain(|weak| weak.upgrade().is_some_and(|b| !b.is_finished()));
41    bars.push(bar.downgrade());
42}
43
44/// Runs `f` with every drawing bar stopped and its line cleared. The bars
45/// draw again when `f` returns. No bar draws while `f` runs: the bar's
46/// tick waits for its lock.
47pub(crate) fn paused<R>(f: impl FnOnce() -> R) -> R {
48    // Copy the bars out: the registry lock is not held while `f` runs.
49    let bars: Vec<ProgressBar> = DRAWING
50        .lock()
51        .unwrap_or_else(|e| e.into_inner())
52        .iter()
53        .filter_map(WeakProgressBar::upgrade)
54        .filter(|b| !b.is_finished())
55        .collect();
56    paused_all(&bars, f)
57}
58
59fn paused_all<R>(bars: &[ProgressBar], f: impl FnOnce() -> R) -> R {
60    match bars.split_first() {
61        None => f(),
62        Some((bar, rest)) => bar.suspend(|| paused_all(rest, f)),
63    }
64}
65
66/// A simple spinner for single-step or short sequential operations.
67///
68/// Automatically disables itself when stderr is not a TTY (e.g. piped output)
69/// or when the output does not allow progress (e.g. inside the TUI). A hidden
70/// spinner writes its messages as plain lines to the output's error channel.
71pub struct Spinner {
72    bar: ProgressBar,
73    output: Output,
74}
75
76impl Spinner {
77    /// Creates and starts a spinner with the given message. A hidden
78    /// spinner writes the message as a note.
79    pub fn new(message: &str, output: &Output) -> Self {
80        let bar = if output.progress_enabled() && atty_stderr() {
81            let pb = ProgressBar::new_spinner();
82            pb.set_style(
83                ProgressStyle::with_template(if color_enabled_stderr() {
84                    "{spinner:.cyan} {msg}"
85                } else {
86                    "{spinner} {msg}"
87                })
88                .unwrap()
89                .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
90            );
91            pb.set_message(message.to_string());
92            pb.enable_steady_tick(Duration::from_millis(80));
93            register(&pb);
94            pb
95        } else {
96            // No animation: write a simple status line instead
97            note!(output, "{}", message);
98            ProgressBar::hidden()
99        };
100        Self {
101            bar,
102            output: output.clone(),
103        }
104    }
105
106    /// Updates the spinner message in-place.
107    pub fn set_message(&self, message: impl Into<String>) {
108        self.bar.set_message(message.into());
109    }
110
111    /// Finishes the spinner with a success message (checkmark).
112    pub fn finish(&self, message: &str) {
113        if !self.bar.is_hidden() {
114            self.bar.finish_with_message(format!("✓ {}", message));
115        }
116    }
117
118    /// Finishes the spinner with a warning message.
119    pub fn finish_warn(&self, message: &str) {
120        if !self.bar.is_hidden() {
121            self.bar.finish_with_message(format!("⚠ {}", message));
122        }
123    }
124
125    /// Finishes and clears the spinner line (no residual output).
126    pub fn finish_and_clear(&self) {
127        self.bar.finish_and_clear();
128    }
129
130    /// Writes a note above the spinner. The spinner's line is cleared
131    /// while the note is written and drawn again below it. The note goes
132    /// through the [`Output`], so a TUI run keeps a copy.
133    pub fn println(&self, message: &str) {
134        self.bar.suspend(|| note!(self.output, "{}", message));
135    }
136}
137
138/// A status that passes: the spinner's message when a spinner draws,
139/// otherwise a note.
140pub(crate) fn status(spinner: Option<&Spinner>, out: &Output, message: &str) {
141    match spinner {
142        Some(sp) => sp.set_message(message.to_string()),
143        None => note!(out, "{}", message),
144    }
145}
146
147/// A status line that stays: above the spinner when one draws, otherwise
148/// a note.
149pub(crate) fn notice(spinner: Option<&Spinner>, out: &Output, message: &str) {
150    match spinner {
151        Some(sp) => sp.println(message),
152        None => note!(out, "{}", message),
153    }
154}
155
156/// A counted progress bar for multi-step operations (X of Y).
157pub struct StepProgress {
158    bar: ProgressBar,
159}
160
161impl StepProgress {
162    /// Creates a progress bar for `total` steps with the given prefix. A
163    /// hidden bar writes the prefix as a note.
164    pub fn new(total: u64, prefix: &str, output: &Output) -> Self {
165        let bar = if output.progress_enabled() && atty_stderr() {
166            let pb = ProgressBar::new(total);
167            pb.set_style(
168                ProgressStyle::with_template(if color_enabled_stderr() {
169                    "{prefix} [{bar:30.cyan/dim}] {pos}/{len} {msg}"
170                } else {
171                    "{prefix} [{bar:30}] {pos}/{len} {msg}"
172                })
173                .unwrap()
174                .progress_chars("━━╸"),
175            );
176            pb.set_prefix(prefix.to_string());
177            register(&pb);
178            pb
179        } else {
180            note!(output, "{} (0/{})", prefix, total);
181            ProgressBar::hidden()
182        };
183        Self { bar }
184    }
185
186    /// Increments progress by one and updates the message.
187    pub fn inc(&self, message: &str) {
188        self.bar.set_message(message.to_string());
189        self.bar.inc(1);
190    }
191
192    /// Finishes the progress bar with a success message.
193    pub fn finish(&self, message: &str) {
194        if !self.bar.is_hidden() {
195            self.bar.finish_with_message(format!("✓ {}", message));
196        }
197    }
198}
199
200/// Decides whether a progress bar may draw. Pure: the caller passes the
201/// environment and whether stderr is a terminal.
202///
203/// A bar needs a terminal that can redraw a line, so `TERM=dumb` turns it
204/// off. `NO_COLOR` does not: it governs color only.
205fn progress_bar_ok(env: &dyn Fn(&str) -> Option<String>, stderr_is_tty: bool) -> bool {
206    stderr_is_tty && env("TERM").as_deref() != Some("dumb")
207}
208
209/// Checks if stderr is a terminal that can show a progress bar.
210fn atty_stderr() -> bool {
211    use std::io::IsTerminal;
212    progress_bar_ok(&|k| std::env::var(k).ok(), std::io::stderr().is_terminal())
213}
214
215// ============================================================================
216// Unit Tests
217// ============================================================================
218
219/// Seams for tests that need a bar that draws, here and in the prompt
220/// tests.
221#[cfg(test)]
222pub(crate) mod testing {
223    use super::*;
224
225    /// A terminal that records what a bar draws.
226    #[derive(Clone, Default, Debug)]
227    pub(crate) struct Screen(std::sync::Arc<Mutex<Vec<String>>>);
228
229    impl Screen {
230        pub(crate) fn ops(&self) -> Vec<String> {
231            self.0.lock().unwrap().clone()
232        }
233        fn push(&self, op: String) -> std::io::Result<()> {
234            self.0.lock().unwrap().push(op);
235            Ok(())
236        }
237    }
238
239    impl indicatif::TermLike for Screen {
240        fn width(&self) -> u16 {
241            80
242        }
243        fn move_cursor_up(&self, n: usize) -> std::io::Result<()> {
244            self.push(format!("up {n}"))
245        }
246        fn move_cursor_down(&self, n: usize) -> std::io::Result<()> {
247            self.push(format!("down {n}"))
248        }
249        fn move_cursor_right(&self, n: usize) -> std::io::Result<()> {
250            self.push(format!("right {n}"))
251        }
252        fn move_cursor_left(&self, n: usize) -> std::io::Result<()> {
253            self.push(format!("left {n}"))
254        }
255        fn write_line(&self, s: &str) -> std::io::Result<()> {
256            self.push(format!("line {s}"))
257        }
258        fn write_str(&self, s: &str) -> std::io::Result<()> {
259            self.push(format!("str {s}"))
260        }
261        fn clear_line(&self) -> std::io::Result<()> {
262            self.push("clear".into())
263        }
264        fn flush(&self) -> std::io::Result<()> {
265            Ok(())
266        }
267    }
268
269    /// A spinner that draws on `screen`, as one on a real terminal does.
270    pub(crate) fn drawing_spinner(message: &str, output: &Output, screen: &Screen) -> Spinner {
271        let bar = ProgressBar::with_draw_target(
272            None,
273            indicatif::ProgressDrawTarget::term_like(Box::new(screen.clone())),
274        );
275        bar.set_style(ProgressStyle::with_template("{spinner} {msg}").unwrap());
276        bar.set_message(message.to_string());
277        bar.enable_steady_tick(Duration::from_millis(5));
278        register(&bar);
279        Spinner {
280            bar,
281            output: output.clone(),
282        }
283    }
284
285    /// Serializes the tests that use the process-wide bar registry.
286    pub(crate) static REGISTRY_LOCK: Mutex<()> = Mutex::new(());
287}
288
289#[cfg(test)]
290mod tests {
291    use super::testing::*;
292    use super::*;
293
294    #[test]
295    fn test_progress_bar_rules() {
296        let env = |m: &'static [(&'static str, &'static str)]| {
297            move |k: &str| m.iter().find(|(n, _)| *n == k).map(|(_, v)| v.to_string())
298        };
299        assert!(progress_bar_ok(&env(&[]), true));
300        // NO_COLOR governs color, not the bar.
301        assert!(progress_bar_ok(&env(&[("NO_COLOR", "1")]), true));
302        assert!(!progress_bar_ok(&env(&[("TERM", "dumb")]), true));
303        assert!(!progress_bar_ok(&env(&[]), false));
304    }
305
306    fn quiet() -> Output {
307        Output::capture().0
308    }
309
310    #[test]
311    fn test_hidden_spinner_writes_status_to_err_channel() {
312        // Inside the TUI the spinner must not draw; its message must still
313        // reach the user through the error channel, never through data.
314        let (o, cap) = Output::capture();
315        let sp = Spinner::new("Fetching...", &o);
316        sp.println("note");
317        sp.finish("done");
318        assert_eq!(cap.err(), "Fetching...\nnote\n");
319        assert_eq!(cap.out(), "");
320    }
321
322    #[test]
323    fn test_spinner_create_and_finish() {
324        // In test context, stderr is not a TTY, so spinner is hidden
325        let sp = Spinner::new("Testing...", &quiet());
326        sp.set_message("Updated");
327        sp.finish("Done");
328    }
329
330    #[test]
331    fn test_spinner_finish_and_clear() {
332        let sp = Spinner::new("Testing...", &quiet());
333        sp.finish_and_clear();
334    }
335
336    #[test]
337    fn test_spinner_finish_warn() {
338        let sp = Spinner::new("Testing...", &quiet());
339        sp.finish_warn("Warning");
340    }
341
342    #[test]
343    fn test_step_progress_create_and_finish() {
344        let prog = StepProgress::new(3, "Processing", &quiet());
345        prog.inc("Step 1");
346        prog.inc("Step 2");
347        prog.inc("Step 3");
348        prog.finish("Done");
349    }
350
351    #[test]
352    fn test_spinner_multiple_set_message() {
353        let sp = Spinner::new("Initial", &quiet());
354        sp.set_message("First update");
355        sp.set_message("Second update");
356        sp.set_message("Third update");
357        sp.finish("Complete");
358    }
359
360    #[test]
361    fn test_step_progress_multiple_inc() {
362        let prog = StepProgress::new(5, "Processing", &quiet());
363        prog.inc("Step 1");
364        prog.inc("Step 2");
365        prog.inc("Step 3");
366        prog.inc("Step 4");
367        prog.inc("Step 5");
368        prog.finish("Done");
369    }
370
371    #[test]
372    fn test_step_progress_single_step() {
373        let prog = StepProgress::new(1, "Single", &quiet());
374        prog.inc("Only step");
375        prog.finish("Complete");
376    }
377
378    #[test]
379    fn test_step_progress_large_total() {
380        let prog = StepProgress::new(100, "Large", &quiet());
381        for i in 1..=100 {
382            prog.inc(&format!("Step {}", i));
383        }
384        prog.finish("Complete");
385    }
386
387    #[test]
388    fn test_step_progress_zero_total() {
389        let prog = StepProgress::new(0, "Empty", &quiet());
390        prog.finish("Complete");
391    }
392
393    #[test]
394    fn test_spinner_finish_warn_with_message() {
395        let sp = Spinner::new("Warning test", &quiet());
396        sp.finish_warn("Something went wrong");
397    }
398
399    #[test]
400    fn test_spinner_finish_warn_multiple_calls() {
401        let sp = Spinner::new("Test", &quiet());
402        sp.finish_warn("First warning");
403        // finish_warn can be called multiple times (though unusual)
404        sp.finish_warn("Second warning");
405    }
406
407    #[test]
408    fn test_spinner_println() {
409        let sp = Spinner::new("Working...", &quiet());
410        sp.println("A message above the spinner");
411        sp.finish("Done");
412    }
413
414    /// How long a test waits for a ticking bar to draw. Generous: a loaded
415    /// machine can hold the tick thread back for a long time.
416    const DRAW_DEADLINE: Duration = Duration::from_secs(2);
417
418    /// Waits until one of `screen`'s ops from index `from` on contains
419    /// `text`, or [`DRAW_DEADLINE`] passes. A draw is several ops and the
420    /// tick thread can be part-way through one, so look at every op since
421    /// `from`, never only the last few.
422    fn drew_since(screen: &Screen, from: usize, text: &str) -> bool {
423        let deadline = std::time::Instant::now() + DRAW_DEADLINE;
424        loop {
425            if screen.ops()[from..].iter().any(|op| op.contains(text)) {
426                return true;
427            }
428            if std::time::Instant::now() >= deadline {
429                return false;
430            }
431            std::thread::sleep(Duration::from_millis(1));
432        }
433    }
434
435    #[test]
436    fn test_paused_stops_every_drawing_bar_while_it_runs() {
437        // A prompt shown while a spinner ticks is drawn over: the spinner
438        // must clear its line and stay away until the prompt is answered.
439        let _lock = REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
440        let screen = Screen::default();
441        let sp = drawing_spinner("Searching...", &quiet(), &screen);
442        assert!(
443            drew_since(&screen, 0, "Searching..."),
444            "it draws before the pause: {:?}",
445            screen.ops()
446        );
447        let (drawn_during, drawn_at_end) = paused(|| {
448            let at_start = screen.ops();
449            let last = |op: &str| at_start.iter().rposition(|o| o.starts_with(op));
450            assert!(last("clear") > last("str"), "the line is cleared");
451            std::thread::sleep(Duration::from_millis(40));
452            let at_end = screen.ops().len();
453            (at_end - at_start.len(), at_end)
454        });
455        assert_eq!(drawn_during, 0, "the spinner drew while paused");
456        assert!(
457            drew_since(&screen, drawn_at_end, "Searching..."),
458            "it draws again after: {:?}",
459            screen.ops()
460        );
461        sp.finish_and_clear();
462    }
463
464    #[test]
465    fn test_paused_skips_ended_bars_and_runs_without_any() {
466        let _lock = REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
467        let screen = Screen::default();
468        let sp = drawing_spinner("x", &quiet(), &screen);
469        sp.finish("done");
470        let before = screen.ops().len();
471        assert_eq!(paused(|| 7), 7);
472        assert_eq!(screen.ops().len(), before, "an ended bar keeps its line");
473        drop(sp);
474        assert_eq!(paused(|| 8), 8);
475    }
476
477    #[test]
478    fn test_println_beside_a_drawing_spinner_goes_through_the_output() {
479        // In a TUI run outside the TUI, the copy for the scrollback comes
480        // from the Output. A line the bar printed itself never reached it.
481        let _lock = REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
482        let screen = Screen::default();
483        let (o, cap) = Output::capture();
484        let sp = drawing_spinner("Searching...", &o, &screen);
485        sp.println("Not found on DexScreener; found X on Y (CEX)");
486        assert_eq!(cap.err(), "Not found on DexScreener; found X on Y (CEX)\n");
487        assert!(
488            !screen.ops().iter().any(|op| op.contains("DexScreener")),
489            "not drawn by the bar"
490        );
491        sp.finish_and_clear();
492    }
493    #[test]
494    fn test_a_drawing_spinner_ends_on_its_warning() {
495        let _lock = REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
496        let screen = Screen::default();
497        let sp = drawing_spinner("Checking...", &quiet(), &screen);
498        let from = screen.ops().len();
499        sp.finish_warn("partial data");
500        assert!(
501            drew_since(&screen, from, "⚠ partial data"),
502            "{:?}",
503            screen.ops()
504        );
505        assert_eq!(paused(|| 1), 1, "an ended bar is not paused");
506    }
507
508    #[test]
509    fn test_a_drawing_step_progress_counts_and_ends_on_its_message() {
510        let screen = Screen::default();
511        let bar = ProgressBar::with_draw_target(
512            Some(3),
513            indicatif::ProgressDrawTarget::term_like(Box::new(screen.clone())),
514        );
515        bar.set_style(ProgressStyle::with_template("{pos}/{len} {msg}").unwrap());
516        let steps = StepProgress { bar };
517        steps.inc("first");
518        steps.inc("second");
519        assert_eq!(steps.bar.position(), 2);
520        assert_eq!(steps.bar.message(), "second");
521        // Finishing fills the bar.
522        steps.finish("all done");
523        assert!(steps.bar.is_finished());
524        assert!(
525            drew_since(&screen, 0, "3/3 ✓ all done"),
526            "{:?}",
527            screen.ops()
528        );
529    }
530}