1use crate::manifest::Manifest;
40use indicatif::{ProgressBar as IndicatifBar, ProgressStyle as IndicatifStyle};
41use std::sync::{Arc, Mutex};
42use std::time::{Duration, Instant};
43
44#[deprecated(since = "0.3.0", note = "Use MultiPhaseProgress instead")]
46pub use indicatif::ProgressBar;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum InstallationPhase {
51 SyncingSources,
53 ResolvingDependencies,
55 Installing,
57 InstallingResources,
59 Finalizing,
61}
62
63impl InstallationPhase {
64 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
76struct ActiveWindow {
80 slots: Vec<Option<IndicatifBar>>,
82 counter_bar: Option<IndicatifBar>,
84 max_slots: usize,
86 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#[derive(Clone)]
104pub struct MultiPhaseProgress {
105 multi: Arc<indicatif::MultiProgress>,
107 current_bar: Arc<Mutex<Option<IndicatifBar>>>,
109 enabled: bool,
111 phase_start: Arc<Mutex<Option<Instant>>>,
113 active_window: Arc<Mutex<ActiveWindow>>,
115}
116
117impl MultiPhaseProgress {
118 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 pub fn start_phase(&self, phase: InstallationPhase, message: Option<&str>) {
131 if !self.enabled {
132 return;
133 }
134
135 *self.phase_start.lock().unwrap() = Some(Instant::now());
137
138 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 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 pub fn start_phase_with_progress(&self, phase: InstallationPhase, total: usize) {
166 if !self.enabled {
167 return;
168 }
169
170 *self.phase_start.lock().unwrap() = Some(Instant::now());
172
173 if let Ok(mut guard) = self.current_bar.lock() {
175 *guard = None;
176 }
177
178 let progress_bar = self.multi.add(IndicatifBar::new(total as u64));
180
181 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 *self.current_bar.lock().unwrap() = Some(progress_bar);
192 }
193
194 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 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 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 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 pub fn complete_phase(&self, message: Option<&str>) {
232 if !self.enabled {
233 return;
234 }
235
236 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 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 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 *self.phase_start.lock().unwrap() = Some(Instant::now());
281
282 if let Ok(mut guard) = self.current_bar.lock() {
284 *guard = None;
285 }
286
287 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 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(""); slots.push(Some(slot));
308 }
309
310 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 *self.current_bar.lock().unwrap() = window.counter_bar.clone();
319 }
320
321 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 let base_name = entry.name.trim_start_matches(&format!("{}/", resource_type_str));
335
336 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 let hash_suffix = self.should_show_hash(entry);
345
346 format!("{}/{}: {}@{}{}", tool, resource_type_str, base_name, version, hash_suffix)
347 }
348
349 fn should_show_hash(&self, entry: &crate::lockfile::LockedResource) -> String {
354 let hash = &entry.variant_inputs.hash();
357 if *hash != crate::utils::EMPTY_VARIANT_INPUTS_HASH.as_str() {
358 if hash.len() >= 17 {
360 format!("[{}]", &hash[9..17])
361 } else {
362 String::new()
363 }
364 } else {
365 String::new()
366 }
367 }
368
369 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 let resource_key = format!("{}:{}", entry.name, entry.variant_inputs.hash());
386
387 for (idx, slot_opt) in window.slots.iter().enumerate() {
391 if let Some(bar) = slot_opt {
392 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 else if window.resource_to_slot.iter().any(|(_, &slot_idx)| slot_idx == idx) {
400 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 break;
407 }
408 }
409 }
410 }
411 }
412 }
413
414 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 let resource_key = format!("{}:{}", entry.name, entry.variant_inputs.hash());
435
436 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(""); }
441 window.resource_to_slot.remove(&resource_key);
442 } else {
443 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(""); break;
450 }
451 }
452 }
453
454 if let Some(ref counter) = window.counter_bar {
456 counter.set_message(format!("Installing resources ({}/{} complete)", completed, total));
457 }
458 }
459
460 pub fn complete_phase_with_window(&self, message: Option<&str>) {
463 if !self.enabled {
464 return;
465 }
466
467 let duration = self.phase_start.lock().unwrap().take().map(|start| start.elapsed());
469
470 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 if let Ok(mut guard) = self.current_bar.lock() {
485 *guard = None;
486 }
487
488 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 pub fn calculate_window_size(concurrency: usize) -> usize {
506 concurrency.clamp(5, 10)
508 }
509
510 pub fn suspend<F, R>(&self, f: F) -> R
513 where
514 F: FnOnce() -> R,
515 {
516 self.multi.suspend(f)
517 }
518
519 pub fn clear(&self) {
521 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 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
546pub 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 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 progress.start_phase_with_active_tracking(InstallationPhase::InstallingResources, 10, 5);
599
600 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 progress.mark_resource_active(&resource1);
607 progress.mark_resource_active(&resource2);
608 progress.mark_resource_active(&resource3);
609
610 progress.mark_resource_complete(&resource1, 1, 10);
612
613 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 progress.start_phase_with_active_tracking(InstallationPhase::InstallingResources, 10, 3);
623
624 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 progress.mark_resource_active(&r1);
633 progress.mark_resource_active(&r2);
634 progress.mark_resource_active(&r3);
635 progress.mark_resource_active(&r4); progress.mark_resource_active(&r5); progress.mark_resource_complete(&r1, 1, 10);
640
641 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); }
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 }
664
665 #[test]
666 fn test_multi_phase_progress_new() {
667 let progress = MultiPhaseProgress::new(true);
668
669 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 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 }
704}