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}