sublime_cli_tools/output/
progress.rs

1//! Progress indicators for long-running operations.
2//!
3//! This module provides progress bars and spinners for CLI operations that take time.
4//! It uses the `indicatif` crate and automatically handles:
5//! - TTY detection (no progress in non-TTY environments)
6//! - JSON mode suppression (no progress when outputting JSON)
7//! - Quiet mode suppression (no progress in quiet mode)
8//! - Proper cleanup on completion or error
9//!
10//! # What
11//!
12//! Provides:
13//! - `ProgressBar` wrapper for determinate operations (known total)
14//! - `Spinner` wrapper for indeterminate operations (unknown duration)
15//! - `MultiProgress` for managing multiple concurrent progress indicators
16//! - Automatic suppression based on output format and terminal capabilities
17//!
18//! # How
19//!
20//! Uses the `indicatif` crate for rendering progress indicators with consistent
21//! styling that matches the CLI theme. Automatically detects when progress should
22//! be suppressed (non-TTY, JSON mode, quiet mode) and becomes a no-op in those cases.
23//!
24//! # Why
25//!
26//! Progress indicators improve user experience for long-running operations by:
27//! - Showing that work is being done (preventing "is it hung?" questions)
28//! - Providing feedback on completion percentage
29//! - Estimating time remaining
30//! - Not interfering with structured output (JSON)
31//!
32//! # Examples
33//!
34//! Using a spinner for indeterminate operations:
35//!
36//! ```rust
37//! use sublime_cli_tools::output::progress::Spinner;
38//!
39//! let spinner = Spinner::new("Loading packages...");
40//! // ... do work ...
41//! spinner.finish_with_message("✓ Loaded 5 packages");
42//! ```
43//!
44//! Using a progress bar for determinate operations:
45//!
46//! ```rust
47//! use sublime_cli_tools::output::progress::ProgressBar;
48//!
49//! let progress = ProgressBar::new(100);
50//! progress.set_message("Processing files...");
51//!
52//! for i in 0..100 {
53//!     // ... do work ...
54//!     progress.inc(1);
55//! }
56//!
57//! progress.finish_with_message("✓ Complete");
58//! ```
59//!
60//! Managing multiple progress indicators:
61//!
62//! ```rust
63//! use sublime_cli_tools::output::progress::MultiProgress;
64//!
65//! let multi = MultiProgress::new();
66//! let pb1 = multi.add_progress_bar(100);
67//! let pb2 = multi.add_progress_bar(50);
68//!
69//! // Both progress bars update independently
70//! pb1.set_message("Task 1");
71//! pb2.set_message("Task 2");
72//! ```
73
74use console::Term;
75use indicatif::{
76    MultiProgress as IndicatifMultiProgress, ProgressBar as IndicatifProgressBar, ProgressStyle,
77};
78use std::time::Duration;
79
80use super::OutputFormat;
81
82/// Progress bar for determinate operations with known total.
83///
84/// Automatically suppressed in non-TTY, JSON mode, or quiet mode.
85/// When suppressed, all operations become no-ops.
86///
87/// # Examples
88///
89/// ```rust
90/// use sublime_cli_tools::output::progress::ProgressBar;
91///
92/// let pb = ProgressBar::new(100);
93/// pb.set_message("Processing...");
94///
95/// for i in 0..100 {
96///     pb.inc(1);
97/// }
98///
99/// pb.finish_with_message("✓ Done");
100/// ```
101pub struct ProgressBar {
102    inner: Option<IndicatifProgressBar>,
103}
104
105impl ProgressBar {
106    /// Creates a new progress bar with the given length.
107    ///
108    /// The progress bar is automatically suppressed if:
109    /// - stdout is not a TTY
110    /// - Output format is JSON or quiet
111    ///
112    /// # Examples
113    ///
114    /// ```rust
115    /// use sublime_cli_tools::output::progress::ProgressBar;
116    ///
117    /// let pb = ProgressBar::new(100);
118    /// ```
119    #[must_use]
120    pub fn new(len: u64) -> Self {
121        Self::new_with_format(len, OutputFormat::Human)
122    }
123
124    /// Creates a new progress bar with explicit format control.
125    ///
126    /// # Examples
127    ///
128    /// ```rust
129    /// use sublime_cli_tools::output::{progress::ProgressBar, OutputFormat};
130    ///
131    /// let pb = ProgressBar::new_with_format(100, OutputFormat::Human);
132    /// ```
133    #[must_use]
134    pub fn new_with_format(len: u64, format: OutputFormat) -> Self {
135        if should_show_progress(format) {
136            let pb = IndicatifProgressBar::new(len);
137            pb.set_style(
138                ProgressStyle::default_bar()
139                    .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
140                    .map_or_else(|_| ProgressStyle::default_bar(), |s| s.progress_chars("#>-")),
141            );
142            pb.enable_steady_tick(Duration::from_millis(100));
143            Self { inner: Some(pb) }
144        } else {
145            Self { inner: None }
146        }
147    }
148
149    /// Sets the progress bar message.
150    ///
151    /// # Examples
152    ///
153    /// ```rust
154    /// use sublime_cli_tools::output::progress::ProgressBar;
155    ///
156    /// let pb = ProgressBar::new(100);
157    /// pb.set_message("Downloading files...");
158    /// ```
159    pub fn set_message(&self, msg: impl Into<String>) {
160        if let Some(ref pb) = self.inner {
161            pb.set_message(msg.into());
162        }
163    }
164
165    /// Sets the current position of the progress bar.
166    ///
167    /// # Examples
168    ///
169    /// ```rust
170    /// use sublime_cli_tools::output::progress::ProgressBar;
171    ///
172    /// let pb = ProgressBar::new(100);
173    /// pb.set_position(50);
174    /// ```
175    pub fn set_position(&self, pos: u64) {
176        if let Some(ref pb) = self.inner {
177            pb.set_position(pos);
178        }
179    }
180
181    /// Increments the progress bar by the given amount.
182    ///
183    /// # Examples
184    ///
185    /// ```rust
186    /// use sublime_cli_tools::output::progress::ProgressBar;
187    ///
188    /// let pb = ProgressBar::new(100);
189    /// pb.inc(1);
190    /// ```
191    pub fn inc(&self, delta: u64) {
192        if let Some(ref pb) = self.inner {
193            pb.inc(delta);
194        }
195    }
196
197    /// Sets the progress bar length.
198    ///
199    /// # Examples
200    ///
201    /// ```rust
202    /// use sublime_cli_tools::output::progress::ProgressBar;
203    ///
204    /// let pb = ProgressBar::new(100);
205    /// pb.set_length(200);
206    /// ```
207    pub fn set_length(&self, len: u64) {
208        if let Some(ref pb) = self.inner {
209            pb.set_length(len);
210        }
211    }
212
213    /// Finishes the progress bar and clears it.
214    ///
215    /// # Examples
216    ///
217    /// ```rust
218    /// use sublime_cli_tools::output::progress::ProgressBar;
219    ///
220    /// let pb = ProgressBar::new(100);
221    /// pb.finish();
222    /// ```
223    pub fn finish(&self) {
224        if let Some(ref pb) = self.inner {
225            pb.finish_and_clear();
226        }
227    }
228
229    /// Finishes the progress bar with a message.
230    ///
231    /// # Examples
232    ///
233    /// ```rust
234    /// use sublime_cli_tools::output::progress::ProgressBar;
235    ///
236    /// let pb = ProgressBar::new(100);
237    /// pb.finish_with_message("✓ Complete");
238    /// ```
239    pub fn finish_with_message(&self, msg: impl Into<String>) {
240        if let Some(ref pb) = self.inner {
241            pb.finish_with_message(msg.into());
242        }
243    }
244
245    /// Finishes the progress bar and abandons it (shows as incomplete).
246    ///
247    /// # Examples
248    ///
249    /// ```rust
250    /// use sublime_cli_tools::output::progress::ProgressBar;
251    ///
252    /// let pb = ProgressBar::new(100);
253    /// pb.abandon();
254    /// ```
255    pub fn abandon(&self) {
256        if let Some(ref pb) = self.inner {
257            pb.abandon();
258        }
259    }
260
261    /// Finishes the progress bar and abandons it with a message.
262    ///
263    /// # Examples
264    ///
265    /// ```rust
266    /// use sublime_cli_tools::output::progress::ProgressBar;
267    ///
268    /// let pb = ProgressBar::new(100);
269    /// pb.abandon_with_message("✗ Failed");
270    /// ```
271    pub fn abandon_with_message(&self, msg: impl Into<String>) {
272        if let Some(ref pb) = self.inner {
273            pb.abandon_with_message(msg.into());
274        }
275    }
276
277    /// Returns true if the progress bar is active (not suppressed).
278    ///
279    /// # Examples
280    ///
281    /// ```rust
282    /// use sublime_cli_tools::output::progress::ProgressBar;
283    ///
284    /// let pb = ProgressBar::new(100);
285    /// if pb.is_active() {
286    ///     println!("Progress bar is active");
287    /// }
288    /// ```
289    #[must_use]
290    pub fn is_active(&self) -> bool {
291        self.inner.is_some()
292    }
293}
294
295/// Spinner for indeterminate operations with unknown duration.
296///
297/// Automatically suppressed in non-TTY, JSON mode, or quiet mode.
298/// When suppressed, all operations become no-ops.
299///
300/// # Examples
301///
302/// ```rust
303/// use sublime_cli_tools::output::progress::Spinner;
304///
305/// let spinner = Spinner::new("Loading...");
306/// // ... do work ...
307/// spinner.finish_with_message("✓ Done");
308/// ```
309pub struct Spinner {
310    inner: Option<IndicatifProgressBar>,
311}
312
313impl Spinner {
314    /// Creates a new spinner with the given message.
315    ///
316    /// The spinner is automatically suppressed if:
317    /// - stdout is not a TTY
318    /// - Output format is JSON or quiet
319    ///
320    /// # Examples
321    ///
322    /// ```rust
323    /// use sublime_cli_tools::output::progress::Spinner;
324    ///
325    /// let spinner = Spinner::new("Loading packages...");
326    /// ```
327    #[must_use]
328    pub fn new(msg: impl Into<String>) -> Self {
329        Self::new_with_format(msg, OutputFormat::Human)
330    }
331
332    /// Creates a new spinner with explicit format control.
333    ///
334    /// # Examples
335    ///
336    /// ```rust
337    /// use sublime_cli_tools::output::{progress::Spinner, OutputFormat};
338    ///
339    /// let spinner = Spinner::new_with_format("Loading...", OutputFormat::Human);
340    /// ```
341    #[must_use]
342    pub fn new_with_format(msg: impl Into<String>, format: OutputFormat) -> Self {
343        if should_show_progress(format) {
344            let pb = IndicatifProgressBar::new_spinner();
345            pb.set_style(
346                ProgressStyle::default_spinner().template("{spinner:.green} {msg}").map_or_else(
347                    |_| ProgressStyle::default_spinner(),
348                    |s| s.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
349                ),
350            );
351            pb.set_message(msg.into());
352            pb.enable_steady_tick(Duration::from_millis(80));
353            Self { inner: Some(pb) }
354        } else {
355            Self { inner: None }
356        }
357    }
358
359    /// Sets the spinner message.
360    ///
361    /// # Examples
362    ///
363    /// ```rust
364    /// use sublime_cli_tools::output::progress::Spinner;
365    ///
366    /// let spinner = Spinner::new("Loading...");
367    /// spinner.set_message("Still loading...");
368    /// ```
369    pub fn set_message(&self, msg: impl Into<String>) {
370        if let Some(ref pb) = self.inner {
371            pb.set_message(msg.into());
372        }
373    }
374
375    /// Manually ticks the spinner (usually not needed due to steady tick).
376    ///
377    /// # Examples
378    ///
379    /// ```rust
380    /// use sublime_cli_tools::output::progress::Spinner;
381    ///
382    /// let spinner = Spinner::new("Loading...");
383    /// spinner.tick();
384    /// ```
385    pub fn tick(&self) {
386        if let Some(ref pb) = self.inner {
387            pb.tick();
388        }
389    }
390
391    /// Finishes the spinner and clears it.
392    ///
393    /// # Examples
394    ///
395    /// ```rust
396    /// use sublime_cli_tools::output::progress::Spinner;
397    ///
398    /// let spinner = Spinner::new("Loading...");
399    /// spinner.finish();
400    /// ```
401    pub fn finish(&self) {
402        if let Some(ref pb) = self.inner {
403            pb.finish_and_clear();
404        }
405    }
406
407    /// Finishes the spinner with a message.
408    ///
409    /// # Examples
410    ///
411    /// ```rust
412    /// use sublime_cli_tools::output::progress::Spinner;
413    ///
414    /// let spinner = Spinner::new("Loading...");
415    /// spinner.finish_with_message("✓ Loaded 5 packages");
416    /// ```
417    pub fn finish_with_message(&self, msg: impl Into<String>) {
418        if let Some(ref pb) = self.inner {
419            pb.finish_with_message(msg.into());
420        }
421    }
422
423    /// Finishes the spinner and abandons it (shows as incomplete).
424    ///
425    /// # Examples
426    ///
427    /// ```rust
428    /// use sublime_cli_tools::output::progress::Spinner;
429    ///
430    /// let spinner = Spinner::new("Loading...");
431    /// spinner.abandon();
432    /// ```
433    pub fn abandon(&self) {
434        if let Some(ref pb) = self.inner {
435            pb.abandon();
436        }
437    }
438
439    /// Finishes the spinner and abandons it with a message.
440    ///
441    /// # Examples
442    ///
443    /// ```rust
444    /// use sublime_cli_tools::output::progress::Spinner;
445    ///
446    /// let spinner = Spinner::new("Loading...");
447    /// spinner.abandon_with_message("✗ Failed to load");
448    /// ```
449    pub fn abandon_with_message(&self, msg: impl Into<String>) {
450        if let Some(ref pb) = self.inner {
451            pb.abandon_with_message(msg.into());
452        }
453    }
454
455    /// Returns true if the spinner is active (not suppressed).
456    ///
457    /// # Examples
458    ///
459    /// ```rust
460    /// use sublime_cli_tools::output::progress::Spinner;
461    ///
462    /// let spinner = Spinner::new("Loading...");
463    /// if spinner.is_active() {
464    ///     println!("Spinner is active");
465    /// }
466    /// ```
467    #[must_use]
468    pub fn is_active(&self) -> bool {
469        self.inner.is_some()
470    }
471}
472
473/// Multi-progress manager for handling multiple progress indicators concurrently.
474///
475/// Automatically suppressed in non-TTY, JSON mode, or quiet mode.
476/// When suppressed, all operations become no-ops.
477///
478/// # Examples
479///
480/// ```rust
481/// use sublime_cli_tools::output::progress::MultiProgress;
482///
483/// let multi = MultiProgress::new();
484/// let pb1 = multi.add_progress_bar(100);
485/// let pb2 = multi.add_progress_bar(50);
486///
487/// pb1.set_message("Task 1");
488/// pb2.set_message("Task 2");
489/// ```
490pub struct MultiProgress {
491    inner: Option<IndicatifMultiProgress>,
492}
493
494impl MultiProgress {
495    /// Creates a new multi-progress manager.
496    ///
497    /// The manager is automatically suppressed if:
498    /// - stdout is not a TTY
499    /// - Output format is JSON or quiet
500    ///
501    /// # Examples
502    ///
503    /// ```rust
504    /// use sublime_cli_tools::output::progress::MultiProgress;
505    ///
506    /// let multi = MultiProgress::new();
507    /// ```
508    #[must_use]
509    pub fn new() -> Self {
510        Self::new_with_format(OutputFormat::Human)
511    }
512
513    /// Creates a new multi-progress manager with explicit format control.
514    ///
515    /// # Examples
516    ///
517    /// ```rust
518    /// use sublime_cli_tools::output::{progress::MultiProgress, OutputFormat};
519    ///
520    /// let multi = MultiProgress::new_with_format(OutputFormat::Human);
521    /// ```
522    #[must_use]
523    pub fn new_with_format(format: OutputFormat) -> Self {
524        if should_show_progress(format) {
525            let multi = IndicatifMultiProgress::new();
526            Self { inner: Some(multi) }
527        } else {
528            Self { inner: None }
529        }
530    }
531
532    /// Adds a progress bar to the multi-progress manager.
533    ///
534    /// # Examples
535    ///
536    /// ```rust
537    /// use sublime_cli_tools::output::progress::MultiProgress;
538    ///
539    /// let multi = MultiProgress::new();
540    /// let pb = multi.add_progress_bar(100);
541    /// pb.set_message("Processing...");
542    /// ```
543    #[must_use]
544    pub fn add_progress_bar(&self, len: u64) -> ProgressBar {
545        if let Some(ref multi) = self.inner {
546            let pb = multi.add(IndicatifProgressBar::new(len));
547            pb.set_style(
548                ProgressStyle::default_bar()
549                    .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
550                    .map_or_else(|_| ProgressStyle::default_bar(), |s| s.progress_chars("#>-")),
551            );
552            pb.enable_steady_tick(Duration::from_millis(100));
553            ProgressBar { inner: Some(pb) }
554        } else {
555            ProgressBar { inner: None }
556        }
557    }
558
559    /// Adds a spinner to the multi-progress manager.
560    ///
561    /// # Examples
562    ///
563    /// ```rust
564    /// use sublime_cli_tools::output::progress::MultiProgress;
565    ///
566    /// let multi = MultiProgress::new();
567    /// let spinner = multi.add_spinner("Loading...");
568    /// ```
569    #[must_use]
570    pub fn add_spinner(&self, msg: impl Into<String>) -> Spinner {
571        if let Some(ref multi) = self.inner {
572            let pb = multi.add(IndicatifProgressBar::new_spinner());
573            pb.set_style(
574                ProgressStyle::default_spinner().template("{spinner:.green} {msg}").map_or_else(
575                    |_| ProgressStyle::default_spinner(),
576                    |s| s.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
577                ),
578            );
579            pb.set_message(msg.into());
580            pb.enable_steady_tick(Duration::from_millis(80));
581            Spinner { inner: Some(pb) }
582        } else {
583            Spinner { inner: None }
584        }
585    }
586
587    /// Clears all progress indicators.
588    ///
589    /// # Examples
590    ///
591    /// ```rust
592    /// use sublime_cli_tools::output::progress::MultiProgress;
593    ///
594    /// let multi = MultiProgress::new();
595    /// let pb = multi.add_progress_bar(100);
596    /// multi.clear();
597    /// ```
598    pub fn clear(&self) {
599        if let Some(ref multi) = self.inner {
600            multi.clear().ok();
601        }
602    }
603
604    /// Returns true if the multi-progress manager is active (not suppressed).
605    ///
606    /// # Examples
607    ///
608    /// ```rust
609    /// use sublime_cli_tools::output::progress::MultiProgress;
610    ///
611    /// let multi = MultiProgress::new();
612    /// if multi.is_active() {
613    ///     println!("Multi-progress is active");
614    /// }
615    /// ```
616    #[must_use]
617    pub fn is_active(&self) -> bool {
618        self.inner.is_some()
619    }
620}
621
622impl Default for MultiProgress {
623    fn default() -> Self {
624        Self::new()
625    }
626}
627
628/// Determines if progress indicators should be shown.
629///
630/// Progress is suppressed when:
631/// - Output format is JSON or JsonCompact (to avoid mixing progress with structured output)
632/// - Output format is Quiet (no output at all)
633/// - stdout is not a TTY (e.g., piped to file or another command)
634///
635/// # Examples
636///
637/// ```rust
638/// use sublime_cli_tools::output::{OutputFormat, progress::should_show_progress};
639///
640/// assert!(!should_show_progress(OutputFormat::Json));
641/// assert!(!should_show_progress(OutputFormat::Quiet));
642/// ```
643#[must_use]
644pub fn should_show_progress(format: OutputFormat) -> bool {
645    // Never show progress in JSON or Quiet modes
646    if format.is_json() || format.is_quiet() {
647        return false;
648    }
649
650    // Only show progress if stdout is a TTY
651    Term::stdout().is_term()
652}