Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ife1391ed23a1e7f388b1b5eca90b9ea76a6a6964
370 lines
9.2 KiB
Rust
370 lines
9.2 KiB
Rust
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<String> = 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<String, String>,
|
|
name: String,
|
|
project_type: String,
|
|
side: String,
|
|
file_updates: Vec<FileUpdate>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct FileUpdate {
|
|
platform: String,
|
|
old_filename: String,
|
|
new_filename: String,
|
|
}
|
|
|
|
async fn check_updates_sequential(
|
|
lockfile: &LockFile,
|
|
) -> Result<(Vec<ProjectUpdate>, 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<ProjectUpdate>, 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<Option<ProjectUpdate>> {
|
|
// 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<String> = 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<FileUpdate> {
|
|
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<String> {
|
|
match platform {
|
|
"modrinth" => std::env::var("MODRINTH_TOKEN").ok(),
|
|
"curseforge" => std::env::var("CURSEFORGE_API_KEY").ok(),
|
|
_ => None,
|
|
}
|
|
}
|