Skip to main content

ethcli/utils/
progress.rs

1//! Progress indicator utilities with TTY detection
2//!
3//! Provides consistent progress output that automatically adapts based on
4//! whether stdout/stderr is a terminal (TTY) or being piped/redirected.
5
6use std::io::{IsTerminal, Write};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11/// Check if stderr is a terminal (TTY)
12///
13/// Returns true if stderr is connected to an interactive terminal,
14/// false if it's being piped or redirected.
15#[inline]
16pub fn is_tty() -> bool {
17    std::io::stderr().is_terminal()
18}
19
20/// Check if stdout is a terminal (TTY)
21#[inline]
22pub fn is_stdout_tty() -> bool {
23    std::io::stdout().is_terminal()
24}
25
26/// Print a status message to stderr if it's a TTY
27///
28/// This is useful for progress messages that should only appear
29/// in interactive mode, not when output is being piped.
30pub fn status(msg: &str) {
31    if is_tty() {
32        eprintln!("{}", msg);
33    }
34}
35
36/// Print a status message with formatting if stderr is a TTY
37#[macro_export]
38macro_rules! status {
39    ($($arg:tt)*) => {
40        if $crate::utils::progress::is_tty() {
41            eprintln!($($arg)*);
42        }
43    };
44}
45
46/// A simple spinner for long-running operations
47///
48/// Only shows animation if stderr is a TTY.
49pub struct Spinner {
50    message: String,
51    frames: &'static [&'static str],
52    frame_idx: usize,
53    start: Instant,
54    is_tty: bool,
55    stopped: Arc<AtomicBool>,
56}
57
58impl Spinner {
59    /// Default spinner frames
60    const DEFAULT_FRAMES: &'static [&'static str] =
61        &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
62
63    /// ASCII-only spinner frames (for non-unicode terminals)
64    const ASCII_FRAMES: &'static [&'static str] = &["|", "/", "-", "\\"];
65
66    /// Create a new spinner with the given message
67    pub fn new(message: impl Into<String>) -> Self {
68        Self {
69            message: message.into(),
70            frames: Self::DEFAULT_FRAMES,
71            frame_idx: 0,
72            start: Instant::now(),
73            is_tty: is_tty(),
74            stopped: Arc::new(AtomicBool::new(false)),
75        }
76    }
77
78    /// Use ASCII-only frames (for terminals that don't support unicode)
79    pub fn ascii(mut self) -> Self {
80        self.frames = Self::ASCII_FRAMES;
81        self
82    }
83
84    /// Start the spinner (prints initial message)
85    pub fn start(&self) {
86        if self.is_tty {
87            eprint!("\r{} {}...", self.frames[0], self.message);
88            let _ = std::io::stderr().flush();
89        }
90    }
91
92    /// Update the spinner frame (call periodically)
93    pub fn tick(&mut self) {
94        if !self.is_tty || self.stopped.load(Ordering::Relaxed) {
95            return;
96        }
97
98        self.frame_idx = (self.frame_idx + 1) % self.frames.len();
99        eprint!("\r{} {}...", self.frames[self.frame_idx], self.message);
100        let _ = std::io::stderr().flush();
101    }
102
103    /// Update the message while spinning
104    pub fn set_message(&mut self, message: impl Into<String>) {
105        self.message = message.into();
106        if self.is_tty {
107            // Clear the line first
108            eprint!("\r\x1b[K");
109            eprint!("{} {}...", self.frames[self.frame_idx], self.message);
110            let _ = std::io::stderr().flush();
111        }
112    }
113
114    /// Stop the spinner with a success message
115    pub fn success(self, message: &str) {
116        self.stopped.store(true, Ordering::Relaxed);
117        if self.is_tty {
118            eprintln!(
119                "\r\x1b[K✓ {} ({:.1}s)",
120                message,
121                self.start.elapsed().as_secs_f64()
122            );
123        }
124    }
125
126    /// Stop the spinner with an error message
127    pub fn error(self, message: &str) {
128        self.stopped.store(true, Ordering::Relaxed);
129        if self.is_tty {
130            eprintln!("\r\x1b[K✗ {}", message);
131        }
132    }
133
134    /// Stop the spinner without a message (clear the line)
135    pub fn stop(self) {
136        self.stopped.store(true, Ordering::Relaxed);
137        if self.is_tty {
138            eprint!("\r\x1b[K");
139            let _ = std::io::stderr().flush();
140        }
141    }
142
143    /// Get elapsed time since spinner started
144    pub fn elapsed(&self) -> Duration {
145        self.start.elapsed()
146    }
147}
148
149impl Drop for Spinner {
150    fn drop(&mut self) {
151        // Ensure we clean up the line if dropped without explicit stop
152        if self.is_tty && !self.stopped.load(Ordering::Relaxed) {
153            eprint!("\r\x1b[K");
154            let _ = std::io::stderr().flush();
155        }
156    }
157}
158
159/// A simple progress bar
160pub struct ProgressBar {
161    total: u64,
162    current: u64,
163    message: String,
164    width: usize,
165    start: Instant,
166    is_tty: bool,
167    last_draw: Instant,
168}
169
170impl ProgressBar {
171    /// Create a new progress bar with the given total
172    pub fn new(total: u64, message: impl Into<String>) -> Self {
173        Self {
174            total,
175            current: 0,
176            message: message.into(),
177            width: 30,
178            start: Instant::now(),
179            is_tty: is_tty(),
180            last_draw: Instant::now(),
181        }
182    }
183
184    /// Set the bar width (default 30)
185    pub fn with_width(mut self, width: usize) -> Self {
186        self.width = width;
187        self
188    }
189
190    /// Update progress to a specific value
191    pub fn set(&mut self, current: u64) {
192        self.current = current.min(self.total);
193        self.draw(false);
194    }
195
196    /// Increment progress by 1
197    pub fn inc(&mut self) {
198        self.current = (self.current + 1).min(self.total);
199        self.draw(false);
200    }
201
202    /// Increment progress by a specific amount
203    pub fn inc_by(&mut self, amount: u64) {
204        self.current = (self.current + amount).min(self.total);
205        self.draw(false);
206    }
207
208    /// Draw the progress bar (rate limited to avoid flicker)
209    fn draw(&mut self, force: bool) {
210        if !self.is_tty {
211            return;
212        }
213
214        // Rate limit drawing to every 50ms unless forced
215        if !force && self.last_draw.elapsed() < Duration::from_millis(50) {
216            return;
217        }
218        self.last_draw = Instant::now();
219
220        let percent = if self.total > 0 {
221            (self.current as f64 / self.total as f64 * 100.0) as u64
222        } else {
223            0
224        };
225
226        let filled = if self.total > 0 {
227            (self.current as f64 / self.total as f64 * self.width as f64) as usize
228        } else {
229            0
230        };
231
232        let empty = self.width.saturating_sub(filled);
233        let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty));
234
235        let elapsed = self.start.elapsed().as_secs_f64();
236        let rate = if elapsed > 0.0 {
237            self.current as f64 / elapsed
238        } else {
239            0.0
240        };
241
242        let eta = if rate > 0.0 && self.current < self.total {
243            let remaining = self.total - self.current;
244            format!(" ETA: {:.0}s", remaining as f64 / rate)
245        } else {
246            String::new()
247        };
248
249        eprint!(
250            "\r{} [{}] {:>3}% ({}/{}){}",
251            self.message, bar, percent, self.current, self.total, eta
252        );
253        let _ = std::io::stderr().flush();
254    }
255
256    /// Finish the progress bar
257    pub fn finish(mut self) {
258        self.current = self.total;
259        self.draw(true);
260        if self.is_tty {
261            eprintln!(); // New line after completion
262        }
263    }
264
265    /// Finish with a message
266    pub fn finish_with_message(self, message: &str) {
267        if self.is_tty {
268            eprintln!(
269                "\r\x1b[K{} ({:.1}s)",
270                message,
271                self.start.elapsed().as_secs_f64()
272            );
273        }
274    }
275}
276
277/// Print a progress message only if not in quiet mode and stderr is a TTY
278///
279/// Use this for "Fetching X..." style messages.
280pub fn progress_message(quiet: bool, message: impl std::fmt::Display) {
281    if !quiet && is_tty() {
282        eprintln!("{}...", message);
283    }
284}
285
286/// Macro for progress messages that respects quiet mode
287#[macro_export]
288macro_rules! progress {
289    ($quiet:expr, $($arg:tt)*) => {
290        if !$quiet && $crate::utils::progress::is_tty() {
291            eprintln!($($arg)*);
292        }
293    };
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_is_tty() {
302        // In tests, this will usually be false when piped
303        let _ = is_tty();
304        let _ = is_stdout_tty();
305    }
306
307    #[test]
308    fn test_spinner_creation() {
309        let spinner = Spinner::new("Loading");
310        assert!(!spinner.stopped.load(Ordering::Relaxed));
311        spinner.stop();
312    }
313
314    #[test]
315    fn test_spinner_ascii() {
316        let spinner = Spinner::new("Loading").ascii();
317        assert_eq!(spinner.frames, Spinner::ASCII_FRAMES);
318        spinner.stop();
319    }
320
321    #[test]
322    fn test_progress_bar_creation() {
323        let mut bar = ProgressBar::new(100, "Processing");
324        bar.set(50);
325        assert_eq!(bar.current, 50);
326        bar.inc();
327        assert_eq!(bar.current, 51);
328        bar.inc_by(10);
329        assert_eq!(bar.current, 61);
330    }
331
332    #[test]
333    fn test_progress_bar_overflow() {
334        let mut bar = ProgressBar::new(100, "Test");
335        bar.set(150); // Should cap at total
336        assert_eq!(bar.current, 100);
337    }
338
339    #[test]
340    fn test_progress_bar_zero_total() {
341        let bar = ProgressBar::new(0, "Empty");
342        assert_eq!(bar.total, 0);
343    }
344}