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}