pakker/src/cli/commands/status.rs
NotAShelf ef28bdaeb4
initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ife1391ed23a1e7f388b1b5eca90b9ea76a6a6964
2026-02-13 00:14:46 +03:00

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 &current.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,
}
}