Skip to main content

oxigaf_cli/
progress.rs

1//! Unified progress bar utilities with consistent styling.
2//!
3//! Provides reusable progress bar helpers for various CLI operations:
4//! - Training iterations
5//! - File downloads
6//! - Rendering frames
7//! - Export operations
8//! - Indeterminate spinners
9//! - Multi-progress for parallel operations
10//!
11//! All progress bars respect verbosity settings and use a consistent
12//! color scheme (green spinner, cyan/blue bar).
13
14use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
15
16use crate::verbosity::Verbosity;
17
18// ---------------------------------------------------------------------------
19// Progress Bar Helpers
20// ---------------------------------------------------------------------------
21
22/// Create a progress bar for training iterations.
23///
24/// Shows iteration count, loss value, and estimated time to completion.
25///
26/// # Arguments
27///
28/// * `total` - Total number of training iterations
29/// * `verbosity` - Verbosity level to respect
30///
31/// # Returns
32///
33/// A configured progress bar, or hidden bar if progress shouldn't be shown.
34///
35/// # Examples
36///
37/// ```no_run
38/// use oxigaf_cli::progress;
39/// use oxigaf_cli::verbosity::Verbosity;
40///
41/// let pb = progress::training_progress(1000, Verbosity::Normal);
42/// for i in 0..1000 {
43///     pb.set_message(format!("0.{:04}", 1000 - i));
44///     pb.inc(1);
45/// }
46/// pb.finish_with_message("Training complete");
47/// ```
48#[must_use]
49pub fn training_progress(total: u64, verbosity: Verbosity) -> ProgressBar {
50    if !verbosity.show_progress() {
51        return ProgressBar::hidden();
52    }
53
54    let pb = ProgressBar::new(total);
55    pb.set_style(
56        ProgressStyle::with_template(
57            "{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} | loss: {msg} | ETA: {eta}",
58        )
59        .unwrap_or_else(|_| ProgressStyle::default_bar())
60        .progress_chars("=>-"),
61    );
62    pb
63}
64
65/// Create a progress bar for file downloads.
66///
67/// Shows bytes downloaded, download speed, and estimated time remaining.
68///
69/// # Arguments
70///
71/// * `total_bytes` - Total size of download in bytes
72/// * `verbosity` - Verbosity level to respect
73///
74/// # Returns
75///
76/// A configured progress bar, or hidden bar if progress shouldn't be shown.
77///
78/// # Examples
79///
80/// ```no_run
81/// use oxigaf_cli::progress;
82/// use oxigaf_cli::verbosity::Verbosity;
83///
84/// let pb = progress::download_progress(1024 * 1024 * 100, Verbosity::Normal);
85/// // Update as bytes are received
86/// pb.inc(4096);
87/// pb.finish_with_message("Download complete");
88/// ```
89#[must_use]
90pub fn download_progress(total_bytes: u64, verbosity: Verbosity) -> ProgressBar {
91    if !verbosity.show_progress() {
92        return ProgressBar::hidden();
93    }
94
95    let pb = ProgressBar::new(total_bytes);
96    pb.set_style(
97        ProgressStyle::with_template(
98            "{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}) | ETA: {eta}",
99        )
100        .unwrap_or_else(|_| ProgressStyle::default_bar())
101        .progress_chars("=>-"),
102    );
103    pb
104}
105
106/// Create a progress bar for rendering frames.
107///
108/// Shows frame count and rendering progress with ETA.
109///
110/// # Arguments
111///
112/// * `num_frames` - Total number of frames to render
113/// * `verbosity` - Verbosity level to respect
114///
115/// # Returns
116///
117/// A configured progress bar, or hidden bar if progress shouldn't be shown.
118///
119/// # Examples
120///
121/// ```no_run
122/// use oxigaf_cli::progress;
123/// use oxigaf_cli::verbosity::Verbosity;
124///
125/// let pb = progress::render_progress(120, Verbosity::Normal);
126/// for i in 0..120 {
127///     pb.set_message(format!("frame {:03}", i));
128///     pb.inc(1);
129/// }
130/// pb.finish_with_message("Rendering complete");
131/// ```
132#[must_use]
133#[allow(dead_code)]
134pub fn render_progress(num_frames: u64, verbosity: Verbosity) -> ProgressBar {
135    if !verbosity.show_progress() {
136        return ProgressBar::hidden();
137    }
138
139    let pb = ProgressBar::new(num_frames);
140    pb.set_style(
141        ProgressStyle::with_template(
142            "{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} frames | {msg} | ETA: {eta}",
143        )
144        .unwrap_or_else(|_| ProgressStyle::default_bar())
145        .progress_chars("=>-"),
146    );
147    pb
148}
149
150/// Create a progress bar for export operations.
151///
152/// Shows item count with custom message support.
153///
154/// # Arguments
155///
156/// * `num_items` - Total number of items to export
157/// * `verbosity` - Verbosity level to respect
158///
159/// # Returns
160///
161/// A configured progress bar, or hidden bar if progress shouldn't be shown.
162///
163/// # Examples
164///
165/// ```no_run
166/// use oxigaf_cli::progress;
167/// use oxigaf_cli::verbosity::Verbosity;
168///
169/// let pb = progress::export_progress(50, Verbosity::Normal);
170/// pb.set_message("extracting");
171/// for i in 0..50 {
172///     pb.inc(1);
173/// }
174/// pb.finish_with_message("Export complete");
175/// ```
176#[must_use]
177pub fn export_progress(num_items: u64, verbosity: Verbosity) -> ProgressBar {
178    if !verbosity.show_progress() {
179        return ProgressBar::hidden();
180    }
181
182    let pb = ProgressBar::new(num_items);
183    pb.set_style(
184        ProgressStyle::with_template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} | {msg}")
185            .unwrap_or_else(|_| ProgressStyle::default_bar())
186            .progress_chars("=>-"),
187    );
188    pb
189}
190
191/// Create a spinner for indeterminate operations.
192///
193/// Use when the total work amount is unknown or for quick operations.
194///
195/// # Arguments
196///
197/// * `message` - Message to display next to the spinner
198/// * `verbosity` - Verbosity level to respect
199///
200/// # Returns
201///
202/// A configured spinner, or hidden spinner if progress shouldn't be shown.
203///
204/// # Examples
205///
206/// ```no_run
207/// use oxigaf_cli::progress;
208/// use oxigaf_cli::verbosity::Verbosity;
209///
210/// let pb = progress::spinner("Downloading...", Verbosity::Normal);
211/// // Do work
212/// pb.finish_with_message("Done!");
213/// ```
214#[must_use]
215pub fn spinner(message: &str, verbosity: Verbosity) -> ProgressBar {
216    if !verbosity.show_progress() {
217        return ProgressBar::hidden();
218    }
219
220    let pb = ProgressBar::new_spinner();
221    pb.set_style(
222        ProgressStyle::with_template("{spinner:.green} {msg}")
223            .unwrap_or_else(|_| ProgressStyle::default_spinner()),
224    );
225    pb.set_message(message.to_string());
226    pb
227}
228
229/// Create a multi-progress container for parallel operations.
230///
231/// Allows multiple progress bars to be displayed simultaneously.
232///
233/// # Arguments
234///
235/// * `verbosity` - Verbosity level to respect
236///
237/// # Returns
238///
239/// A multi-progress container if progress should be shown, None otherwise.
240///
241/// # Examples
242///
243/// ```no_run
244/// use oxigaf_cli::progress;
245/// use oxigaf_cli::verbosity::Verbosity;
246///
247/// if let Some(multi) = progress::multi_progress(Verbosity::Normal) {
248///     let pb1 = multi.add(progress::render_progress(100, Verbosity::Normal));
249///     let pb2 = multi.add(progress::render_progress(100, Verbosity::Normal));
250///     // Use pb1 and pb2 for parallel work
251/// }
252/// ```
253#[must_use]
254pub fn multi_progress(verbosity: Verbosity) -> Option<MultiProgress> {
255    if verbosity.show_progress() {
256        Some(MultiProgress::new())
257    } else {
258        None
259    }
260}
261
262/// Create a generic progress bar with custom template.
263///
264/// For specialized use cases not covered by the predefined helpers.
265///
266/// # Arguments
267///
268/// * `total` - Total number of items/iterations
269/// * `template` - Custom template string (see indicatif documentation)
270/// * `verbosity` - Verbosity level to respect
271///
272/// # Returns
273///
274/// A configured progress bar, or hidden bar if progress shouldn't be shown.
275///
276/// # Examples
277///
278/// ```no_run
279/// use oxigaf_cli::progress;
280/// use oxigaf_cli::verbosity::Verbosity;
281///
282/// let pb = progress::custom_progress(
283///     1000,
284///     "{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} iterations | {msg}",
285///     Verbosity::Normal,
286/// );
287/// ```
288#[must_use]
289pub fn custom_progress(total: u64, template: &str, verbosity: Verbosity) -> ProgressBar {
290    if !verbosity.show_progress() {
291        return ProgressBar::hidden();
292    }
293
294    let pb = ProgressBar::new(total);
295    pb.set_style(
296        ProgressStyle::with_template(template)
297            .unwrap_or_else(|_| ProgressStyle::default_bar())
298            .progress_chars("=>-"),
299    );
300    pb
301}
302
303// ---------------------------------------------------------------------------
304// Tests
305// ---------------------------------------------------------------------------
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn training_progress_hidden_in_quiet_mode() {
313        let pb = training_progress(100, Verbosity::Quiet);
314        assert!(pb.is_hidden());
315    }
316
317    #[test]
318    fn training_progress_hidden_in_debug_mode() {
319        let pb = training_progress(100, Verbosity::Debug);
320        assert!(pb.is_hidden());
321    }
322
323    #[test]
324    fn training_progress_hidden_in_trace_mode() {
325        let pb = training_progress(100, Verbosity::Trace);
326        assert!(pb.is_hidden());
327    }
328
329    #[test]
330    fn training_progress_visible_in_normal_mode() {
331        let pb = training_progress(100, Verbosity::Normal);
332        // Check that progress bar has the expected length
333        assert_eq!(pb.length(), Some(100));
334    }
335
336    #[test]
337    fn training_progress_visible_in_verbose_mode() {
338        let pb = training_progress(100, Verbosity::Verbose);
339        // Check that progress bar has the expected length
340        assert_eq!(pb.length(), Some(100));
341    }
342
343    #[test]
344    fn download_progress_hidden_in_quiet_mode() {
345        let pb = download_progress(1000, Verbosity::Quiet);
346        assert!(pb.is_hidden());
347    }
348
349    #[test]
350    fn download_progress_visible_in_normal_mode() {
351        let pb = download_progress(1000, Verbosity::Normal);
352        // Check that progress bar has the expected length
353        assert_eq!(pb.length(), Some(1000));
354    }
355
356    #[test]
357    fn render_progress_respects_verbosity() {
358        assert!(render_progress(100, Verbosity::Quiet).is_hidden());
359        assert_eq!(render_progress(100, Verbosity::Normal).length(), Some(100));
360        assert_eq!(render_progress(100, Verbosity::Verbose).length(), Some(100));
361        assert!(render_progress(100, Verbosity::Debug).is_hidden());
362        assert!(render_progress(100, Verbosity::Trace).is_hidden());
363    }
364
365    #[test]
366    fn export_progress_respects_verbosity() {
367        assert!(export_progress(50, Verbosity::Quiet).is_hidden());
368        assert_eq!(export_progress(50, Verbosity::Normal).length(), Some(50));
369        assert_eq!(export_progress(50, Verbosity::Verbose).length(), Some(50));
370        assert!(export_progress(50, Verbosity::Debug).is_hidden());
371    }
372
373    #[test]
374    fn spinner_respects_verbosity() {
375        let pb = spinner("test", Verbosity::Quiet);
376        assert!(pb.is_hidden());
377
378        let pb = spinner("test", Verbosity::Normal);
379        // Spinners have no length, just check it's not hidden by checking it has a style
380        assert!(!pb.is_finished());
381
382        let pb = spinner("test", Verbosity::Verbose);
383        assert!(!pb.is_finished());
384
385        let pb = spinner("test", Verbosity::Debug);
386        assert!(pb.is_hidden());
387    }
388
389    #[test]
390    fn multi_progress_respects_verbosity() {
391        assert!(multi_progress(Verbosity::Quiet).is_none());
392        assert!(multi_progress(Verbosity::Normal).is_some());
393        assert!(multi_progress(Verbosity::Verbose).is_some());
394        assert!(multi_progress(Verbosity::Debug).is_none());
395    }
396
397    #[test]
398    fn custom_progress_respects_verbosity() {
399        let template = "{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len}";
400
401        assert!(custom_progress(100, template, Verbosity::Quiet).is_hidden());
402        assert_eq!(
403            custom_progress(100, template, Verbosity::Normal).length(),
404            Some(100)
405        );
406        assert_eq!(
407            custom_progress(100, template, Verbosity::Verbose).length(),
408            Some(100)
409        );
410        assert!(custom_progress(100, template, Verbosity::Debug).is_hidden());
411    }
412
413    #[test]
414    fn progress_bars_have_correct_length() {
415        let pb = training_progress(1000, Verbosity::Normal);
416        assert_eq!(pb.length(), Some(1000));
417
418        let pb = download_progress(2048, Verbosity::Normal);
419        assert_eq!(pb.length(), Some(2048));
420
421        let pb = render_progress(120, Verbosity::Normal);
422        assert_eq!(pb.length(), Some(120));
423
424        let pb = export_progress(50, Verbosity::Normal);
425        assert_eq!(pb.length(), Some(50));
426    }
427}