agpm_cli/utils/
progress.rs

1//! Progress indicators and user interface utilities
2//!
3//! This module provides a unified progress system for AGPM operations using the
4//! `MultiPhaseProgress` approach. All progress tracking goes through phases to ensure
5//! consistent user experience across different operations.
6//!
7//! # Features
8//!
9//! - **Unified progress**: All operations use `MultiPhaseProgress` for consistency
10//! - **Phase-based tracking**: Installation/update operations broken into logical phases
11//! - **CI/quiet mode support**: Automatically disables in non-interactive environments
12//! - **Thread safety**: Safe to use across async tasks and parallel operations
13//!
14//! # Configuration
15//!
16//! Progress indicators are now controlled via the `MultiPhaseProgress` constructor
17//! parameter rather than environment variables for better thread safety.
18//!
19//! # Examples
20//!
21//! ## Multi-Phase Progress
22//!
23//! ```rust,no_run
24//! use agpm_cli::utils::progress::{MultiPhaseProgress, InstallationPhase};
25//!
26//! let progress = MultiPhaseProgress::new(true);
27//!
28//! // Start syncing phase
29//! progress.start_phase(InstallationPhase::SyncingSources, Some("Fetching repositories"));
30//! // ... do work ...
31//! progress.complete_phase(Some("Synced 3 repositories"));
32//!
33//! // Start resolving phase
34//! progress.start_phase(InstallationPhase::ResolvingDependencies, None);
35//! // ... do work ...
36//! progress.complete_phase(Some("Resolved 25 dependencies"));
37//! ```
38
39use crate::manifest::Manifest;
40use indicatif::{ProgressBar as IndicatifBar, ProgressStyle as IndicatifStyle};
41use std::sync::{Arc, Mutex};
42use std::time::Duration;
43
44// Re-export for deprecated functions - use MultiPhaseProgress instead
45#[deprecated(since = "0.3.0", note = "Use MultiPhaseProgress instead")]
46pub use indicatif::ProgressBar;
47
48/// Represents different phases of the installation process
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum InstallationPhase {
51    /// Syncing source repositories
52    SyncingSources,
53    /// Resolving dependencies and versions
54    ResolvingDependencies,
55    /// Installing resources from resolved dependencies
56    Installing,
57    /// Installing specific resources (used during updates)
58    InstallingResources,
59    /// Updating configuration files and finalizing
60    Finalizing,
61}
62
63impl InstallationPhase {
64    /// Get a human-readable description of the phase
65    pub const fn description(&self) -> &'static str {
66        match self {
67            Self::SyncingSources => "Syncing sources",
68            Self::ResolvingDependencies => "Resolving dependencies",
69            Self::Installing => "Installing resources",
70            Self::InstallingResources => "Installing resources",
71            Self::Finalizing => "Finalizing installation",
72        }
73    }
74
75    /// Get the spinner prefix for this phase
76    pub const fn spinner_prefix(&self) -> &'static str {
77        match self {
78            Self::SyncingSources => "⏳",
79            Self::ResolvingDependencies => "🔍",
80            Self::Installing => "📦",
81            Self::InstallingResources => "📦",
82            Self::Finalizing => "✨",
83        }
84    }
85}
86
87/// Multi-phase progress manager that displays multiple progress bars
88/// with completed phases showing as static messages
89#[derive(Clone)]
90pub struct MultiPhaseProgress {
91    /// `MultiProgress` container from indicatif
92    multi: Arc<indicatif::MultiProgress>,
93    /// Current active spinner/progress bar
94    current_bar: Arc<Mutex<Option<IndicatifBar>>>,
95    /// Whether progress is enabled
96    enabled: bool,
97}
98
99impl MultiPhaseProgress {
100    /// Create a new multi-phase progress manager
101    pub fn new(enabled: bool) -> Self {
102        Self {
103            multi: Arc::new(indicatif::MultiProgress::new()),
104            current_bar: Arc::new(Mutex::new(None)),
105            enabled,
106        }
107    }
108
109    /// Start a new phase with a spinner
110    pub fn start_phase(&self, phase: InstallationPhase, message: Option<&str>) {
111        if !self.enabled {
112            // In non-TTY mode, just print the phase
113            if !self.enabled {
114                return;
115            }
116            let phase_msg = if let Some(msg) = message {
117                format!("{} {} {}", phase.spinner_prefix(), phase.description(), msg)
118            } else {
119                format!("{} {}", phase.spinner_prefix(), phase.description())
120            };
121            println!("{phase_msg}");
122            return;
123        }
124
125        // Don't clear the existing bar - it should already be finished with a message
126        // Just remove our reference to it
127        if let Ok(mut guard) = self.current_bar.lock() {
128            *guard = None;
129        }
130
131        // Create new spinner for this phase
132        let spinner = self.multi.add(IndicatifBar::new_spinner());
133
134        // Format the phase message
135        let phase_msg =
136            format!("{} {} {}", phase.spinner_prefix(), phase.description(), message.unwrap_or(""));
137
138        // Configure spinner style
139        let style = IndicatifStyle::default_spinner()
140            .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ")
141            .template("{spinner} {msg}")
142            .unwrap();
143
144        spinner.set_style(style);
145        spinner.set_message(phase_msg);
146        spinner.enable_steady_tick(Duration::from_millis(100));
147
148        // Store the spinner
149        *self.current_bar.lock().unwrap() = Some(spinner);
150    }
151
152    /// Start a new phase with a progress bar
153    pub fn start_phase_with_progress(&self, phase: InstallationPhase, total: usize) {
154        if !self.enabled {
155            // In non-TTY mode, just print the phase
156            if !self.enabled {
157                return;
158            }
159            println!("{} {} (0/{})", phase.spinner_prefix(), phase.description(), total);
160            return;
161        }
162
163        // Don't clear the existing bar - it should already be finished with a message
164        // Just remove our reference to it
165        if let Ok(mut guard) = self.current_bar.lock() {
166            *guard = None;
167        }
168
169        // Create new progress bar for this phase
170        let progress_bar = self.multi.add(IndicatifBar::new(total as u64));
171
172        // Configure progress bar style with phase prefix
173        let style = IndicatifStyle::default_bar()
174            .template(&format!(
175                "{} {{msg}} [{{bar:40.cyan/blue}}] {{pos}}/{{len}}",
176                phase.spinner_prefix()
177            ))
178            .unwrap()
179            .progress_chars("=>-");
180
181        progress_bar.set_style(style);
182        progress_bar.set_message(phase.description());
183
184        // Store the progress bar
185        *self.current_bar.lock().unwrap() = Some(progress_bar);
186    }
187
188    /// Update the message of the current phase
189    pub fn update_message(&self, message: String) {
190        if let Ok(guard) = self.current_bar.lock()
191            && let Some(ref bar) = *guard
192        {
193            bar.set_message(message);
194        }
195    }
196
197    /// Update the current message for the active phase
198    pub fn update_current_message(&self, message: &str) {
199        if let Ok(guard) = self.current_bar.lock()
200            && let Some(ref bar) = *guard
201        {
202            bar.set_message(message.to_string());
203        }
204    }
205
206    /// Increment progress for progress bars
207    pub fn increment_progress(&self, delta: u64) {
208        if let Ok(guard) = self.current_bar.lock()
209            && let Some(ref bar) = *guard
210        {
211            bar.inc(delta);
212        }
213    }
214
215    /// Set progress position for progress bars
216    pub fn set_progress(&self, pos: usize) {
217        if let Ok(guard) = self.current_bar.lock()
218            && let Some(ref bar) = *guard
219        {
220            bar.set_position(pos as u64);
221        }
222    }
223
224    /// Complete the current phase and show it as a static message
225    pub fn complete_phase(&self, message: Option<&str>) {
226        if !self.enabled {
227            // In non-TTY mode, just print completion
228            if !self.enabled {
229                return;
230            }
231            if let Some(msg) = message {
232                println!("✓ {msg}");
233            }
234            return;
235        }
236
237        // Complete the current bar/spinner with a message and leave it visible
238        if let Ok(mut guard) = self.current_bar.lock()
239            && let Some(bar) = guard.take()
240        {
241            // Disable any animation
242            bar.disable_steady_tick();
243
244            // Set the final message
245            let final_message = if let Some(msg) = message {
246                format!("✓ {msg}")
247            } else {
248                "✓ Phase complete".to_string()
249            };
250
251            // Clear the spinner
252            bar.finish_and_clear();
253
254            // Use suspend to print the completion message outside of the MultiProgress
255            // This ensures it stays visible
256            self.multi.suspend(|| {
257                println!("{final_message}");
258            });
259        }
260    }
261
262    /// Clear all progress displays
263    pub fn clear(&self) {
264        // Clear current bar if any
265        if let Ok(mut guard) = self.current_bar.lock()
266            && let Some(bar) = guard.take()
267        {
268            bar.finish_and_clear();
269        }
270        self.multi.clear().ok();
271    }
272
273    /// Create a subordinate progress bar for detailed progress within a phase
274    pub fn add_progress_bar(&self, total: u64) -> Option<IndicatifBar> {
275        if !self.enabled {
276            return None;
277        }
278
279        let pb = self.multi.add(IndicatifBar::new(total));
280        let style = IndicatifStyle::default_bar()
281            .template("  {msg} [{bar:40.cyan/blue}] {pos}/{len}")
282            .unwrap()
283            .progress_chars("=>-");
284        pb.set_style(style);
285        Some(pb)
286    }
287}
288
289/// Helper function to collect dependency names from a manifest
290pub fn collect_dependency_names(manifest: &Manifest) -> Vec<String> {
291    manifest.all_dependencies().iter().map(|(name, _)| (*name).to_string()).collect()
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_installation_phase_description() {
300        assert_eq!(InstallationPhase::SyncingSources.description(), "Syncing sources");
301        assert_eq!(
302            InstallationPhase::ResolvingDependencies.description(),
303            "Resolving dependencies"
304        );
305        assert_eq!(InstallationPhase::Installing.description(), "Installing resources");
306        assert_eq!(InstallationPhase::InstallingResources.description(), "Installing resources");
307        assert_eq!(InstallationPhase::Finalizing.description(), "Finalizing installation");
308    }
309
310    #[test]
311    fn test_installation_phase_spinner_prefix() {
312        assert_eq!(InstallationPhase::SyncingSources.spinner_prefix(), "⏳");
313        assert_eq!(InstallationPhase::ResolvingDependencies.spinner_prefix(), "🔍");
314        assert_eq!(InstallationPhase::Installing.spinner_prefix(), "📦");
315        assert_eq!(InstallationPhase::InstallingResources.spinner_prefix(), "📦");
316        assert_eq!(InstallationPhase::Finalizing.spinner_prefix(), "✨");
317    }
318
319    #[test]
320    fn test_multi_phase_progress_new() {
321        let progress = MultiPhaseProgress::new(true);
322
323        // Test basic functionality
324        progress.start_phase(InstallationPhase::SyncingSources, Some("test message"));
325        progress.update_current_message("updated message");
326        progress.complete_phase(Some("completed"));
327        progress.clear();
328    }
329
330    #[test]
331    fn test_multi_phase_progress_with_progress_bar() {
332        let progress = MultiPhaseProgress::new(true);
333
334        progress.start_phase_with_progress(InstallationPhase::Installing, 10);
335        progress.increment_progress(5);
336        progress.set_progress(8);
337        progress.complete_phase(Some("Installation completed"));
338    }
339
340    #[test]
341    fn test_multi_phase_progress_disabled() {
342        let progress = MultiPhaseProgress::new(false);
343
344        // These should not panic when disabled
345        progress.start_phase(InstallationPhase::SyncingSources, None);
346        progress.complete_phase(Some("test"));
347        progress.clear();
348    }
349
350    #[test]
351    fn test_collect_dependency_names() {
352        // This test would need a proper Manifest instance to work
353        // For now, just ensure the function compiles and runs
354
355        // Note: This is a minimal test since we'd need to construct a full manifest
356        // In real usage, this function extracts dependency names from the manifest
357    }
358}