fob_cli/ui/
progress.rs

1//! Progress tracking for multi-step bundling operations.
2
3use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
4use owo_colors::OwoColorize;
5use std::time::Duration;
6
7/// Progress tracker for bundling operations.
8///
9/// Provides a main progress bar and multiple subtask spinners for detailed
10/// feedback during multi-step build operations. Automatically cleans up on drop.
11///
12/// # Examples
13///
14/// ```no_run
15/// use fob_cli::ui::BundleProgress;
16///
17/// let mut progress = BundleProgress::new(5);
18/// let task1 = progress.add_task("Parsing modules");
19/// progress.finish_task(task1, "Parsed 50 modules");
20///
21/// let task2 = progress.add_task("Transforming code");
22/// progress.finish_task(task2, "Transformed TypeScript");
23///
24/// progress.finish("Build complete!");
25/// ```
26pub struct BundleProgress {
27    multi: MultiProgress,
28    main_bar: ProgressBar,
29    task_bars: Vec<ProgressBar>,
30}
31
32impl BundleProgress {
33    /// Create a new progress tracker with the specified number of total tasks.
34    ///
35    /// # Arguments
36    ///
37    /// * `total_tasks` - Total number of tasks to complete
38    ///
39    /// # Examples
40    ///
41    /// ```no_run
42    /// use fob_cli::ui::BundleProgress;
43    ///
44    /// let mut progress = BundleProgress::new(5);
45    /// // Add and complete tasks...
46    /// ```
47    pub fn new(total_tasks: u64) -> Self {
48        let multi = MultiProgress::new();
49
50        let main_bar = multi.add(ProgressBar::new(total_tasks));
51        main_bar.set_style(
52            ProgressStyle::default_bar()
53                .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
54                .expect("valid template")
55                .progress_chars("█▓▒░"),
56        );
57        main_bar.enable_steady_tick(Duration::from_millis(100));
58
59        Self {
60            multi,
61            main_bar,
62            task_bars: Vec::new(),
63        }
64    }
65
66    /// Add a subtask progress spinner.
67    ///
68    /// Returns the task ID which can be used to update or finish the task.
69    ///
70    /// # Arguments
71    ///
72    /// * `name` - Initial message to display for this task
73    ///
74    /// # Returns
75    ///
76    /// Task ID for updating this task's status
77    pub fn add_task(&mut self, name: &str) -> usize {
78        let pb = self.multi.add(ProgressBar::new_spinner());
79        pb.set_style(
80            ProgressStyle::default_spinner()
81                .template("  {spinner:.blue} {msg}")
82                .expect("valid template")
83                .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
84        );
85        pb.set_message(format!("{}", name.dimmed()));
86        pb.enable_steady_tick(Duration::from_millis(80));
87
88        let idx = self.task_bars.len();
89        self.task_bars.push(pb);
90        idx
91    }
92
93    /// Update a task's status message.
94    ///
95    /// # Arguments
96    ///
97    /// * `task_id` - ID returned from `add_task`
98    /// * `message` - New message to display
99    pub fn update_task(&self, task_id: usize, message: &str) {
100        if let Some(pb) = self.task_bars.get(task_id) {
101            pb.set_message(message.to_string());
102        }
103    }
104
105    /// Mark a task as successfully completed.
106    ///
107    /// Increments the main progress bar and displays a success checkmark.
108    ///
109    /// # Arguments
110    ///
111    /// * `task_id` - ID returned from `add_task`
112    /// * `message` - Completion message to display
113    pub fn finish_task(&mut self, task_id: usize, message: &str) {
114        if let Some(pb) = self.task_bars.get(task_id) {
115            pb.finish_with_message(format!("  {} {}", "✓".green(), message));
116        }
117        self.main_bar.inc(1);
118    }
119
120    /// Mark a task as failed.
121    ///
122    /// Does not increment the main progress bar, displays an error symbol.
123    ///
124    /// # Arguments
125    ///
126    /// * `task_id` - ID returned from `add_task`
127    /// * `message` - Failure message to display
128    pub fn fail_task(&self, task_id: usize, message: &str) {
129        if let Some(pb) = self.task_bars.get(task_id) {
130            pb.finish_with_message(format!("  {} {}", "✗".red(), message));
131        }
132    }
133
134    /// Complete the entire progress operation.
135    ///
136    /// # Arguments
137    ///
138    /// * `message` - Final completion message
139    pub fn finish(&self, message: &str) {
140        self.main_bar.finish_with_message(message.to_string());
141    }
142
143    /// Check if progress bars should be shown.
144    ///
145    /// Returns `false` in CI environments or when output is not a TTY.
146    ///
147    /// # Returns
148    ///
149    /// `true` if progress bars should be displayed
150    pub fn should_show() -> bool {
151        console::user_attended() && !super::is_ci()
152    }
153}
154
155impl Drop for BundleProgress {
156    /// Clean up any unfinished progress bars.
157    ///
158    /// Ensures terminal state is properly restored even if progress is interrupted.
159    fn drop(&mut self) {
160        for bar in &self.task_bars {
161            if !bar.is_finished() {
162                bar.finish_and_clear();
163            }
164        }
165        if !self.main_bar.is_finished() {
166            self.main_bar.finish_and_clear();
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_bundle_progress_creation() {
177        // Should not panic
178        let progress = BundleProgress::new(5);
179        assert_eq!(progress.task_bars.len(), 0);
180    }
181
182    #[test]
183    fn test_bundle_progress_add_task() {
184        let mut progress = BundleProgress::new(3);
185        let task1 = progress.add_task("Task 1");
186        let task2 = progress.add_task("Task 2");
187
188        assert_eq!(task1, 0);
189        assert_eq!(task2, 1);
190        assert_eq!(progress.task_bars.len(), 2);
191    }
192
193    #[test]
194    fn test_bundle_progress_update_task() {
195        let mut progress = BundleProgress::new(1);
196        let task = progress.add_task("Initial");
197
198        // Should not panic
199        progress.update_task(task, "Updated");
200        progress.update_task(999, "Invalid task"); // Should handle gracefully
201    }
202
203    #[test]
204    fn test_bundle_progress_finish_task() {
205        let mut progress = BundleProgress::new(2);
206        let task = progress.add_task("Task");
207
208        // Should not panic
209        progress.finish_task(task, "Completed");
210    }
211
212    #[test]
213    fn test_bundle_progress_fail_task() {
214        let mut progress = BundleProgress::new(1);
215        let task = progress.add_task("Task");
216
217        // Should not panic
218        progress.fail_task(task, "Failed");
219    }
220}