use crate::manifest::Manifest;
use indicatif::{ProgressBar as IndicatifBar, ProgressStyle as IndicatifStyle};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallationPhase {
SyncingSources,
ResolvingDependencies,
Installing,
InstallingResources,
Finalizing,
}
impl InstallationPhase {
pub const fn description(&self) -> &'static str {
match self {
Self::SyncingSources => "Syncing sources",
Self::ResolvingDependencies => "Resolving dependencies",
Self::Installing => "Installing resources",
Self::InstallingResources => "Installing resources",
Self::Finalizing => "Finalizing installation",
}
}
}
struct ActiveWindow {
slots: Vec<Option<IndicatifBar>>,
counter_bar: Option<IndicatifBar>,
max_slots: usize,
resource_to_slot: std::collections::HashMap<String, usize>,
}
impl ActiveWindow {
fn new(max_slots: usize) -> Self {
Self {
slots: Vec::with_capacity(max_slots),
counter_bar: None,
max_slots,
resource_to_slot: std::collections::HashMap::new(),
}
}
}
#[derive(Clone)]
pub struct MultiPhaseProgress {
multi: Arc<indicatif::MultiProgress>,
current_bar: Arc<Mutex<Option<IndicatifBar>>>,
enabled: bool,
phase_start: Arc<Mutex<Option<Instant>>>,
active_window: Arc<Mutex<ActiveWindow>>,
}
impl MultiPhaseProgress {
pub fn new(enabled: bool) -> Self {
Self {
multi: Arc::new(indicatif::MultiProgress::new()),
current_bar: Arc::new(Mutex::new(None)),
enabled,
phase_start: Arc::new(Mutex::new(None)),
active_window: Arc::new(Mutex::new(ActiveWindow::new(7))),
}
}
pub fn start_phase(&self, phase: InstallationPhase, message: Option<&str>) {
if !self.enabled {
return;
}
*self.phase_start.lock().unwrap() = Some(Instant::now());
if let Ok(mut guard) = self.current_bar.lock() {
*guard = None;
}
let spinner = self.multi.add(IndicatifBar::new_spinner());
let phase_msg = if let Some(msg) = message {
format!("{} {}", phase.description(), msg)
} else {
phase.description().to_string()
};
let style = IndicatifStyle::default_spinner()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ")
.template("{spinner} {msg}")
.unwrap();
spinner.set_style(style);
spinner.set_message(phase_msg);
spinner.enable_steady_tick(Duration::from_millis(100));
*self.current_bar.lock().unwrap() = Some(spinner);
}
pub fn start_phase_with_progress(&self, phase: InstallationPhase, total: usize) {
if !self.enabled {
return;
}
*self.phase_start.lock().unwrap() = Some(Instant::now());
if let Ok(mut guard) = self.current_bar.lock() {
*guard = None;
}
let progress_bar = self.multi.add(IndicatifBar::new(total as u64));
let style = IndicatifStyle::default_bar()
.template("{msg} [{bar:40.cyan/blue}] {pos}/{len}")
.unwrap()
.progress_chars("=>-");
progress_bar.set_style(style);
progress_bar.set_message(phase.description());
*self.current_bar.lock().unwrap() = Some(progress_bar);
}
pub fn update_message(&self, message: String) {
if let Ok(guard) = self.current_bar.lock()
&& let Some(ref bar) = *guard
{
bar.set_message(message);
}
}
pub fn update_current_message(&self, message: &str) {
if let Ok(guard) = self.current_bar.lock()
&& let Some(ref bar) = *guard
{
bar.set_message(message.to_string());
}
}
pub fn increment_progress(&self, delta: u64) {
if let Ok(guard) = self.current_bar.lock()
&& let Some(ref bar) = *guard
{
bar.inc(delta);
}
}
pub fn set_progress(&self, pos: usize) {
if let Ok(guard) = self.current_bar.lock()
&& let Some(ref bar) = *guard
{
bar.set_position(pos as u64);
}
}
pub fn complete_phase(&self, message: Option<&str>) {
if !self.enabled {
return;
}
let duration = self.phase_start.lock().unwrap().take().map(|start| start.elapsed());
if let Ok(mut guard) = self.current_bar.lock() {
if let Some(bar) = guard.take() {
bar.disable_steady_tick();
bar.finish_and_clear();
let final_message = match (message, duration) {
(Some(msg), Some(d)) => {
format!("✓ {} ({:.1}s)", msg, d.as_secs_f64())
}
(Some(msg), None) => format!("✓ {}", msg),
(None, Some(d)) => format!("✓ Complete ({:.1}s)", d.as_secs_f64()),
(None, None) => "✓ Complete".to_string(),
};
self.multi.suspend(|| {
println!("{}", final_message);
});
}
}
}
pub fn start_phase_with_active_tracking(
&self,
phase: InstallationPhase,
total: usize,
window_size: usize,
) {
if !self.enabled {
return;
}
*self.phase_start.lock().unwrap() = Some(Instant::now());
if let Ok(mut guard) = self.current_bar.lock() {
*guard = None;
}
let counter_bar = self.multi.add(IndicatifBar::new(total as u64));
let style = IndicatifStyle::default_spinner()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ")
.template("{spinner} {msg}")
.unwrap();
counter_bar.set_style(style);
counter_bar.set_message(format!("{} (0/{} complete)", phase.description(), total));
counter_bar.enable_steady_tick(Duration::from_millis(100));
let mut slots = Vec::with_capacity(window_size);
for _ in 0..window_size {
let slot = self.multi.add(IndicatifBar::new_spinner());
let slot_style = IndicatifStyle::default_spinner()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ")
.template(" {msg}")
.unwrap();
slot.set_style(slot_style);
slot.set_message(""); slots.push(Some(slot));
}
let mut window = self.active_window.lock().unwrap();
window.counter_bar = Some(counter_bar);
window.slots = slots;
window.max_slots = window_size;
window.resource_to_slot.clear();
*self.current_bar.lock().unwrap() = window.counter_bar.clone();
}
fn format_resource_display_name(&self, entry: &crate::lockfile::LockedResource) -> String {
let tool = entry.tool.as_deref().unwrap_or("claude-code");
let resource_type_str = entry.resource_type.to_string();
let base_name = entry.name.trim_start_matches(&format!("{}/", resource_type_str));
let version = if entry.source.is_none() {
"local".to_string()
} else {
entry.version.clone().unwrap_or_else(|| "unknown".to_string())
};
let hash_suffix = self.should_show_hash(entry);
format!("{}/{}: {}@{}{}", tool, resource_type_str, base_name, version, hash_suffix)
}
fn should_show_hash(&self, entry: &crate::lockfile::LockedResource) -> String {
let hash = &entry.variant_inputs.hash();
if *hash != crate::utils::EMPTY_VARIANT_INPUTS_HASH.as_str() {
if hash.len() >= 17 {
format!("[{}]", &hash[9..17])
} else {
String::new()
}
} else {
String::new()
}
}
pub fn mark_item_active(&self, display_name: &str, unique_key: &str) {
if !self.enabled {
return;
}
let Ok(mut window) = self.active_window.try_lock() else {
return; };
for (idx, slot_opt) in window.slots.iter().enumerate() {
if let Some(bar) = slot_opt {
if bar.message().trim().is_empty() {
bar.set_message(format!("→ {}", display_name));
window.resource_to_slot.insert(unique_key.to_string(), idx);
break;
}
else if window.resource_to_slot.iter().any(|(_, &slot_idx)| slot_idx == idx) {
if let Some((existing_key, _)) =
window.resource_to_slot.iter().find(|&(_, &slot_idx)| slot_idx == idx)
{
if existing_key == unique_key {
break;
}
}
}
}
}
}
pub fn mark_resource_active(&self, entry: &crate::lockfile::LockedResource) {
if !self.enabled {
return;
}
let display_name = self.format_resource_display_name(entry);
let resource_key = format!("{}:{}", entry.name, entry.variant_inputs.hash());
self.mark_item_active(&display_name, &resource_key);
}
pub fn mark_item_complete(
&self,
unique_key: &str,
display_name_fallback: Option<&str>,
completed: usize,
total: usize,
phase_name: &str,
) {
if !self.enabled {
return;
}
let Ok(mut window) = self.active_window.try_lock() else {
return; };
if let Some(&slot_idx) = window.resource_to_slot.get(unique_key) {
if let Some(Some(bar)) = window.slots.get(slot_idx) {
bar.set_message(""); }
window.resource_to_slot.remove(unique_key);
} else if let Some(display_name) = display_name_fallback {
for bar in window.slots.iter().flatten() {
let message = bar.message();
if message.contains(display_name) {
bar.set_message(""); break;
}
}
}
if let Some(ref counter) = window.counter_bar {
counter.set_message(format!("{} ({}/{} complete)", phase_name, completed, total));
}
}
pub fn mark_resource_complete(
&self,
entry: &crate::lockfile::LockedResource,
completed: usize,
total: usize,
) {
if !self.enabled {
return;
}
let resource_key = format!("{}:{}", entry.name, entry.variant_inputs.hash());
let display_name = self.format_resource_display_name(entry);
self.mark_item_complete(
&resource_key,
Some(&display_name),
completed,
total,
"Installing resources",
);
}
pub fn complete_phase_with_window(&self, message: Option<&str>) {
if !self.enabled {
return;
}
let duration = self.phase_start.lock().unwrap().take().map(|start| start.elapsed());
let mut window = self.active_window.lock().unwrap();
for slot in window.slots.iter_mut() {
if let Some(bar) = slot.take() {
bar.finish_and_clear();
}
}
if let Some(counter) = window.counter_bar.take() {
counter.disable_steady_tick();
counter.finish_and_clear();
}
window.resource_to_slot.clear();
if let Ok(mut guard) = self.current_bar.lock() {
*guard = None;
}
let final_message = match (message, duration) {
(Some(msg), Some(d)) => {
format!("✓ {} ({:.1}s)", msg, d.as_secs_f64())
}
(Some(msg), None) => format!("✓ {}", msg),
(None, Some(d)) => format!("✓ Complete ({:.1}s)", d.as_secs_f64()),
(None, None) => "✓ Complete".to_string(),
};
self.multi.suspend(|| {
println!("{}", final_message);
});
}
pub fn calculate_window_size(concurrency: usize) -> usize {
concurrency.clamp(5, 10)
}
pub fn suspend<F, R>(&self, f: F) -> R
where
F: FnOnce() -> R,
{
self.multi.suspend(f)
}
pub fn clear(&self) {
if let Ok(mut guard) = self.current_bar.lock()
&& let Some(bar) = guard.take()
{
bar.finish_and_clear();
}
self.multi.clear().ok();
}
pub fn add_progress_bar(&self, total: u64) -> Option<IndicatifBar> {
if !self.enabled {
return None;
}
let pb = self.multi.add(IndicatifBar::new(total));
let style = IndicatifStyle::default_bar()
.template(" {msg} [{bar:40.cyan/blue}] {pos}/{len}")
.unwrap()
.progress_chars("=>-");
pb.set_style(style);
Some(pb)
}
}
pub fn collect_dependency_names(manifest: &Manifest) -> Vec<String> {
manifest.all_dependencies().iter().map(|(name, _)| (*name).to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ResourceType;
use crate::lockfile::LockedResource;
use crate::resolver::lockfile_builder::VariantInputs;
use std::str::FromStr;
fn create_test_locked_resource(name: &str, resource_type: &str) -> LockedResource {
LockedResource {
name: name.to_string(),
manifest_alias: None,
source: Some("test".to_string()),
url: Some("https://example.com".to_string()),
version: Some("v1.0.0".to_string()),
path: format!("{}.md", name),
resolved_commit: Some("abc123def456".to_string()),
resource_type: ResourceType::from_str(resource_type).unwrap_or(ResourceType::Agent),
tool: Some("claude-code".to_string()),
installed_at: format!(".claude/{}/{}.md", resource_type, name),
checksum: "sha256:test123".to_string(),
context_checksum: Some("sha256:context456".to_string()),
variant_inputs: VariantInputs::default(),
is_private: false,
dependencies: vec![],
applied_patches: std::collections::BTreeMap::new(),
install: Some(true),
approximate_token_count: None,
}
}
#[test]
fn test_installation_phase_description() {
assert_eq!(InstallationPhase::SyncingSources.description(), "Syncing sources");
assert_eq!(
InstallationPhase::ResolvingDependencies.description(),
"Resolving dependencies"
);
assert_eq!(InstallationPhase::Installing.description(), "Installing resources");
assert_eq!(InstallationPhase::InstallingResources.description(), "Installing resources");
assert_eq!(InstallationPhase::Finalizing.description(), "Finalizing installation");
}
#[test]
fn test_active_window_basic() {
let progress = MultiPhaseProgress::new(true);
progress.start_phase_with_active_tracking(InstallationPhase::InstallingResources, 10, 5);
let resource1 = create_test_locked_resource("resource1", "agents");
let resource2 = create_test_locked_resource("resource2", "agents");
let resource3 = create_test_locked_resource("resource3", "agents");
progress.mark_resource_active(&resource1);
progress.mark_resource_active(&resource2);
progress.mark_resource_active(&resource3);
progress.mark_resource_complete(&resource1, 1, 10);
progress.complete_phase_with_window(Some("Installed 10 resources"));
}
#[test]
fn test_active_window_overflow() {
let progress = MultiPhaseProgress::new(true);
progress.start_phase_with_active_tracking(InstallationPhase::InstallingResources, 10, 3);
let r1 = create_test_locked_resource("r1", "agents");
let r2 = create_test_locked_resource("r2", "agents");
let r3 = create_test_locked_resource("r3", "agents");
let r4 = create_test_locked_resource("r4", "agents");
let r5 = create_test_locked_resource("r5", "agents");
progress.mark_resource_active(&r1);
progress.mark_resource_active(&r2);
progress.mark_resource_active(&r3);
progress.mark_resource_active(&r4); progress.mark_resource_active(&r5);
progress.mark_resource_complete(&r1, 1, 10);
progress.mark_resource_active(&r4);
}
#[test]
fn test_calculate_window_size() {
assert_eq!(MultiPhaseProgress::calculate_window_size(1), 5);
assert_eq!(MultiPhaseProgress::calculate_window_size(5), 5);
assert_eq!(MultiPhaseProgress::calculate_window_size(7), 7);
assert_eq!(MultiPhaseProgress::calculate_window_size(10), 10);
assert_eq!(MultiPhaseProgress::calculate_window_size(50), 10); }
#[test]
fn test_phase_timing() {
let progress = MultiPhaseProgress::new(true);
progress.start_phase(InstallationPhase::SyncingSources, None);
std::thread::sleep(Duration::from_millis(100));
progress.complete_phase(Some("Sources synced"));
}
#[test]
fn test_multi_phase_progress_new() {
let progress = MultiPhaseProgress::new(true);
progress.start_phase(InstallationPhase::SyncingSources, Some("test message"));
progress.update_current_message("updated message");
progress.complete_phase(Some("completed"));
progress.clear();
}
#[test]
fn test_multi_phase_progress_with_progress_bar() {
let progress = MultiPhaseProgress::new(true);
progress.start_phase_with_progress(InstallationPhase::Installing, 10);
progress.increment_progress(5);
progress.set_progress(8);
progress.complete_phase(Some("Installation completed"));
}
#[test]
fn test_multi_phase_progress_disabled() {
let progress = MultiPhaseProgress::new(false);
progress.start_phase(InstallationPhase::SyncingSources, None);
progress.complete_phase(Some("test"));
progress.clear();
}
#[test]
fn test_collect_dependency_names() {
}
}