Skip to main content

scope/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::progress::Spinner;
11//!
12//! let sp = Spinner::new("Fetching address data...");
13//! // ... do work ...
14//! sp.finish("Address data loaded.");
15//! ```
16
17use indicatif::{ProgressBar, ProgressStyle};
18use std::time::Duration;
19
20/// A simple spinner for single-step or short sequential operations.
21///
22/// Automatically disables itself when stdout is not a TTY (e.g. piped output).
23pub struct Spinner {
24    bar: ProgressBar,
25}
26
27impl Spinner {
28    /// Creates and starts a spinner with the given message.
29    pub fn new(message: &str) -> Self {
30        let bar = if atty_stderr() {
31            let pb = ProgressBar::new_spinner();
32            pb.set_style(
33                ProgressStyle::with_template("{spinner:.cyan} {msg}")
34                    .unwrap()
35                    .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
36            );
37            pb.set_message(message.to_string());
38            pb.enable_steady_tick(Duration::from_millis(80));
39            pb
40        } else {
41            // Non-TTY: print a simple status line to stderr instead
42            eprintln!("{}", message);
43            ProgressBar::hidden()
44        };
45        Self { bar }
46    }
47
48    /// Updates the spinner message in-place.
49    pub fn set_message(&self, message: impl Into<String>) {
50        self.bar.set_message(message.into());
51    }
52
53    /// Finishes the spinner with a success message (checkmark).
54    pub fn finish(&self, message: &str) {
55        if !self.bar.is_hidden() {
56            self.bar.finish_with_message(format!("✓ {}", message));
57        }
58    }
59
60    /// Finishes the spinner with a warning message.
61    pub fn finish_warn(&self, message: &str) {
62        if !self.bar.is_hidden() {
63            self.bar.finish_with_message(format!("⚠ {}", message));
64        }
65    }
66
67    /// Finishes and clears the spinner line (no residual output).
68    pub fn finish_and_clear(&self) {
69        self.bar.finish_and_clear();
70    }
71
72    /// Prints a line above the spinner without garbling the animation.
73    ///
74    /// Uses `indicatif`'s built-in `println` which clears the spinner line,
75    /// writes the message, then redraws the spinner below it.
76    pub fn println(&self, message: &str) {
77        if self.bar.is_hidden() {
78            eprintln!("{}", message);
79        } else {
80            self.bar.println(message);
81        }
82    }
83
84    /// Temporarily suspends the spinner while running a closure, returning its result.
85    ///
86    /// The spinner is paused (line cleared) before `f` runs and resumed after.
87    /// Useful when other code needs to print to the terminal.
88    pub fn suspend<R, F: FnOnce() -> R>(&self, f: F) -> R {
89        if self.bar.is_hidden() {
90            f()
91        } else {
92            self.bar.suspend(f)
93        }
94    }
95}
96
97/// A counted progress bar for multi-step operations (X of Y).
98pub struct StepProgress {
99    bar: ProgressBar,
100}
101
102impl StepProgress {
103    /// Creates a progress bar for `total` steps with the given prefix.
104    pub fn new(total: u64, prefix: &str) -> Self {
105        let bar = if atty_stderr() {
106            let pb = ProgressBar::new(total);
107            pb.set_style(
108                ProgressStyle::with_template("{prefix} [{bar:30.cyan/dim}] {pos}/{len} {msg}")
109                    .unwrap()
110                    .progress_chars("━━╸"),
111            );
112            pb.set_prefix(prefix.to_string());
113            pb
114        } else {
115            eprintln!("{} (0/{})", prefix, total);
116            ProgressBar::hidden()
117        };
118        Self { bar }
119    }
120
121    /// Increments progress by one and updates the message.
122    pub fn inc(&self, message: &str) {
123        self.bar.set_message(message.to_string());
124        self.bar.inc(1);
125    }
126
127    /// Finishes the progress bar with a success message.
128    pub fn finish(&self, message: &str) {
129        if !self.bar.is_hidden() {
130            self.bar.finish_with_message(format!("✓ {}", message));
131        }
132    }
133}
134
135/// Checks if stderr is a TTY (interactive terminal).
136fn atty_stderr() -> bool {
137    use std::io::IsTerminal;
138    std::io::stderr().is_terminal()
139}
140
141// ============================================================================
142// Unit Tests
143// ============================================================================
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_spinner_create_and_finish() {
151        // In test context, stderr is not a TTY, so spinner is hidden
152        let sp = Spinner::new("Testing...");
153        sp.set_message("Updated");
154        sp.finish("Done");
155    }
156
157    #[test]
158    fn test_spinner_finish_and_clear() {
159        let sp = Spinner::new("Testing...");
160        sp.finish_and_clear();
161    }
162
163    #[test]
164    fn test_spinner_finish_warn() {
165        let sp = Spinner::new("Testing...");
166        sp.finish_warn("Warning");
167    }
168
169    #[test]
170    fn test_step_progress_create_and_finish() {
171        let prog = StepProgress::new(3, "Processing");
172        prog.inc("Step 1");
173        prog.inc("Step 2");
174        prog.inc("Step 3");
175        prog.finish("Done");
176    }
177
178    #[test]
179    fn test_spinner_multiple_set_message() {
180        let sp = Spinner::new("Initial");
181        sp.set_message("First update");
182        sp.set_message("Second update");
183        sp.set_message("Third update");
184        sp.finish("Complete");
185    }
186
187    #[test]
188    fn test_step_progress_multiple_inc() {
189        let prog = StepProgress::new(5, "Processing");
190        prog.inc("Step 1");
191        prog.inc("Step 2");
192        prog.inc("Step 3");
193        prog.inc("Step 4");
194        prog.inc("Step 5");
195        prog.finish("Done");
196    }
197
198    #[test]
199    fn test_step_progress_single_step() {
200        let prog = StepProgress::new(1, "Single");
201        prog.inc("Only step");
202        prog.finish("Complete");
203    }
204
205    #[test]
206    fn test_step_progress_large_total() {
207        let prog = StepProgress::new(100, "Large");
208        for i in 1..=100 {
209            prog.inc(&format!("Step {}", i));
210        }
211        prog.finish("Complete");
212    }
213
214    #[test]
215    fn test_step_progress_zero_total() {
216        let prog = StepProgress::new(0, "Empty");
217        prog.finish("Complete");
218    }
219
220    #[test]
221    fn test_spinner_finish_warn_with_message() {
222        let sp = Spinner::new("Warning test");
223        sp.finish_warn("Something went wrong");
224    }
225
226    #[test]
227    fn test_spinner_finish_warn_multiple_calls() {
228        let sp = Spinner::new("Test");
229        sp.finish_warn("First warning");
230        // finish_warn can be called multiple times (though unusual)
231        sp.finish_warn("Second warning");
232    }
233
234    #[test]
235    fn test_spinner_println() {
236        let sp = Spinner::new("Working...");
237        sp.println("A message above the spinner");
238        sp.finish("Done");
239    }
240
241    #[test]
242    fn test_spinner_suspend() {
243        let sp = Spinner::new("Working...");
244        sp.suspend(|| {
245            // Code that prints directly
246            eprintln!("Suspended output");
247        });
248        sp.finish("Done");
249    }
250}