use std::{collections::HashMap, path::Path, sync::Arc}; use futures::stream::{FuturesUnordered, StreamExt}; use indicatif::{ProgressBar, ProgressStyle}; use tokio::sync::Semaphore; use yansi::Paint; use crate::{ error::Result, model::{Config, LockFile, Project}, platform::create_platform, }; pub async fn execute( parallel: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<()> { let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); let config_dir = config_path.parent().unwrap_or(Path::new(".")); let lockfile = LockFile::load(lockfile_dir)?; let config = Config::load(config_dir)?; // Display modpack metadata display_modpack_info(&lockfile, &config); println!(); // Check for updates (sequential or parallel) let (updates, errors) = if parallel { check_updates_parallel(&lockfile).await? } else { check_updates_sequential(&lockfile).await? }; // Display results display_update_results(&updates); // Display errors if any if !errors.is_empty() { println!(); println!("{}", "Errors encountered:".red()); for (project, error) in &errors { println!(" - {}: {}", project.yellow(), error.red()); } } // Prompt to update if there are updates available if !updates.is_empty() { println!(); if crate::ui_utils::prompt_yes_no("Update now?", false)? { // Call update command programmatically (update all projects) let update_args = crate::cli::UpdateArgs { inputs: vec![], yes: true, // Auto-yes for status command }; crate::cli::commands::update::execute( update_args, lockfile_path, config_path, ) .await?; } } Ok(()) } fn display_modpack_info(lockfile: &LockFile, config: &Config) { let author = config.author.as_deref().unwrap_or("Unknown"); println!( "Managing {} modpack, version {}, by {}", config.name.cyan(), config.version.cyan(), author.cyan() ); let mc_versions = lockfile.mc_versions.join(", "); let loaders: Vec = lockfile .loaders .iter() .map(|(loader, version)| format!("{loader}-{version}")) .collect(); let loaders_str = loaders.join(", "); println!( "on Minecraft version {}, loader {}, targeting platform {:?}.", mc_versions.cyan(), loaders_str.cyan(), lockfile.target ); } #[derive(Debug)] struct ProjectUpdate { slug: HashMap, name: String, project_type: String, side: String, file_updates: Vec, } #[derive(Debug)] struct FileUpdate { platform: String, old_filename: String, new_filename: String, } async fn check_updates_sequential( lockfile: &LockFile, ) -> Result<(Vec, Vec<(String, String)>)> { let total = lockfile.projects.len(); let mut updates = Vec::new(); let mut errors = Vec::new(); // Create progress bar let pb = ProgressBar::new(total as u64); pb.set_style( ProgressStyle::default_bar() .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") .unwrap() .progress_chars("#>-"), ); pb.set_message("Checking for updates..."); for project in &lockfile.projects { let project_name = project .name .values() .next() .unwrap_or(&"Unknown".to_string()) .clone(); pb.set_message(format!("Checking {project_name}...")); match check_project_update(project, lockfile).await { Ok(update_opt) => { if let Some(update) = update_opt { updates.push(update); } }, Err(e) => { errors.push((project_name.clone(), e.to_string())); }, } pb.inc(1); } pb.finish_with_message(format!("Checked {total} projects")); println!(); // Add blank line after progress bar Ok((updates, errors)) } async fn check_updates_parallel( lockfile: &LockFile, ) -> Result<(Vec, Vec<(String, String)>)> { let total = lockfile.projects.len(); let semaphore = Arc::new(Semaphore::new(10)); let mut futures = FuturesUnordered::new(); // Create progress bar let pb = Arc::new(ProgressBar::new(total as u64)); pb.set_style( ProgressStyle::default_bar() .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") .unwrap() .progress_chars("#>-"), ); pb.set_message("Checking for updates (parallel)..."); for project in &lockfile.projects { let project = project.clone(); let sem = semaphore.clone(); let pb_clone = pb.clone(); let lockfile_clone = lockfile.clone(); futures.push(async move { let _permit = sem.acquire().await.unwrap(); let result = check_project_update(&project, &lockfile_clone).await; pb_clone.inc(1); (project, result) }); } let mut updates = Vec::new(); let mut errors = Vec::new(); while let Some((project, result)) = futures.next().await { match result { Ok(update_opt) => { if let Some(update) = update_opt { updates.push(update); } }, Err(e) => { let project_name = project .name .values() .next() .unwrap_or(&"Unknown".to_string()) .clone(); errors.push((project_name, e.to_string())); }, } } pb.finish_with_message(format!("Checked {total} projects")); println!(); // Add blank line after progress bar Ok((updates, errors)) } async fn check_project_update( project: &Project, lockfile: &LockFile, ) -> Result> { // Get primary slug let slug = project .slug .values() .next() .ok_or_else(|| { crate::error::PakkerError::InvalidProject("No slug found".to_string()) })? .clone(); // Try each platform in project for platform_name in project.id.keys() { let api_key = get_api_key(platform_name); let platform = match create_platform(platform_name, api_key) { Ok(p) => p, Err(_) => continue, }; let loaders: Vec = lockfile.loaders.keys().cloned().collect(); match platform .request_project_with_files(&slug, &lockfile.mc_versions, &loaders) .await { Ok(updated_project) => { // Compare files to detect updates let file_updates = detect_file_updates(project, &updated_project); if !file_updates.is_empty() { return Ok(Some(ProjectUpdate { slug: project.slug.clone(), name: project.name.values().next().cloned().unwrap_or_default(), project_type: format!("{:?}", project.r#type), side: format!("{:?}", project.side), file_updates, })); } return Ok(None); // No updates }, Err(_) => { // Try next platform continue; }, } } Err(crate::error::PakkerError::PlatformApiError( "Failed to check for updates on any platform".to_string(), )) } fn detect_file_updates( current: &Project, updated: &Project, ) -> Vec { let mut updates = Vec::new(); for old_file in ¤t.files { if let Some(new_file) = updated .files .iter() .find(|f| f.file_type == old_file.file_type) { // Check if file ID changed (indicates update) if new_file.id != old_file.id { updates.push(FileUpdate { platform: old_file.file_type.clone(), old_filename: old_file.file_name.clone(), new_filename: new_file.file_name.clone(), }); } } } updates } fn display_update_results(updates: &[ProjectUpdate]) { if updates.is_empty() { println!("{}", "✓ All projects are up to date".green()); return; } println!(); println!("{}", "📦 Updates Available:".cyan().bold()); println!(); for update in updates { // Create hyperlink for project name using ui_utils let project_url = if let Some((platform, slug)) = update.slug.iter().next() { match platform.as_str() { "modrinth" => crate::ui_utils::modrinth_project_url(slug), "curseforge" => crate::ui_utils::curseforge_project_url(slug), _ => String::new(), } } else { String::new() }; if project_url.is_empty() { println!( "{} ({}, {})", update.name.yellow(), update.project_type, update.side ); } else { let hyperlinked = crate::ui_utils::hyperlink( &project_url, &update.name.yellow().to_string(), ); println!("{} ({}, {})", hyperlinked, update.project_type, update.side); } for file_update in &update.file_updates { println!( " • {}: {} → {}", file_update.platform.cyan(), file_update.old_filename.dim(), file_update.new_filename.green() ); } println!(); } println!( "{}", format!("{} project(s) need updates", updates.len()).yellow() ); } #[allow(dead_code)] fn get_project_display_name(project: &Project) -> String { project .name .values() .next() .or_else(|| project.slug.values().next()) .cloned() .unwrap_or_else(|| "Unknown".to_string()) } fn get_api_key(platform: &str) -> Option { match platform { "modrinth" => std::env::var("MODRINTH_TOKEN").ok(), "curseforge" => std::env::var("CURSEFORGE_API_KEY").ok(), _ => None, } }