Skip to main content

rusty_commit/output/
progress.rs

1//! Enhanced progress tracking for Rusty Commit CLI.
2//!
3//! Provides multi-step progress indicators with timing and status tracking.
4
5use std::time::Instant;
6
7use colored::Colorize;
8use indicatif::{ProgressBar, ProgressStyle};
9
10use super::styling::{Color, Palette, Theme};
11
12/// Default progress spinner characters.
13#[allow(dead_code)]
14static SPINNER_CHARS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
15
16/// Step status for multi-step progress.
17#[allow(dead_code)]
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum StepStatus {
20    /// Step is pending (not started).
21    Pending,
22    /// Step is currently in progress.
23    Active,
24    /// Step completed successfully.
25    Completed,
26    /// Step failed.
27    Failed,
28    /// Step was skipped.
29    Skipped,
30}
31
32/// A single step in a multi-step workflow.
33#[allow(dead_code)]
34#[derive(Debug, Clone)]
35pub struct Step {
36    /// The title of this step.
37    title: String,
38    /// Optional detail text (shown when active).
39    detail: Option<String>,
40    /// Current status of this step.
41    status: StepStatus,
42    /// When this step started (for timing).
43    started_at: Option<Instant>,
44    /// How long this step took (when completed).
45    duration_ms: Option<u64>,
46}
47
48#[allow(dead_code)]
49impl Step {
50    /// Create a new pending step.
51    pub fn pending(title: &str) -> Self {
52        Self {
53            title: title.to_string(),
54            detail: None,
55            status: StepStatus::Pending,
56            started_at: None,
57            duration_ms: None,
58        }
59    }
60
61    /// Create a new active step with detail.
62    pub fn active(title: &str, detail: &str) -> Self {
63        Self {
64            title: title.to_string(),
65            detail: Some(detail.to_string()),
66            status: StepStatus::Active,
67            started_at: Some(Instant::now()),
68            duration_ms: None,
69        }
70    }
71
72    /// Mark this step as completed.
73    pub fn completed(mut self) -> Self {
74        self.status = StepStatus::Completed;
75        if let Some(start) = self.started_at {
76            self.duration_ms = Some(start.elapsed().as_millis() as u64);
77        }
78        self
79    }
80
81    /// Mark this step as failed.
82    pub fn failed(mut self) -> Self {
83        self.status = StepStatus::Failed;
84        if let Some(start) = self.started_at {
85            self.duration_ms = Some(start.elapsed().as_millis() as u64);
86        }
87        self
88    }
89
90    /// Set detail text.
91    pub fn with_detail(mut self, detail: &str) -> Self {
92        self.detail = Some(detail.to_string());
93        self
94    }
95
96    /// Get the current status.
97    pub fn status(&self) -> StepStatus {
98        self.status
99    }
100
101    /// Get the title.
102    pub fn title(&self) -> &str {
103        &self.title
104    }
105
106    /// Get the detail text.
107    pub fn detail(&self) -> Option<&str> {
108        self.detail.as_deref()
109    }
110
111    /// Get the duration in milliseconds.
112    pub fn duration_ms(&self) -> Option<u64> {
113        self.duration_ms
114    }
115}
116
117/// Enhanced progress tracker with multi-step support.
118#[allow(dead_code)]
119#[derive(Debug, Clone)]
120pub struct ProgressTracker {
121    /// The underlying progress bar.
122    progress_bar: ProgressBar,
123    /// Theme for styling.
124    theme: Theme,
125    /// Steps in the workflow.
126    steps: Vec<Step>,
127    /// Current step index.
128    current_step: usize,
129    /// Overall start time.
130    started_at: Instant,
131    /// Timing breakdown for each step.
132    timing_breakdown: Vec<(String, u64)>,
133}
134
135#[allow(dead_code)]
136impl ProgressTracker {
137    /// Create a new progress tracker.
138    pub fn new(message: &str) -> Self {
139        let pb = ProgressBar::new_spinner();
140        pb.set_style(
141            ProgressStyle::default_spinner()
142                .template("{spinner:.cyan} {msg}")
143                .unwrap(),
144        );
145        pb.set_message(message.to_string());
146        pb.enable_steady_tick(std::time::Duration::from_millis(100));
147
148        Self {
149            progress_bar: pb,
150            theme: Theme::new(),
151            steps: Vec::new(),
152            current_step: 0,
153            started_at: Instant::now(),
154            timing_breakdown: Vec::new(),
155        }
156    }
157
158    /// Create a new tracker with theme.
159    pub fn with_theme(message: &str, theme: Theme) -> Self {
160        let mut tracker = Self::new(message);
161        tracker.theme = theme;
162        tracker
163    }
164
165    /// Add steps to the tracker.
166    pub fn steps(mut self, steps: &[Step]) -> Self {
167        self.steps = steps.to_vec();
168        self
169    }
170
171    /// Set the current step as active.
172    pub fn set_active(&mut self, index: usize) {
173        if index < self.steps.len() {
174            self.current_step = index;
175            self.steps[index].started_at = Some(Instant::now());
176            self.update_message();
177        }
178    }
179
180    /// Set detail text for current step.
181    pub fn set_detail(&mut self, detail: &str) {
182        if self.current_step < self.steps.len() {
183            self.steps[self.current_step].detail = Some(detail.to_string());
184            self.update_message();
185        }
186    }
187
188    /// Mark the current step as completed.
189    pub fn complete_current(&mut self) {
190        if self.current_step < self.steps.len() {
191            self.steps[self.current_step].status = StepStatus::Completed;
192            if let Some(start) = self.steps[self.current_step].started_at {
193                let duration = start.elapsed().as_millis() as u64;
194                self.timing_breakdown
195                    .push((self.steps[self.current_step].title.clone(), duration));
196            }
197            self.current_step += 1;
198            self.update_message();
199        }
200    }
201
202    /// Mark a specific step as completed.
203    pub fn complete_step(&mut self, index: usize) {
204        if index < self.steps.len() {
205            self.steps[index].status = StepStatus::Completed;
206            if let Some(start) = self.steps[index].started_at {
207                let duration = start.elapsed().as_millis() as u64;
208                self.timing_breakdown
209                    .push((self.steps[index].title.clone(), duration));
210            }
211            self.update_message();
212        }
213    }
214
215    /// Mark the current step as failed.
216    pub fn fail_current(&mut self) {
217        if self.current_step < self.steps.len() {
218            self.steps[self.current_step].status = StepStatus::Failed;
219            self.update_message();
220        }
221    }
222
223    fn update_message(&self) {
224        if self.current_step < self.steps.len() {
225            let step = &self.steps[self.current_step];
226            let msg = match step.status {
227                StepStatus::Active => {
228                    if let Some(detail) = &step.detail {
229                        format!("{} ({})", step.title, detail)
230                    } else {
231                        step.title.clone()
232                    }
233                }
234                _ => step.title.clone(),
235            };
236            self.progress_bar.set_message(msg);
237        }
238    }
239
240    /// Finish with a success message.
241    pub fn finish_with_success(&self, message: &str) {
242        self.progress_bar.finish_with_message(message.to_string());
243    }
244
245    /// Finish with an error message.
246    pub fn finish_with_error(&self, message: &str) {
247        self.progress_bar.finish_with_message(message.to_string());
248    }
249
250    /// Get the timing breakdown.
251    pub fn timing_breakdown(&self) -> &[(String, u64)] {
252        &self.timing_breakdown
253    }
254
255    /// Get total elapsed time in milliseconds.
256    pub fn elapsed_ms(&self) -> u64 {
257        self.started_at.elapsed().as_millis() as u64
258    }
259
260    /// Get formatted elapsed time.
261    pub fn elapsed_formatted(&self) -> String {
262        let ms = self.elapsed_ms();
263        if ms < 1000 {
264            format!("{}ms", ms)
265        } else {
266            format!("{:.1}s", ms as f64 / 1000.0)
267        }
268    }
269
270    /// Get the progress bar for external control.
271    pub fn progress_bar(&self) -> &ProgressBar {
272        &self.progress_bar
273    }
274
275    /// Get mutable progress bar for external control.
276    pub fn progress_bar_mut(&mut self) -> &mut ProgressBar {
277        &mut self.progress_bar
278    }
279}
280
281/// Convenience function to create a simple spinner.
282pub fn spinner(message: &str) -> ProgressBar {
283    let pb = ProgressBar::new_spinner();
284    pb.set_style(
285        ProgressStyle::default_spinner()
286            .template("{spinner:.green} {msg}")
287            .unwrap(),
288    );
289    pb.set_message(message.to_string());
290    pb.enable_steady_tick(std::time::Duration::from_millis(100));
291    pb
292}
293
294/// OAuth wait spinner with consistent styling for authentication flows.
295pub fn oauth_wait_spinner() -> ProgressBar {
296    let pb = ProgressBar::new_spinner();
297    pb.set_style(
298        ProgressStyle::default_spinner()
299            .template("{spinner:.cyan} {msg}")
300            .unwrap()
301            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
302    );
303    pb.set_message("Waiting for authentication...".to_string());
304    pb.enable_steady_tick(std::time::Duration::from_millis(100));
305    pb
306}
307
308/// Create a styled progress bar with custom template.
309#[allow(dead_code)]
310pub fn styled_progress(message: &str, palette: &Palette) -> ProgressBar {
311    let pb = ProgressBar::new_spinner();
312    let template = format!(
313        "{{spinner:.{}}} {{msg}}",
314        match palette.primary {
315            Color::MutedBlue | Color::Cyan => "cyan",
316            Color::Green => "green",
317            Color::Red => "red",
318            Color::Amber => "yellow",
319            Color::Purple => "magenta",
320            _ => "green",
321        }
322    );
323    pb.set_style(
324        ProgressStyle::default_spinner()
325            .template(&template)
326            .unwrap(),
327    );
328    pb.set_message(message.to_string());
329    pb.enable_steady_tick(std::time::Duration::from_millis(100));
330    pb
331}
332
333/// Format timing breakdown as a string.
334#[allow(dead_code)]
335pub fn format_timing_breakdown(breakdown: &[(String, u64)], total_ms: u64) -> String {
336    let mut result = String::new();
337
338    for (name, duration) in breakdown {
339        let duration_str = if *duration < 1000 {
340            format!("{}ms", duration)
341        } else {
342            format!("{:.1}s", *duration as f64 / 1000.0)
343        };
344        result.push_str(&format!("  {} {}\n", name.dimmed(), duration_str.green()));
345    }
346
347    // Add total
348    let total_str = if total_ms < 1000 {
349        format!("{}ms", total_ms)
350    } else {
351        format!("{:.1}s", total_ms as f64 / 1000.0)
352    };
353    result.push_str(&format!("  {} {}", "Total".dimmed(), total_str.green()));
354
355    result
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_step_pending() {
364        let step = Step::pending("Test Step");
365        assert_eq!(step.title(), "Test Step");
366        assert_eq!(step.status(), StepStatus::Pending);
367        assert!(step.detail().is_none());
368    }
369
370    #[test]
371    fn test_step_active() {
372        let step = Step::active("Active Step", "details here");
373        assert_eq!(step.title(), "Active Step");
374        assert_eq!(step.status(), StepStatus::Active);
375        assert_eq!(step.detail(), Some("details here"));
376    }
377
378    #[test]
379    fn test_step_completed() {
380        let step = Step::active("Test", "detail").completed();
381        assert_eq!(step.status(), StepStatus::Completed);
382        assert!(step.duration_ms().is_some());
383    }
384
385    #[test]
386    fn test_format_timing_breakdown_empty() {
387        let result = format_timing_breakdown(&[], 0);
388        assert!(result.contains("Total"));
389    }
390
391    #[test]
392    fn test_format_timing_breakdown_with_items() {
393        let breakdown = vec![("Step1".to_string(), 100u64), ("Step2".to_string(), 500u64)];
394        let result = format_timing_breakdown(&breakdown, 600);
395        assert!(result.contains("Step1"));
396        assert!(result.contains("Step2"));
397        assert!(result.contains("100ms"));
398        assert!(result.contains("500ms"));
399        assert!(result.contains("600ms"));
400    }
401
402    #[test]
403    fn test_format_timing_breakdown_seconds() {
404        let breakdown = vec![("Long Step".to_string(), 2500u64)];
405        let result = format_timing_breakdown(&breakdown, 2500);
406        assert!(result.contains("2.5s"));
407    }
408}