Skip to main content

entrenar_common/
progress.rs

1//! Progress indicators for long-running operations.
2//!
3//! Provides visual feedback during operations like model downloading,
4//! training, and export (Visual Control principle).
5
6use std::io::{self, Write};
7
8/// A simple progress bar for terminal output.
9#[derive(Debug)]
10pub struct ProgressBar {
11    total: u64,
12    current: u64,
13    width: usize,
14    message: String,
15    enabled: bool,
16}
17
18impl ProgressBar {
19    /// Create a new progress bar with the given total.
20    pub fn new(total: u64) -> Self {
21        Self {
22            total,
23            current: 0,
24            width: 40,
25            message: String::new(),
26            enabled: true,
27        }
28    }
29
30    /// Set the display width.
31    pub fn with_width(mut self, width: usize) -> Self {
32        self.width = width;
33        self
34    }
35
36    /// Set whether the progress bar is enabled.
37    pub fn with_enabled(mut self, enabled: bool) -> Self {
38        self.enabled = enabled;
39        self
40    }
41
42    /// Set the message to display.
43    pub fn set_message(&mut self, message: impl Into<String>) {
44        self.message = message.into();
45        self.render();
46    }
47
48    /// Increment progress by the given amount.
49    pub fn inc(&mut self, amount: u64) {
50        self.current = (self.current + amount).min(self.total);
51        self.render();
52    }
53
54    /// Set the current progress.
55    pub fn set(&mut self, current: u64) {
56        self.current = current.min(self.total);
57        self.render();
58    }
59
60    /// Get the current progress percentage.
61    pub fn percentage(&self) -> f64 {
62        if self.total == 0 {
63            return 100.0;
64        }
65        (self.current as f64 / self.total as f64) * 100.0
66    }
67
68    /// Finish the progress bar.
69    pub fn finish(&mut self) {
70        self.current = self.total;
71        self.render();
72        if self.enabled {
73            println!();
74        }
75    }
76
77    /// Finish with a message.
78    pub fn finish_with_message(&mut self, message: impl Into<String>) {
79        self.message = message.into();
80        self.finish();
81    }
82
83    fn render(&self) {
84        if !self.enabled {
85            return;
86        }
87
88        let percentage = self.percentage();
89        let filled = (percentage / 100.0 * self.width as f64) as usize;
90        let empty = self.width - filled;
91
92        let bar = format!(
93            "\r[{}{}] {:>5.1}% {}",
94            "█".repeat(filled),
95            "░".repeat(empty),
96            percentage,
97            self.message
98        );
99
100        print!("{bar}");
101        let _ = io::stdout().flush();
102    }
103}
104
105/// A spinner for indeterminate progress.
106#[derive(Debug)]
107pub struct Spinner {
108    frames: Vec<&'static str>,
109    current_frame: usize,
110    message: String,
111    enabled: bool,
112}
113
114impl Default for Spinner {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl Spinner {
121    /// Create a new spinner.
122    pub fn new() -> Self {
123        Self {
124            frames: vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
125            current_frame: 0,
126            message: String::new(),
127            enabled: true,
128        }
129    }
130
131    /// Set whether the spinner is enabled.
132    pub fn with_enabled(mut self, enabled: bool) -> Self {
133        self.enabled = enabled;
134        self
135    }
136
137    /// Set the message.
138    pub fn set_message(&mut self, message: impl Into<String>) {
139        self.message = message.into();
140    }
141
142    /// Advance to the next frame.
143    pub fn tick(&mut self) {
144        if !self.enabled {
145            return;
146        }
147
148        self.current_frame = (self.current_frame + 1) % self.frames.len();
149        print!("\r{} {}", self.frames[self.current_frame], self.message);
150        let _ = io::stdout().flush();
151    }
152
153    /// Finish the spinner with a success message.
154    pub fn finish_with_message(&mut self, message: impl Into<String>) {
155        if self.enabled {
156            println!("\r✓ {}", message.into());
157        }
158    }
159
160    /// Finish the spinner with an error message.
161    pub fn finish_with_error(&mut self, message: impl Into<String>) {
162        if self.enabled {
163            println!("\r✗ {}", message.into());
164        }
165    }
166}
167
168/// Status indicator for multi-step operations.
169#[derive(Debug)]
170pub struct StepTracker {
171    steps: Vec<String>,
172    current: usize,
173    enabled: bool,
174}
175
176impl StepTracker {
177    /// Create a new step tracker.
178    pub fn new(steps: Vec<impl Into<String>>) -> Self {
179        Self {
180            steps: steps.into_iter().map(Into::into).collect(),
181            current: 0,
182            enabled: true,
183        }
184    }
185
186    /// Set whether output is enabled.
187    pub fn with_enabled(mut self, enabled: bool) -> Self {
188        self.enabled = enabled;
189        self
190    }
191
192    /// Start the next step.
193    pub fn next_step(&mut self) {
194        if self.current < self.steps.len() {
195            if self.enabled {
196                println!(
197                    "[{}/{}] {}...",
198                    self.current + 1,
199                    self.steps.len(),
200                    self.steps[self.current]
201                );
202            }
203            self.current += 1;
204        }
205    }
206
207    /// Complete the current step with a message.
208    pub fn complete_step(&self, message: impl Into<String>) {
209        if self.enabled {
210            println!("  ✓ {}", message.into());
211        }
212    }
213
214    /// Mark the current step as failed.
215    pub fn fail_step(&self, message: impl Into<String>) {
216        if self.enabled {
217            println!("  ✗ {}", message.into());
218        }
219    }
220
221    /// Check if all steps are complete.
222    pub fn is_complete(&self) -> bool {
223        self.current >= self.steps.len()
224    }
225
226    /// Get the total number of steps.
227    pub fn total_steps(&self) -> usize {
228        self.steps.len()
229    }
230
231    /// Get the current step number (1-indexed).
232    pub fn current_step(&self) -> usize {
233        self.current
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_progress_bar_percentage() {
243        let mut bar = ProgressBar::new(100).with_enabled(false);
244        assert_eq!(bar.percentage(), 0.0);
245
246        bar.set(50);
247        assert_eq!(bar.percentage(), 50.0);
248
249        bar.set(100);
250        assert_eq!(bar.percentage(), 100.0);
251    }
252
253    #[test]
254    fn test_progress_bar_increment() {
255        let mut bar = ProgressBar::new(100).with_enabled(false);
256        bar.inc(25);
257        assert_eq!(bar.current, 25);
258        bar.inc(25);
259        assert_eq!(bar.current, 50);
260    }
261
262    #[test]
263    fn test_progress_bar_overflow_protection() {
264        let mut bar = ProgressBar::new(100).with_enabled(false);
265        bar.set(200);
266        assert_eq!(bar.current, 100);
267        assert_eq!(bar.percentage(), 100.0);
268    }
269
270    #[test]
271    fn test_progress_bar_zero_total() {
272        let bar = ProgressBar::new(0).with_enabled(false);
273        assert_eq!(bar.percentage(), 100.0);
274    }
275
276    #[test]
277    fn test_step_tracker_progression() {
278        let mut tracker = StepTracker::new(vec!["Step 1", "Step 2", "Step 3"]).with_enabled(false);
279
280        assert_eq!(tracker.total_steps(), 3);
281        assert_eq!(tracker.current_step(), 0);
282        assert!(!tracker.is_complete());
283
284        tracker.next_step();
285        assert_eq!(tracker.current_step(), 1);
286
287        tracker.next_step();
288        tracker.next_step();
289        assert!(tracker.is_complete());
290    }
291
292    #[test]
293    fn test_spinner_frames() {
294        let spinner = Spinner::new();
295        assert!(!spinner.frames.is_empty());
296    }
297
298    #[test]
299    fn test_spinner_default() {
300        let spinner = Spinner::default();
301        assert_eq!(spinner.frames.len(), 10);
302        assert!(spinner.enabled);
303    }
304
305    #[test]
306    fn test_spinner_disabled() {
307        let spinner = Spinner::new().with_enabled(false);
308        assert!(!spinner.enabled);
309    }
310
311    #[test]
312    fn test_spinner_message() {
313        let mut spinner = Spinner::new().with_enabled(false);
314        spinner.set_message("Loading...");
315        assert_eq!(spinner.message, "Loading...");
316    }
317
318    #[test]
319    fn test_spinner_tick_cycles_frames() {
320        let mut spinner = Spinner::new();
321        spinner.enabled = false; // Disable output but keep frame cycling
322                                 // Manually cycle since tick() returns early when disabled
323        spinner.current_frame = (spinner.current_frame + 1) % spinner.frames.len();
324        assert_eq!(spinner.current_frame, 1);
325        spinner.current_frame = (spinner.current_frame + 1) % spinner.frames.len();
326        assert_eq!(spinner.current_frame, 2);
327    }
328
329    #[test]
330    fn test_spinner_tick_wraps_around() {
331        let spinner = Spinner::new();
332        // Test wrap around logic directly
333        let frame_count = spinner.frames.len();
334        let after_15_ticks = 15 % frame_count;
335        assert_eq!(after_15_ticks, 5);
336    }
337
338    #[test]
339    fn test_progress_bar_width() {
340        let bar = ProgressBar::new(100).with_width(20);
341        assert_eq!(bar.width, 20);
342    }
343
344    #[test]
345    fn test_progress_bar_message() {
346        let mut bar = ProgressBar::new(100).with_enabled(false);
347        bar.set_message("Downloading");
348        assert_eq!(bar.message, "Downloading");
349    }
350
351    #[test]
352    fn test_progress_bar_finish() {
353        let mut bar = ProgressBar::new(100).with_enabled(false);
354        bar.set(50);
355        bar.finish();
356        assert_eq!(bar.current, 100);
357    }
358
359    #[test]
360    fn test_progress_bar_finish_with_message() {
361        let mut bar = ProgressBar::new(100).with_enabled(false);
362        bar.finish_with_message("Done!");
363        assert_eq!(bar.message, "Done!");
364        assert_eq!(bar.current, 100);
365    }
366
367    #[test]
368    fn test_step_tracker_empty() {
369        let tracker = StepTracker::new(Vec::<String>::new()).with_enabled(false);
370        assert_eq!(tracker.total_steps(), 0);
371        assert!(tracker.is_complete());
372    }
373
374    #[test]
375    fn test_step_tracker_current_step_1_indexed() {
376        let mut tracker = StepTracker::new(vec!["A", "B"]).with_enabled(false);
377        assert_eq!(tracker.current_step(), 0);
378        tracker.next_step();
379        assert_eq!(tracker.current_step(), 1);
380        tracker.next_step();
381        assert_eq!(tracker.current_step(), 2);
382    }
383
384    #[test]
385    fn test_step_tracker_overflow_protection() {
386        let mut tracker = StepTracker::new(vec!["Only"]).with_enabled(false);
387        tracker.next_step();
388        tracker.next_step(); // Extra call should be safe
389        tracker.next_step();
390        assert!(tracker.is_complete());
391        assert_eq!(tracker.current_step(), 1);
392    }
393}