1use crate::manifest::Manifest;
52use indicatif::{ProgressBar as IndicatifBar, ProgressStyle as IndicatifStyle};
53use std::sync::{Arc, Mutex};
54use std::time::{Duration, Instant};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum InstallationPhase {
59 SyncingSources,
61 ResolvingDependencies,
63 Installing,
65 InstallingResources,
67 Finalizing,
69}
70
71impl InstallationPhase {
72 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
84struct ActiveWindow {
88 slots: Vec<Option<IndicatifBar>>,
90 counter_bar: Option<IndicatifBar>,
92 max_slots: usize,
94 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#[derive(Clone)]
112pub struct MultiPhaseProgress {
113 multi: Arc<indicatif::MultiProgress>,
115 current_bar: Arc<Mutex<Option<IndicatifBar>>>,
117 enabled: bool,
119 phase_start: Arc<Mutex<Option<Instant>>>,
121 active_window: Arc<Mutex<ActiveWindow>>,
123}
124
125impl MultiPhaseProgress {
126 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 pub fn start_phase(&self, phase: InstallationPhase, message: Option<&str>) {
139 if !self.enabled {
140 return;
141 }
142
143 *self.phase_start.lock().unwrap() = Some(Instant::now());
145
146 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 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 pub fn start_phase_with_progress(&self, phase: InstallationPhase, total: usize) {
174 if !self.enabled {
175 return;
176 }
177
178 *self.phase_start.lock().unwrap() = Some(Instant::now());
180
181 if let Ok(mut guard) = self.current_bar.lock() {
183 *guard = None;
184 }
185
186 let progress_bar = self.multi.add(IndicatifBar::new(total as u64));
188
189 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 *self.current_bar.lock().unwrap() = Some(progress_bar);
200 }
201
202 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 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 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 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 pub fn complete_phase(&self, message: Option<&str>) {
240 if !self.enabled {
241 return;
242 }
243
244 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 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 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 *self.phase_start.lock().unwrap() = Some(Instant::now());
289
290 if let Ok(mut guard) = self.current_bar.lock() {
292 *guard = None;
293 }
294
295 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 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(""); slots.push(Some(slot));
316 }
317
318 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 *self.current_bar.lock().unwrap() = window.counter_bar.clone();
327 }
328
329 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 let base_name = entry.name.trim_start_matches(&format!("{}/", resource_type_str));
343
344 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 let hash_suffix = self.should_show_hash(entry);
353
354 format!("{}/{}: {}@{}{}", tool, resource_type_str, base_name, version, hash_suffix)
355 }
356
357 fn should_show_hash(&self, entry: &crate::lockfile::LockedResource) -> String {
362 let hash = &entry.variant_inputs.hash();
365 if *hash != crate::utils::EMPTY_VARIANT_INPUTS_HASH.as_str() {
366 if hash.len() >= 17 {
368 format!("[{}]", &hash[9..17])
369 } else {
370 String::new()
371 }
372 } else {
373 String::new()
374 }
375 }
376
377 pub fn mark_item_active(&self, display_name: &str, unique_key: &str) {
384 if !self.enabled {
385 return;
386 }
387
388 let Ok(mut window) = self.active_window.try_lock() else {
390 return; };
392
393 for (idx, slot_opt) in window.slots.iter().enumerate() {
397 if let Some(bar) = slot_opt {
398 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 else if window.resource_to_slot.iter().any(|(_, &slot_idx)| slot_idx == idx) {
406 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 break;
413 }
414 }
415 }
416 }
417 }
418 }
419
420 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 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 let Ok(mut window) = self.active_window.try_lock() else {
459 return; };
461
462 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(""); }
467 window.resource_to_slot.remove(unique_key);
468 } else if let Some(display_name) = display_name_fallback {
469 for bar in window.slots.iter().flatten() {
471 let message = bar.message();
472 if message.contains(display_name) {
473 bar.set_message(""); break;
475 }
476 }
477 }
478
479 if let Some(ref counter) = window.counter_bar {
481 counter.set_message(format!("{} ({}/{} complete)", phase_name, completed, total));
482 }
483 }
484
485 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 pub fn complete_phase_with_window(&self, message: Option<&str>) {
517 if !self.enabled {
518 return;
519 }
520
521 let duration = self.phase_start.lock().unwrap().take().map(|start| start.elapsed());
523
524 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 if let Ok(mut guard) = self.current_bar.lock() {
539 *guard = None;
540 }
541
542 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 pub fn calculate_window_size(concurrency: usize) -> usize {
560 concurrency.clamp(5, 10)
562 }
563
564 pub fn suspend<F, R>(&self, f: F) -> R
567 where
568 F: FnOnce() -> R,
569 {
570 self.multi.suspend(f)
571 }
572
573 pub fn clear(&self) {
575 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 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
600pub 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 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 progress.start_phase_with_active_tracking(InstallationPhase::InstallingResources, 10, 5);
655
656 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 progress.mark_resource_active(&resource1);
663 progress.mark_resource_active(&resource2);
664 progress.mark_resource_active(&resource3);
665
666 progress.mark_resource_complete(&resource1, 1, 10);
668
669 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 progress.start_phase_with_active_tracking(InstallationPhase::InstallingResources, 10, 3);
679
680 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 progress.mark_resource_active(&r1);
689 progress.mark_resource_active(&r2);
690 progress.mark_resource_active(&r3);
691 progress.mark_resource_active(&r4); progress.mark_resource_active(&r5); progress.mark_resource_complete(&r1, 1, 10);
696
697 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); }
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 }
720
721 #[test]
722 fn test_multi_phase_progress_new() {
723 let progress = MultiPhaseProgress::new(true);
724
725 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 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 }
760}