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