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: &str) {
50        self.bar.set_message(message.to_string());
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
73/// A counted progress bar for multi-step operations (X of Y).
74pub struct StepProgress {
75    bar: ProgressBar,
76}
77
78impl StepProgress {
79    /// Creates a progress bar for `total` steps with the given prefix.
80    pub fn new(total: u64, prefix: &str) -> Self {
81        let bar = if atty_stderr() {
82            let pb = ProgressBar::new(total);
83            pb.set_style(
84                ProgressStyle::with_template("{prefix} [{bar:30.cyan/dim}] {pos}/{len} {msg}")
85                    .unwrap()
86                    .progress_chars("━━╸"),
87            );
88            pb.set_prefix(prefix.to_string());
89            pb
90        } else {
91            eprintln!("{} (0/{})", prefix, total);
92            ProgressBar::hidden()
93        };
94        Self { bar }
95    }
96
97    /// Increments progress by one and updates the message.
98    pub fn inc(&self, message: &str) {
99        self.bar.set_message(message.to_string());
100        self.bar.inc(1);
101    }
102
103    /// Finishes the progress bar with a success message.
104    pub fn finish(&self, message: &str) {
105        if !self.bar.is_hidden() {
106            self.bar.finish_with_message(format!("✓ {}", message));
107        }
108    }
109}
110
111/// Checks if stderr is a TTY (interactive terminal).
112fn atty_stderr() -> bool {
113    use std::io::IsTerminal;
114    std::io::stderr().is_terminal()
115}
116
117// ============================================================================
118// Unit Tests
119// ============================================================================
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_spinner_create_and_finish() {
127        // In test context, stderr is not a TTY, so spinner is hidden
128        let sp = Spinner::new("Testing...");
129        sp.set_message("Updated");
130        sp.finish("Done");
131    }
132
133    #[test]
134    fn test_spinner_finish_and_clear() {
135        let sp = Spinner::new("Testing...");
136        sp.finish_and_clear();
137    }
138
139    #[test]
140    fn test_spinner_finish_warn() {
141        let sp = Spinner::new("Testing...");
142        sp.finish_warn("Warning");
143    }
144
145    #[test]
146    fn test_step_progress_create_and_finish() {
147        let prog = StepProgress::new(3, "Processing");
148        prog.inc("Step 1");
149        prog.inc("Step 2");
150        prog.inc("Step 3");
151        prog.finish("Done");
152    }
153
154    #[test]
155    fn test_spinner_multiple_set_message() {
156        let sp = Spinner::new("Initial");
157        sp.set_message("First update");
158        sp.set_message("Second update");
159        sp.set_message("Third update");
160        sp.finish("Complete");
161    }
162
163    #[test]
164    fn test_step_progress_multiple_inc() {
165        let prog = StepProgress::new(5, "Processing");
166        prog.inc("Step 1");
167        prog.inc("Step 2");
168        prog.inc("Step 3");
169        prog.inc("Step 4");
170        prog.inc("Step 5");
171        prog.finish("Done");
172    }
173
174    #[test]
175    fn test_step_progress_single_step() {
176        let prog = StepProgress::new(1, "Single");
177        prog.inc("Only step");
178        prog.finish("Complete");
179    }
180
181    #[test]
182    fn test_step_progress_large_total() {
183        let prog = StepProgress::new(100, "Large");
184        for i in 1..=100 {
185            prog.inc(&format!("Step {}", i));
186        }
187        prog.finish("Complete");
188    }
189
190    #[test]
191    fn test_step_progress_zero_total() {
192        let prog = StepProgress::new(0, "Empty");
193        prog.finish("Complete");
194    }
195
196    #[test]
197    fn test_spinner_finish_warn_with_message() {
198        let sp = Spinner::new("Warning test");
199        sp.finish_warn("Something went wrong");
200    }
201
202    #[test]
203    fn test_spinner_finish_warn_multiple_calls() {
204        let sp = Spinner::new("Test");
205        sp.finish_warn("First warning");
206        // finish_warn can be called multiple times (though unusual)
207        sp.finish_warn("Second warning");
208    }
209}