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//! # Concurrency Model
15//!
16//! Progress updates use `try_lock()` with early return rather than blocking locks.
17//! This is intentional: **UI updates are best-effort and non-blocking**.
18//!
19//! Under high concurrency, some progress updates may be silently dropped. This is
20//! acceptable because:
21//! - Progress bars are visual feedback only, not correctness-critical
22//! - Blocking on a UI lock would slow down actual work (file I/O, git operations)
23//! - The final completion state is always accurate (operations complete regardless)
24//! - Missing intermediate updates are imperceptible to users at high parallelism
25//!
26//! # Configuration
27//!
28//! Progress indicators are now controlled via the `MultiPhaseProgress` constructor
29//! parameter rather than environment variables for better thread safety.
30//!
31//! # Examples
32//!
33//! ## Multi-Phase Progress
34//!
35//! ```rust,no_run
36//! use agpm_cli::utils::progress::{MultiPhaseProgress, InstallationPhase};
37//!
38//! let progress = MultiPhaseProgress::new(true);
39//!
40//! // Start syncing phase
41//! progress.start_phase(InstallationPhase::SyncingSources, Some("Fetching repositories"));
42//! // ... do work ...
43//! progress.complete_phase(Some("Synced 3 repositories"));
44//!
45//! // Start resolving phase
46//! progress.start_phase(InstallationPhase::ResolvingDependencies, None);
47//! // ... do work ...
48//! progress.complete_phase(Some("Resolved 25 dependencies"));
49//! ```
50
51use crate::manifest::Manifest;
52use indicatif::{ProgressBar as IndicatifBar, ProgressStyle as IndicatifStyle};
53use std::sync::{Arc, Mutex};
54use std::time::{Duration, Instant};
55
56/// Represents different phases of the installation process
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum InstallationPhase {
59    /// Syncing source repositories
60    SyncingSources,
61    /// Resolving dependencies and versions
62    ResolvingDependencies,
63    /// Installing resources from resolved dependencies
64    Installing,
65    /// Installing specific resources (used during updates)
66    InstallingResources,
67    /// Updating configuration files and finalizing
68    Finalizing,
69}
70
71impl InstallationPhase {
72    /// Get a human-readable description of the phase
73    pub const fn description(&self) -> &'static str {
74        match self {
75            Self::SyncingSources => "Syncing sources",
76            Self::ResolvingDependencies => "Resolving dependencies",
77            Self::Installing => "Installing resources",
78            Self::InstallingResources => "Installing resources",
79            Self::Finalizing => "Finalizing installation",
80        }
81    }
82}
83
84/// Manages a fixed-size window of active resources during installation.
85/// This provides real-time visibility into which resources are currently
86/// being processed without unbounded terminal output.
87struct ActiveWindow {
88    /// Fixed number of display slots (typically 5-7)
89    slots: Vec<Option<IndicatifBar>>,
90    /// Counter bar showing overall progress (e.g., "Installing (50/500 complete)")
91    counter_bar: Option<IndicatifBar>,
92    /// Maximum number of slots in the window
93    max_slots: usize,
94    /// Map from resource name to slot index for fast lookup
95    resource_to_slot: std::collections::HashMap<String, usize>,
96}
97
98impl ActiveWindow {
99    fn new(max_slots: usize) -> Self {
100        Self {
101            slots: Vec::with_capacity(max_slots),
102            counter_bar: None,
103            max_slots,
104            resource_to_slot: std::collections::HashMap::new(),
105        }
106    }
107}
108
109/// Multi-phase progress manager that displays multiple progress bars
110/// with completed phases showing as static messages
111#[derive(Clone)]
112pub struct MultiPhaseProgress {
113    /// `MultiProgress` container from indicatif
114    multi: Arc<indicatif::MultiProgress>,
115    /// Current active spinner/progress bar
116    current_bar: Arc<Mutex<Option<IndicatifBar>>>,
117    /// Whether progress is enabled
118    enabled: bool,
119    /// Phase start time for timing calculations
120    phase_start: Arc<Mutex<Option<Instant>>>,
121    /// Active window for showing real-time resource processing
122    active_window: Arc<Mutex<ActiveWindow>>,
123}
124
125impl MultiPhaseProgress {
126    /// Create a new multi-phase progress manager
127    pub fn new(enabled: bool) -> Self {
128        Self {
129            multi: Arc::new(indicatif::MultiProgress::new()),
130            current_bar: Arc::new(Mutex::new(None)),
131            enabled,
132            phase_start: Arc::new(Mutex::new(None)),
133            active_window: Arc::new(Mutex::new(ActiveWindow::new(7))),
134        }
135    }
136
137    /// Start a new phase with a spinner
138    pub fn start_phase(&self, phase: InstallationPhase, message: Option<&str>) {
139        if !self.enabled {
140            return;
141        }
142
143        // Store phase start time
144        *self.phase_start.lock().unwrap() = Some(Instant::now());
145
146        // Remove reference to old bar
147        if let Ok(mut guard) = self.current_bar.lock() {
148            *guard = None;
149        }
150
151        let spinner = self.multi.add(IndicatifBar::new_spinner());
152
153        // Format: "Syncing sources" or "Syncing sources (additional info)"
154        let phase_msg = if let Some(msg) = message {
155            format!("{} {}", phase.description(), msg)
156        } else {
157            phase.description().to_string()
158        };
159
160        let style = IndicatifStyle::default_spinner()
161            .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ")
162            .template("{spinner} {msg}")
163            .unwrap();
164
165        spinner.set_style(style);
166        spinner.set_message(phase_msg);
167        spinner.enable_steady_tick(Duration::from_millis(100));
168
169        *self.current_bar.lock().unwrap() = Some(spinner);
170    }
171
172    /// Start a new phase with a progress bar
173    pub fn start_phase_with_progress(&self, phase: InstallationPhase, total: usize) {
174        if !self.enabled {
175            return;
176        }
177
178        // Store phase start time
179        *self.phase_start.lock().unwrap() = Some(Instant::now());
180
181        // Remove reference to old bar
182        if let Ok(mut guard) = self.current_bar.lock() {
183            *guard = None;
184        }
185
186        // Create new progress bar for this phase
187        let progress_bar = self.multi.add(IndicatifBar::new(total as u64));
188
189        // Configure progress bar style
190        let style = IndicatifStyle::default_bar()
191            .template("{msg} [{bar:40.cyan/blue}] {pos}/{len}")
192            .unwrap()
193            .progress_chars("=>-");
194
195        progress_bar.set_style(style);
196        progress_bar.set_message(phase.description());
197
198        // Store the progress bar
199        *self.current_bar.lock().unwrap() = Some(progress_bar);
200    }
201
202    /// Update the message of the current phase
203    pub fn update_message(&self, message: String) {
204        if let Ok(guard) = self.current_bar.lock()
205            && let Some(ref bar) = *guard
206        {
207            bar.set_message(message);
208        }
209    }
210
211    /// Update the current message for the active phase
212    pub fn update_current_message(&self, message: &str) {
213        if let Ok(guard) = self.current_bar.lock()
214            && let Some(ref bar) = *guard
215        {
216            bar.set_message(message.to_string());
217        }
218    }
219
220    /// Increment progress for progress bars
221    pub fn increment_progress(&self, delta: u64) {
222        if let Ok(guard) = self.current_bar.lock()
223            && let Some(ref bar) = *guard
224        {
225            bar.inc(delta);
226        }
227    }
228
229    /// Set progress position for progress bars
230    pub fn set_progress(&self, pos: usize) {
231        if let Ok(guard) = self.current_bar.lock()
232            && let Some(ref bar) = *guard
233        {
234            bar.set_position(pos as u64);
235        }
236    }
237
238    /// Complete the current phase and show it as a static message
239    pub fn complete_phase(&self, message: Option<&str>) {
240        if !self.enabled {
241            return;
242        }
243
244        // Calculate duration
245        let duration = self.phase_start.lock().unwrap().take().map(|start| start.elapsed());
246
247        if let Ok(mut guard) = self.current_bar.lock() {
248            if let Some(bar) = guard.take() {
249                bar.disable_steady_tick();
250                bar.finish_and_clear();
251
252                // Format completion message with timing
253                let final_message = match (message, duration) {
254                    (Some(msg), Some(d)) => {
255                        format!("✓ {} ({:.1}s)", msg, d.as_secs_f64())
256                    }
257                    (Some(msg), None) => format!("✓ {}", msg),
258                    (None, Some(d)) => format!("✓ Complete ({:.1}s)", d.as_secs_f64()),
259                    (None, None) => "✓ Complete".to_string(),
260                };
261
262                self.multi.suspend(|| {
263                    println!("{}", final_message);
264                });
265            }
266        }
267    }
268
269    /// Start a phase with active resource tracking window.
270    /// This displays a fixed-size window showing which resources are currently
271    /// being processed, along with a counter showing overall progress.
272    ///
273    /// # Arguments
274    /// * `phase` - The installation phase to start
275    /// * `total` - Total number of resources to install
276    /// * `window_size` - Number of slots in the active window (typically 5-7)
277    pub fn start_phase_with_active_tracking(
278        &self,
279        phase: InstallationPhase,
280        total: usize,
281        window_size: usize,
282    ) {
283        if !self.enabled {
284            return;
285        }
286
287        // Store phase start time
288        *self.phase_start.lock().unwrap() = Some(Instant::now());
289
290        // Clear previous bar
291        if let Ok(mut guard) = self.current_bar.lock() {
292            *guard = None;
293        }
294
295        // Create counter bar at top showing overall progress
296        let counter_bar = self.multi.add(IndicatifBar::new(total as u64));
297        let style = IndicatifStyle::default_spinner()
298            .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ")
299            .template("{spinner} {msg}")
300            .unwrap();
301        counter_bar.set_style(style);
302        counter_bar.set_message(format!("{} (0/{} complete)", phase.description(), total));
303        counter_bar.enable_steady_tick(Duration::from_millis(100));
304
305        // Create fixed slots below for active resources
306        let mut slots = Vec::with_capacity(window_size);
307        for _ in 0..window_size {
308            let slot = self.multi.add(IndicatifBar::new_spinner());
309            let slot_style = IndicatifStyle::default_spinner()
310                .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ")
311                .template("  {msg}")
312                .unwrap();
313            slot.set_style(slot_style);
314            slot.set_message(""); // Empty initially
315            slots.push(Some(slot));
316        }
317
318        // Store in active window
319        let mut window = self.active_window.lock().unwrap();
320        window.counter_bar = Some(counter_bar);
321        window.slots = slots;
322        window.max_slots = window_size;
323        window.resource_to_slot.clear();
324
325        // Store counter bar as current bar
326        *self.current_bar.lock().unwrap() = window.counter_bar.clone();
327    }
328
329    /// Format a resource name with tool, type, version, and hash information.
330    ///
331    /// Format: {tool}/{type}: {name}@{version}[{hash}]
332    /// - Hash only shown for non-default configurations
333    /// - "local" shown for version when source is None
334    ///
335    /// # Arguments
336    /// * `entry` - The locked resource entry with full metadata
337    fn format_resource_display_name(&self, entry: &crate::lockfile::LockedResource) -> String {
338        let tool = entry.tool.as_deref().unwrap_or("claude-code");
339        let resource_type_str = entry.resource_type.to_string();
340
341        // Extract the base name without the type prefix
342        let base_name = entry.name.trim_start_matches(&format!("{}/", resource_type_str));
343
344        // Determine version or "local" for local resources
345        let version = if entry.source.is_none() {
346            "local".to_string()
347        } else {
348            entry.version.clone().unwrap_or_else(|| "unknown".to_string())
349        };
350
351        // Determine if we should show the hash (only for non-default configurations)
352        let hash_suffix = self.should_show_hash(entry);
353
354        format!("{}/{}: {}@{}{}", tool, resource_type_str, base_name, version, hash_suffix)
355    }
356
357    /// Determine if hash should be displayed based on whether configuration is non-default.
358    ///
359    /// # Arguments
360    /// * `entry` - The locked resource entry
361    fn should_show_hash(&self, entry: &crate::lockfile::LockedResource) -> String {
362        // Show hash only for resources with non-default variant_inputs
363        // Compare against the static EMPTY_VARIANT_INPUTS_HASH to detect default configuration
364        let hash = &entry.variant_inputs.hash();
365        if *hash != crate::utils::EMPTY_VARIANT_INPUTS_HASH.as_str() {
366            // Extract 8 characters from the hash (skip "sha256:" prefix)
367            if hash.len() >= 17 {
368                format!("[{}]", &hash[9..17])
369            } else {
370                String::new()
371            }
372        } else {
373            String::new()
374        }
375    }
376
377    /// Mark an item as actively being processed (generic version).
378    /// This adds the item to the first available slot in the active window.
379    ///
380    /// # Arguments
381    /// * `display_name` - The formatted display name for the item
382    /// * `unique_key` - A unique key to track this specific item (for deduplication)
383    pub fn mark_item_active(&self, display_name: &str, unique_key: &str) {
384        if !self.enabled {
385            return;
386        }
387
388        // Use try_lock to avoid blocking async executors - UI updates are best-effort
389        let Ok(mut window) = self.active_window.try_lock() else {
390            return; // Skip this update rather than block
391        };
392
393        // Find first available slot:
394        // 1. Look for completely empty slots (no message)
395        // 2. Look for slots assigned to the same exact item (already being processed)
396        for (idx, slot_opt) in window.slots.iter().enumerate() {
397            if let Some(bar) = slot_opt {
398                // Check if slot is empty (empty message or just whitespace)
399                if bar.message().trim().is_empty() {
400                    bar.set_message(format!("→ {}", display_name));
401                    window.resource_to_slot.insert(unique_key.to_string(), idx);
402                    break;
403                }
404                // Check if this slot is already showing the exact same item
405                else if window.resource_to_slot.iter().any(|(_, &slot_idx)| slot_idx == idx) {
406                    // This slot is already assigned to some item, check if it's the same one
407                    if let Some((existing_key, _)) =
408                        window.resource_to_slot.iter().find(|&(_, &slot_idx)| slot_idx == idx)
409                    {
410                        if existing_key == unique_key {
411                            // Already showing this item, no need to do anything
412                            break;
413                        }
414                    }
415                }
416            }
417        }
418    }
419
420    /// Mark a resource as actively being processed.
421    /// This adds the resource to the first available slot in the active window.
422    ///
423    /// # Arguments
424    /// * `entry` - The locked resource entry with full metadata
425    pub fn mark_resource_active(&self, entry: &crate::lockfile::LockedResource) {
426        if !self.enabled {
427            return;
428        }
429
430        let display_name = self.format_resource_display_name(entry);
431        let resource_key = format!("{}:{}", entry.name, entry.variant_inputs.hash());
432
433        self.mark_item_active(&display_name, &resource_key);
434    }
435
436    /// Mark an item as complete and update progress counter (generic version).
437    /// This clears the item from its slot and updates the overall counter.
438    ///
439    /// # Arguments
440    /// * `unique_key` - The unique key for the item that was completed
441    /// * `display_name_fallback` - Optional display name for fallback search
442    /// * `completed` - Number of items completed so far
443    /// * `total` - Total number of items to process
444    /// * `phase_name` - Name of the phase for the counter message (e.g., "Installing resources", "Resolving dependencies")
445    pub fn mark_item_complete(
446        &self,
447        unique_key: &str,
448        display_name_fallback: Option<&str>,
449        completed: usize,
450        total: usize,
451        phase_name: &str,
452    ) {
453        if !self.enabled {
454            return;
455        }
456
457        // Use try_lock to avoid blocking async executors - UI updates are best-effort
458        let Ok(mut window) = self.active_window.try_lock() else {
459            return; // Skip this update rather than block
460        };
461
462        // Find and clear the slot using the unique key from hashmap
463        if let Some(&slot_idx) = window.resource_to_slot.get(unique_key) {
464            if let Some(Some(bar)) = window.slots.get(slot_idx) {
465                bar.set_message(""); // Clear slot
466            }
467            window.resource_to_slot.remove(unique_key);
468        } else if let Some(display_name) = display_name_fallback {
469            // Fallback: search all slots for matching display name
470            for bar in window.slots.iter().flatten() {
471                let message = bar.message();
472                if message.contains(display_name) {
473                    bar.set_message(""); // Clear slot
474                    break;
475                }
476            }
477        }
478
479        // Update counter bar
480        if let Some(ref counter) = window.counter_bar {
481            counter.set_message(format!("{} ({}/{} complete)", phase_name, completed, total));
482        }
483    }
484
485    /// Mark a resource as complete and update progress counter.
486    /// This clears the resource from its slot and updates the overall counter.
487    ///
488    /// # Arguments
489    /// * `entry` - The locked resource entry that was completed
490    /// * `completed` - Number of resources completed so far
491    /// * `total` - Total number of resources to install
492    pub fn mark_resource_complete(
493        &self,
494        entry: &crate::lockfile::LockedResource,
495        completed: usize,
496        total: usize,
497    ) {
498        if !self.enabled {
499            return;
500        }
501
502        let resource_key = format!("{}:{}", entry.name, entry.variant_inputs.hash());
503        let display_name = self.format_resource_display_name(entry);
504
505        self.mark_item_complete(
506            &resource_key,
507            Some(&display_name),
508            completed,
509            total,
510            "Installing resources",
511        );
512    }
513
514    /// Complete phase with active window, showing final summary.
515    /// This is similar to complete_phase but also clears the active window.
516    pub fn complete_phase_with_window(&self, message: Option<&str>) {
517        if !self.enabled {
518            return;
519        }
520
521        // Calculate duration
522        let duration = self.phase_start.lock().unwrap().take().map(|start| start.elapsed());
523
524        // Clear active window slots
525        let mut window = self.active_window.lock().unwrap();
526        for slot in window.slots.iter_mut() {
527            if let Some(bar) = slot.take() {
528                bar.finish_and_clear();
529            }
530        }
531        if let Some(counter) = window.counter_bar.take() {
532            counter.disable_steady_tick();
533            counter.finish_and_clear();
534        }
535        window.resource_to_slot.clear();
536
537        // Clear current bar reference
538        if let Ok(mut guard) = self.current_bar.lock() {
539            *guard = None;
540        }
541
542        // Format completion message with timing
543        let final_message = match (message, duration) {
544            (Some(msg), Some(d)) => {
545                format!("✓ {} ({:.1}s)", msg, d.as_secs_f64())
546            }
547            (Some(msg), None) => format!("✓ {}", msg),
548            (None, Some(d)) => format!("✓ Complete ({:.1}s)", d.as_secs_f64()),
549            (None, None) => "✓ Complete".to_string(),
550        };
551
552        self.multi.suspend(|| {
553            println!("{}", final_message);
554        });
555    }
556
557    /// Calculate optimal window size based on concurrency and terminal constraints.
558    /// Returns a size between 3 and 10, with 7 as a reasonable default.
559    pub fn calculate_window_size(concurrency: usize) -> usize {
560        // Use concurrency as a guide, but cap it for readability
561        concurrency.clamp(5, 10)
562    }
563
564    /// Suspend progress display temporarily to execute a closure.
565    /// This is useful for printing output that should appear outside the progress display.
566    pub fn suspend<F, R>(&self, f: F) -> R
567    where
568        F: FnOnce() -> R,
569    {
570        self.multi.suspend(f)
571    }
572
573    /// Clear all progress displays
574    pub fn clear(&self) {
575        // Clear current bar if any
576        if let Ok(mut guard) = self.current_bar.lock()
577            && let Some(bar) = guard.take()
578        {
579            bar.finish_and_clear();
580        }
581        self.multi.clear().ok();
582    }
583
584    /// Create a subordinate progress bar for detailed progress within a phase
585    pub fn add_progress_bar(&self, total: u64) -> Option<IndicatifBar> {
586        if !self.enabled {
587            return None;
588        }
589
590        let pb = self.multi.add(IndicatifBar::new(total));
591        let style = IndicatifStyle::default_bar()
592            .template("  {msg} [{bar:40.cyan/blue}] {pos}/{len}")
593            .unwrap()
594            .progress_chars("=>-");
595        pb.set_style(style);
596        Some(pb)
597    }
598}
599
600/// Helper function to collect dependency names from a manifest
601pub fn collect_dependency_names(manifest: &Manifest) -> Vec<String> {
602    manifest.all_dependencies().iter().map(|(name, _)| (*name).to_string()).collect()
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use crate::core::ResourceType;
609    use crate::lockfile::LockedResource;
610    use crate::resolver::lockfile_builder::VariantInputs;
611    use std::str::FromStr;
612
613    /// Helper function to create test LockedResource entries
614    fn create_test_locked_resource(name: &str, resource_type: &str) -> LockedResource {
615        LockedResource {
616            name: name.to_string(),
617            manifest_alias: None,
618            source: Some("test".to_string()),
619            url: Some("https://example.com".to_string()),
620            version: Some("v1.0.0".to_string()),
621            path: format!("{}.md", name),
622            resolved_commit: Some("abc123def456".to_string()),
623            resource_type: ResourceType::from_str(resource_type).unwrap_or(ResourceType::Agent),
624            tool: Some("claude-code".to_string()),
625            installed_at: format!(".claude/{}/{}.md", resource_type, name),
626            checksum: "sha256:test123".to_string(),
627            context_checksum: Some("sha256:context456".to_string()),
628            variant_inputs: VariantInputs::default(),
629            is_private: false,
630            dependencies: vec![],
631            applied_patches: std::collections::BTreeMap::new(),
632            install: Some(true),
633            approximate_token_count: None,
634        }
635    }
636
637    #[test]
638    fn test_installation_phase_description() {
639        assert_eq!(InstallationPhase::SyncingSources.description(), "Syncing sources");
640        assert_eq!(
641            InstallationPhase::ResolvingDependencies.description(),
642            "Resolving dependencies"
643        );
644        assert_eq!(InstallationPhase::Installing.description(), "Installing resources");
645        assert_eq!(InstallationPhase::InstallingResources.description(), "Installing resources");
646        assert_eq!(InstallationPhase::Finalizing.description(), "Finalizing installation");
647    }
648
649    #[test]
650    fn test_active_window_basic() {
651        let progress = MultiPhaseProgress::new(true);
652
653        // Start tracking
654        progress.start_phase_with_active_tracking(InstallationPhase::InstallingResources, 10, 5);
655
656        // Create mock LockedResource entries for testing
657        let resource1 = create_test_locked_resource("resource1", "agents");
658        let resource2 = create_test_locked_resource("resource2", "agents");
659        let resource3 = create_test_locked_resource("resource3", "agents");
660
661        // Mark resources active
662        progress.mark_resource_active(&resource1);
663        progress.mark_resource_active(&resource2);
664        progress.mark_resource_active(&resource3);
665
666        // Mark one complete
667        progress.mark_resource_complete(&resource1, 1, 10);
668
669        // Complete phase
670        progress.complete_phase_with_window(Some("Installed 10 resources"));
671    }
672
673    #[test]
674    fn test_active_window_overflow() {
675        let progress = MultiPhaseProgress::new(true);
676
677        // Start with 3 slots
678        progress.start_phase_with_active_tracking(InstallationPhase::InstallingResources, 10, 3);
679
680        // Create mock LockedResource entries for testing
681        let r1 = create_test_locked_resource("r1", "agents");
682        let r2 = create_test_locked_resource("r2", "agents");
683        let r3 = create_test_locked_resource("r3", "agents");
684        let r4 = create_test_locked_resource("r4", "agents");
685        let r5 = create_test_locked_resource("r5", "agents");
686
687        // Try to add 5 resources (should fill 3 slots, other 2 wait)
688        progress.mark_resource_active(&r1);
689        progress.mark_resource_active(&r2);
690        progress.mark_resource_active(&r3);
691        progress.mark_resource_active(&r4); // Won't show until slot clears
692        progress.mark_resource_active(&r5); // Won't show until slot clears
693
694        // Complete one to free slot
695        progress.mark_resource_complete(&r1, 1, 10);
696
697        // Now r4 or r5 can be shown (depends on timing)
698        progress.mark_resource_active(&r4);
699    }
700
701    #[test]
702    fn test_calculate_window_size() {
703        assert_eq!(MultiPhaseProgress::calculate_window_size(1), 5);
704        assert_eq!(MultiPhaseProgress::calculate_window_size(5), 5);
705        assert_eq!(MultiPhaseProgress::calculate_window_size(7), 7);
706        assert_eq!(MultiPhaseProgress::calculate_window_size(10), 10);
707        assert_eq!(MultiPhaseProgress::calculate_window_size(50), 10); // Capped
708    }
709
710    #[test]
711    fn test_phase_timing() {
712        let progress = MultiPhaseProgress::new(true);
713
714        progress.start_phase(InstallationPhase::SyncingSources, None);
715        std::thread::sleep(Duration::from_millis(100));
716        progress.complete_phase(Some("Sources synced"));
717
718        // Timing should be approximately 0.1s (verify via output inspection)
719    }
720
721    #[test]
722    fn test_multi_phase_progress_new() {
723        let progress = MultiPhaseProgress::new(true);
724
725        // Test basic functionality
726        progress.start_phase(InstallationPhase::SyncingSources, Some("test message"));
727        progress.update_current_message("updated message");
728        progress.complete_phase(Some("completed"));
729        progress.clear();
730    }
731
732    #[test]
733    fn test_multi_phase_progress_with_progress_bar() {
734        let progress = MultiPhaseProgress::new(true);
735
736        progress.start_phase_with_progress(InstallationPhase::Installing, 10);
737        progress.increment_progress(5);
738        progress.set_progress(8);
739        progress.complete_phase(Some("Installation completed"));
740    }
741
742    #[test]
743    fn test_multi_phase_progress_disabled() {
744        let progress = MultiPhaseProgress::new(false);
745
746        // These should not panic when disabled
747        progress.start_phase(InstallationPhase::SyncingSources, None);
748        progress.complete_phase(Some("test"));
749        progress.clear();
750    }
751
752    #[test]
753    fn test_collect_dependency_names() {
754        // This test would need a proper Manifest instance to work
755        // For now, just ensure the function compiles and runs
756
757        // Note: This is a minimal test since we'd need to construct a full manifest
758        // In real usage, this function extracts dependency names from the manifest
759    }
760}