diff --git a/crates/pakker-cli/src/cli.rs b/crates/pakker-cli/src/cli.rs index 80d266c..cd48900 100644 --- a/crates/pakker-cli/src/cli.rs +++ b/crates/pakker-cli/src/cli.rs @@ -9,11 +9,7 @@ use crate::model::{ #[derive(Parser)] #[clap(name = "pakker")] -#[clap( - about = "A multiplatform modpack manager for Minecraft", - long_about = None, - version -)] +#[clap(about = "A multiplatform modpack manager for Minecraft", long_about = None)] pub struct Cli { /// Enable verbose output (-v for info, -vv for debug, -vvv for trace) #[clap(short, long, action = clap::ArgAction::Count)] @@ -92,9 +88,6 @@ pub enum Commands { /// Manage fork configuration Fork(ForkArgs), - - /// Check and repair lockfile integrity - Lockfile(LockfileArgs), } #[derive(Args)] @@ -224,33 +217,17 @@ pub struct UpdateArgs { #[derive(Args)] pub struct LsArgs { - /// Show all optional columns (equivalent to enabling all --show-* flags) + /// Show detailed information #[clap(short, long)] pub detailed: bool, - /// Show project type column (mod, resourcepack, shader, etc.) - #[clap(long = "show-type")] - pub show_type: bool, - - /// Show project side column (client, server, both) - #[clap(long = "show-side")] - pub show_side: bool, - - /// Show first slug column - #[clap(long = "show-slug")] - pub show_slug: bool, - - /// Show dependency count column - #[clap(long = "show-links")] - pub show_links: bool, - - /// Show provider versions (when present) column - #[clap(long = "show-versions")] - pub show_versions: bool, - - /// Include update information for projects + /// Add update information for projects #[clap(short = 'c', long = "check-updates")] pub check_updates: bool, + + /// Maximum length for project names + #[clap(long = "name-max-length")] + pub name_max_length: Option, } #[derive(Args)] @@ -646,24 +623,3 @@ pub enum ForkSubcommand { projects: Vec, }, } - -/// Lockfile management subcommand arguments -#[derive(Debug, Args)] -#[command(args_conflicts_with_subcommands = true)] -pub struct LockfileArgs { - #[clap(subcommand)] - pub subcommand: LockfileSubcommand, -} - -#[derive(Debug, Subcommand)] -pub enum LockfileSubcommand { - /// Check the lockfile for known issues - Doctor, - - /// Repair known lockfile issues - Repair { - /// Skip operations that require network access - #[clap(long)] - offline: bool, - }, -} diff --git a/crates/pakker-cli/src/cli/commands/add.rs b/crates/pakker-cli/src/cli/commands/add.rs index bc6ac74..84567e5 100644 --- a/crates/pakker-cli/src/cli/commands/add.rs +++ b/crates/pakker-cli/src/cli/commands/add.rs @@ -148,7 +148,7 @@ async fn resolve_input( let mut merged = projects.remove(0); for project in projects { - merged = merged.merged(project)?; + merged.merge(project); } Ok(merged) } diff --git a/crates/pakker-cli/src/cli/commands/add_prj.rs b/crates/pakker-cli/src/cli/commands/add_prj.rs index e8deea9..2db1c80 100644 --- a/crates/pakker-cli/src/cli/commands/add_prj.rs +++ b/crates/pakker-cli/src/cli/commands/add_prj.rs @@ -237,7 +237,7 @@ pub async fn execute( let mut combined_project = projects_to_merge.remove(0); for project in projects_to_merge { - combined_project = combined_project.merged(project)?; + combined_project.merge(project); } // Apply user-specified properties diff --git a/crates/pakker-cli/src/cli/commands/fork.rs b/crates/pakker-cli/src/cli/commands/fork.rs index 8c4dcab..61ab9b2 100644 --- a/crates/pakker-cli/src/cli/commands/fork.rs +++ b/crates/pakker-cli/src/cli/commands/fork.rs @@ -15,11 +15,8 @@ use crate::{ git::{self, VcsType}, model::{ LockFile, - Project, - credentials::ResolvedCredentials, fork::{ForkIntegrity, LocalConfig, ParentConfig, RefType, hash_content}, }, - platform::create_platform, }; const PAKKU_DIR: &str = ".pakku"; @@ -692,54 +689,6 @@ fn execute_sync() -> Result<(), PakkerError> { Ok(()) } -fn resolve_project_files( - project: &mut Project, - slug: &str, - mc_versions: &[String], - loaders: &[String], -) -> Result<(), PakkerError> { - let handle = tokio::runtime::Handle::current(); - - if let Ok(platform) = create_platform("modrinth", None) - && let Ok(mut resolved) = handle - .block_on(platform.request_project_with_files(slug, mc_versions, loaders)) - && !resolved.files.is_empty() - && resolved.select_file(mc_versions, loaders, None).is_ok() - { - project.files = resolved.files; - return Ok(()); - } - - let creds = ResolvedCredentials::load(); - let cf_key = creds.curseforge_api_key().map(String::from); - if let Ok(platform) = create_platform("curseforge", cf_key) - && let Ok(mut resolved) = handle - .block_on(platform.request_project_with_files(slug, mc_versions, loaders)) - && !resolved.files.is_empty() - && resolved.select_file(mc_versions, loaders, None).is_ok() - { - project.files = resolved.files; - return Ok(()); - } - - let cf_key2 = ResolvedCredentials::load() - .curseforge_api_key() - .map(String::from); - if let Ok(platform) = create_platform("multiplatform", cf_key2) - && let Ok(mut resolved) = handle - .block_on(platform.request_project_with_files(slug, mc_versions, loaders)) - && !resolved.files.is_empty() - && resolved.select_file(mc_versions, loaders, None).is_ok() - { - project.files = resolved.files; - return Ok(()); - } - - Err(PakkerError::FileSelectionError(format!( - "Could not resolve files for '{slug}'" - ))) -} - fn execute_promote(projects: &[String]) -> Result<(), PakkerError> { let config_dir = Path::new("."); let local_config = LocalConfig::load(config_dir)?; @@ -816,24 +765,7 @@ fn execute_promote(projects: &[String]) -> Result<(), PakkerError> { continue; } - let mut project = project.clone(); - if project.files.is_empty() { - // Try to resolve files from platforms - if let Some(slug) = project.slug.values().next().cloned() { - let mc_versions = parent_lockfile.mc_versions.clone(); - let loaders: Vec = - parent_lockfile.loaders.keys().cloned().collect(); - if let Err(e) = - resolve_project_files(&mut project, &slug, &mc_versions, &loaders) - { - log::debug!( - "Failed to resolve files for '{}': {e}", - project.get_name() - ); - } - } - } - local_lockfile.add_project(project); + local_lockfile.add_project(project.clone()); promoted.push(project_arg); } else { not_found.push(project_arg); diff --git a/crates/pakker-cli/src/cli/commands/lockfile.rs b/crates/pakker-cli/src/cli/commands/lockfile.rs deleted file mode 100644 index 6cff3ce..0000000 --- a/crates/pakker-cli/src/cli/commands/lockfile.rs +++ /dev/null @@ -1,304 +0,0 @@ -use std::{collections::HashSet, path::Path}; - -use yansi::Paint; - -use crate::{ - cli::LockfileSubcommand, - error::Result, - model::{LockFile, Project, credentials::ResolvedCredentials}, - platform::create_platform, -}; - -pub fn execute(args: &crate::cli::LockfileArgs) -> Result<()> { - match &args.subcommand { - LockfileSubcommand::Doctor => execute_doctor(), - LockfileSubcommand::Repair { offline } => execute_repair(*offline), - } -} - -fn execute_doctor() -> Result<()> { - let config_dir = Path::new("."); - let lockfile = LockFile::load(config_dir)?; - - let issues = diagnose(&lockfile); - - if issues.is_empty() { - println!("{}", "✓ Lockfile is healthy".green()); - return Ok(()); - } - - println!("{}", "Lockfile Issues:".yellow().bold()); - println!(); - for issue in &issues { - println!(" {} {}", "✗".red(), issue); - } - println!(); - println!( - " {}", - format!( - "{} issue(s) found. Run 'pakker lockfile repair' to fix.", - issues.len() - ) - .dim() - ); - - Ok(()) -} - -fn execute_repair(offline: bool) -> Result<()> { - let config_dir = Path::new("."); - let mut lockfile = LockFile::load(config_dir)?; - let issues_before = diagnose(&lockfile); - - if issues_before.is_empty() { - println!("{}", "✓ Lockfile is healthy — nothing to repair".green()); - return Ok(()); - } - - println!( - "{}", - format!( - "Found {} issue(s). Attempting repair...", - issues_before.len() - ) - .yellow() - ); - println!(); - - let mut fixed = Vec::new(); - let mut skipped = Vec::new(); - - // Fix 1: Resolve projects with empty files - let empty_file_count = lockfile - .projects - .iter() - .filter(|p| p.files.is_empty()) - .count(); - - if empty_file_count > 0 { - if offline { - skipped.push(format!( - "{empty_file_count} project(s) with missing files (requires network)" - )); - } else { - let resolved = resolve_empty_files(&mut lockfile); - if resolved > 0 { - fixed.push(format!( - "{resolved}/{empty_file_count} project(s) with missing files \ - resolved" - )); - } - if resolved < empty_file_count { - skipped.push(format!( - "{} project(s) could not be resolved (check slugs or network)", - empty_file_count - resolved - )); - } - } - } - - // Fix 2: Deduplicate - let project_count_before = lockfile.projects.len(); - lockfile.deduplicate_projects(); - let removed = project_count_before - lockfile.projects.len(); - if removed > 0 { - fixed.push(format!("{removed} duplicate project(s) merged")); - } - - // Save repaired lockfile - lockfile.save(config_dir)?; - - // Report - if !fixed.is_empty() { - println!("{}", "Fixed:".green()); - for item in &fixed { - println!(" {} {}", "✓".green(), item); - } - println!(); - } - if !skipped.is_empty() { - println!("{}", "Skipped (requires attention):".yellow()); - for item in &skipped { - println!(" {} {}", "!".yellow(), item); - } - println!(); - } - - Ok(()) -} - -fn diagnose(lockfile: &LockFile) -> Vec { - let mut issues = Vec::new(); - - // Check for empty mc_versions - if lockfile.mc_versions.is_empty() { - issues.push("No Minecraft versions configured".to_string()); - } - - // Check for empty loaders - if lockfile.loaders.is_empty() { - issues.push("No mod loaders configured".to_string()); - } - - // Check lockfile version - const LOCKFILE_VERSION: u32 = 2; - if lockfile.lockfile_version < LOCKFILE_VERSION { - issues.push(format!( - "Lockfile version {} is outdated (current: {LOCKFILE_VERSION})", - lockfile.lockfile_version - )); - } - - // Check projects - let mut seen_slugs: HashSet<&str> = HashSet::new(); - let mut duplicate_count = 0u32; - - for project in &lockfile.projects { - let name = project.get_name(); - - // Empty files - if project.files.is_empty() && !project.slug.is_empty() { - issues.push(format!("'{name}' has no resolved files")); - } - - // No slugs at all - if project.slug.is_empty() { - issues.push(format!("'{name}' has no platform slugs")); - } - - // No platform IDs - if project.id.is_empty() && !project.slug.is_empty() { - issues.push(format!("'{name}' has no platform IDs")); - } - - // Duplicate slugs - for slug in project.slug.values() { - if !seen_slugs.insert(slug.as_str()) { - duplicate_count += 1; - } - } - } - - if duplicate_count > 0 { - issues.push(format!( - "{duplicate_count} duplicate slug conflict(s) across projects" - )); - } - - issues -} - -fn resolve_empty_files(lockfile: &mut LockFile) -> usize { - let mut resolved = 0usize; - - for project in lockfile.projects.iter_mut() { - if !project.files.is_empty() { - continue; - } - - for (platform_name, platform_slug) in &project.slug.clone() { - let mc_versions = lockfile.mc_versions.clone(); - let loaders: Vec = lockfile.loaders.keys().cloned().collect(); - - if resolve_project_from_platform( - project, - platform_name, - platform_slug, - &mc_versions, - &loaders, - ) { - resolved += 1; - println!( - " {} Resolved files for '{}' via {platform_name}", - "✓".green(), - project.get_name() - ); - break; - } - } - - // Also try by platform ID if slug resolution failed - if project.files.is_empty() { - for (platform_name, platform_id) in &project.id.clone() { - if project.slug.contains_key(platform_name) { - continue; - } - - let mc_versions = lockfile.mc_versions.clone(); - let loaders: Vec = lockfile.loaders.keys().cloned().collect(); - - if resolve_project_from_platform( - project, - platform_name, - platform_id, - &mc_versions, - &loaders, - ) { - resolved += 1; - println!( - " {} Resolved files for '{}' via {platform_name} (by ID)", - "✓".green(), - project.get_name() - ); - break; - } - } - } - } - - resolved -} - -fn resolve_project_from_platform( - project: &mut Project, - platform_name: &str, - identifier: &str, - mc_versions: &[String], - loaders: &[String], -) -> bool { - let handle = tokio::runtime::Handle::current(); - - let api_key = match platform_name { - "curseforge" => { - ResolvedCredentials::load() - .curseforge_api_key() - .map(String::from) - }, - "modrinth" => None, - "github" => { - ResolvedCredentials::load() - .github_access_token() - .map(String::from) - }, - _ => None, - }; - - let platform = match create_platform(platform_name, api_key) { - Ok(p) => p, - Err(_) => { - log::debug!("Failed to create platform '{platform_name}'"); - return false; - }, - }; - - match handle.block_on(platform.request_project_with_files( - identifier, - mc_versions, - loaders, - )) { - Ok(mut resolved) => { - if resolved.files.is_empty() { - return false; - } - if resolved.select_file(mc_versions, loaders, None).is_err() { - return false; - } - project.files = resolved.files; - true - }, - Err(e) => { - log::debug!("Platform '{platform_name}' failed for '{identifier}': {e}"); - false - }, - } -} diff --git a/crates/pakker-cli/src/cli/commands/ls.rs b/crates/pakker-cli/src/cli/commands/ls.rs index a5cea41..37ed85e 100644 --- a/crates/pakker-cli/src/cli/commands/ls.rs +++ b/crates/pakker-cli/src/cli/commands/ls.rs @@ -1,11 +1,9 @@ use std::path::Path; -use yansi::Paint; - use crate::{cli::LsArgs, error::Result, model::LockFile}; -const COL_GAP: usize = 3; // spaces between columns - +/// Truncate a name to fit within `max_len` characters, adding "..." if +/// truncated fn truncate_name(name: &str, max_len: usize) -> String { if name.len() <= max_len { name.to_string() @@ -16,231 +14,80 @@ fn truncate_name(name: &str, max_len: usize) -> String { } } -struct ColumnWidths { - name: usize, - file: usize, - r#type: usize, - side: usize, - slug: usize, - links: usize, - versions: usize, -} - -fn compute_widths( - lockfile: &LockFile, - show_type: bool, - show_side: bool, - show_slug: bool, - show_links: bool, - show_versions: bool, -) -> ColumnWidths { - let mut w = ColumnWidths { - name: "Name".len(), - file: "File".len(), - r#type: "Type".len(), - side: "Side".len(), - slug: "Slug".len(), - links: "Links".len(), - versions: "Versions".len(), - }; - - for p in &lockfile.projects { - w.name = w.name.max(p.get_name().len().min(50)); - let file_len = p.files.first().map_or(1, |f| f.file_name.len()); - w.file = w.file.max(file_len); - - if show_type { - let t = format!("{:?}", p.r#type).to_lowercase(); - w.r#type = w.r#type.max(t.len()); - } - if show_side { - let s = format!("{:?}", p.side).to_lowercase(); - w.side = w.side.max(s.len()); - } - if show_slug { - let slug_len = p.slug.values().next().map_or(1, String::len); - w.slug = w.slug.max(slug_len); - } - if show_links { - let links_len = p.pakku_links.len().to_string().len(); - w.links = w.links.max(links_len); - } - if show_versions { - let v = if p.files.len() > 1 { - p.files - .iter() - .map(|f| format!("{}: {}", f.file_type, f.file_name)) - .collect::>() - .join(", ") - .len() - } else { - 1 - }; - w.versions = w.versions.max(v); - } - } - - w -} - pub fn execute(args: &LsArgs, lockfile_path: &Path) -> Result<()> { + // Load expects directory path, so get parent directory let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); let lockfile = LockFile::load(lockfile_dir)?; if lockfile.projects.is_empty() { - println!("{}", "No projects installed".yellow()); + println!("No projects installed"); return Ok(()); } - let project_count = lockfile.projects.len(); - - let show_type = args.detailed || args.show_type; - let show_side = args.detailed || args.show_side; - let show_slug = args.detailed || args.show_slug; - let show_links = args.detailed || args.show_links; - let show_versions = args.detailed || args.show_versions; - - let widths = compute_widths( - &lockfile, - show_type, - show_side, - show_slug, - show_links, - show_versions, - ); - - // Build header - let mut header_cols: Vec<(&str, usize)> = - vec![("Name", widths.name), ("File", widths.file)]; - if show_type { - header_cols.push(("Type", widths.r#type)); - } - if show_side { - header_cols.push(("Side", widths.side)); - } - if show_slug { - header_cols.push(("Slug", widths.slug)); - } - if show_links { - header_cols.push(("Links", widths.links)); - } - if show_versions { - header_cols.push(("Versions", widths.versions)); - } - - println!( - "{} ({})", - "Installed projects".bold(), - project_count.to_string().cyan().bold() - ); + println!("Installed projects ({}):", lockfile.projects.len()); println!(); - // Print header - let header_line: Vec = header_cols - .iter() - .map(|(text, width)| { - if text == &header_cols.last().unwrap().0 { - format!("{text}") - } else { - format!("{:>() - .join(&" ".repeat(COL_GAP)); - println!("{}", dash_line.dim()); + // Calculate max name length for alignment + let max_name_len = args.name_max_length.unwrap_or_else(|| { + lockfile + .projects + .iter() + .map(|p| p.get_name().len()) + .max() + .unwrap_or(20) + .min(50) + }); for project in &lockfile.projects { - let name = truncate_name(&project.get_name(), widths.name.min(50)); - let warning_marker = if !project.versions_match_across_providers() { + // Check for version mismatch across providers + let version_warning = if project.versions_match_across_providers() { + "" + } else { + // Use the detailed check_version_mismatch for logging if let Some(mismatch_detail) = project.check_version_mismatch() { log::warn!("{mismatch_detail}"); } - " [!]" - } else { - "" + " [!] versions do not match across providers" }; - let file_name = project - .files - .first() - .map(|f| f.file_name.as_str()) - .unwrap_or("-"); + if args.detailed { + let id = project.pakku_id.as_deref().unwrap_or("unknown"); + let name = truncate_name(&project.get_name(), max_name_len); + println!(" {name} ({id}){version_warning}"); + println!(" Type: {:?}", project.r#type); + println!(" Side: {:?}", project.side); - let name_display = format!("{name}{warning_marker}"); - print!(" "); - if warning_marker.is_empty() { - print!( - "{}", - format!("{: 1 { - project - .files - .iter() - .map(|f| format!("{}: {}", f.file_type, f.file_name)) - .collect::>() - .join(", ") - } else { - String::from("-") - }; - print!("{}", " ".repeat(COL_GAP)); - print!("{v}"); - } - - println!(); } Ok(()) diff --git a/crates/pakker-cli/src/cli/commands/mod.rs b/crates/pakker-cli/src/cli/commands/mod.rs index 3616708..97335d8 100644 --- a/crates/pakker-cli/src/cli/commands/mod.rs +++ b/crates/pakker-cli/src/cli/commands/mod.rs @@ -13,7 +13,6 @@ pub mod import; pub mod init; pub mod inspect; pub mod link; -pub mod lockfile; pub mod ls; pub mod remote; pub mod remote_update; diff --git a/crates/pakker-cli/src/cli/commands/status.rs b/crates/pakker-cli/src/cli/commands/status.rs index 0d45139..fed7b13 100644 --- a/crates/pakker-cli/src/cli/commands/status.rs +++ b/crates/pakker-cli/src/cli/commands/status.rs @@ -255,88 +255,49 @@ async fn check_project_update( project: &Project, lockfile: &LockFile, ) -> Result> { - let loaders: Vec = lockfile.loaders.keys().cloned().collect(); - let mc_versions = &lockfile.mc_versions; + // Get primary slug + let slug = project + .slug + .values() + .next() + .ok_or_else(|| { + crate::error::PakkerError::InvalidProject("No slug found".to_string()) + })? + .clone(); - let mut errors: Vec = Vec::new(); - - for (platform_name, platform_slug) in &project.slug { - let api_key = get_api_key(platform_name); - let Ok(platform) = create_platform(platform_name, api_key) else { - continue; - }; - - match platform - .request_project_with_files(platform_slug, mc_versions, &loaders) - .await - { - Ok(updated_project) => { - 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); - }, - Err(e) => { - errors.push(format!("{platform_name}: {e}")); - }, - } - } - - // Also try platforms that have IDs but no slugs (uncommon edge case) + // Try each platform in project for platform_name in project.id.keys() { - if project.slug.contains_key(platform_name) { - continue; - } - let platform_id = project - .id - .get(platform_name) - .expect("key must exist in id map"); let api_key = get_api_key(platform_name); let Ok(platform) = create_platform(platform_name, api_key) else { continue; }; - match platform - .request_project_with_files(platform_id, mc_versions, &loaders) + let loaders: Vec = lockfile.loaders.keys().cloned().collect(); + + if let Ok(updated_project) = platform + .request_project_with_files(&slug, &lockfile.mc_versions, &loaders) .await { - Ok(updated_project) => { - 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); - }, - Err(e) => { - errors.push(format!("{platform_name}(by id): {e}")); - }, + // 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 } } - let error_detail = if errors.is_empty() { - "no platform slugs or IDs available".to_string() - } else { - errors.join(" | ") - }; - - Err(crate::error::PakkerError::PlatformApiError(format!( - "Failed to check for updates on any platform ({error_detail})" - ))) + Err(crate::error::PakkerError::PlatformApiError( + "Failed to check for updates on any platform".to_string(), + )) } fn detect_file_updates( diff --git a/crates/pakker-cli/src/lib.rs b/crates/pakker-cli/src/lib.rs index cb54246..f1074bb 100644 --- a/crates/pakker-cli/src/lib.rs +++ b/crates/pakker-cli/src/lib.rs @@ -238,9 +238,5 @@ pub async fn run() -> Result<(), PakkerError> { cli::commands::fork::execute(&args)?; Ok(()) }, - Commands::Lockfile(args) => { - cli::commands::lockfile::execute(&args)?; - Ok(()) - }, } } diff --git a/crates/pakker-core/src/model/lockfile.rs b/crates/pakker-core/src/model/lockfile.rs index 23568ab..1e56ab1 100644 --- a/crates/pakker-core/src/model/lockfile.rs +++ b/crates/pakker-core/src/model/lockfile.rs @@ -1,7 +1,4 @@ -use std::{ - collections::{HashMap, HashSet}, - path::Path, -}; +use std::{collections::HashMap, path::Path}; use serde::{Deserialize, Serialize}; @@ -659,7 +656,6 @@ impl LockFile { lockfile.validate()?; } lockfile.sort_projects(); - lockfile.deduplicate_projects(); Ok(lockfile) } @@ -758,84 +754,7 @@ impl LockFile { } pub fn add_project(&mut self, project: Project) { - // Check for existing project with overlapping slugs - if let Some(existing) = self.projects.iter_mut().find(|p| { - p.slug - .values() - .any(|s| project.slug.values().any(|ps| ps == s)) - }) { - // Merge data into existing project - for (platform, slug) in &project.slug { - existing - .slug - .entry(platform.clone()) - .or_insert_with(|| slug.clone()); - } - for (platform, name) in &project.name { - existing - .name - .entry(platform.clone()) - .or_insert_with(|| name.clone()); - } - for (platform, id) in &project.id { - existing - .id - .entry(platform.clone()) - .or_insert_with(|| id.clone()); - } - for file in &project.files { - if !existing.files.iter().any(|f| f.file_name == file.file_name) { - existing.files.push(file.clone()); - } - } - log::debug!( - "Merged duplicate project '{}' into existing entry", - project.get_name() - ); - self.projects.sort_by_key(super::project::Project::get_name); - return; - } - self.projects.push(project); self.projects.sort_by_key(super::project::Project::get_name); } - - /// Remove duplicate projects that share overlapping slugs. - /// When duplicates are found, files from the duplicate are merged into - /// the kept project. This handles lockfiles that were corrupted before - /// `add_project` enforced slug uniqueness. - pub fn deduplicate_projects(&mut self) { - let mut seen_slugs: HashSet = HashSet::new(); - let mut slug_to_idx: HashMap = HashMap::new(); - let mut unique: Vec = Vec::with_capacity(self.projects.len()); - - for project in self.projects.drain(..) { - let duplicate_slug = - project.slug.values().find(|s| seen_slugs.contains(*s)); - - if let Some(dup_slug) = duplicate_slug { - log::debug!( - "Removed duplicate project '{}' (slug collision: {dup_slug})", - project.get_name() - ); - if let Some(&existing_idx) = slug_to_idx.get(dup_slug) { - if let Some(existing) = unique.get_mut(existing_idx) { - for file in &project.files { - if !existing.files.iter().any(|f| f.file_name == file.file_name) { - existing.files.push(file.clone()); - } - } - } - } - } else { - for slug in project.slug.values() { - seen_slugs.insert(slug.clone()); - slug_to_idx.insert(slug.clone(), unique.len()); - } - unique.push(project); - } - } - - self.projects = unique; - } } diff --git a/crates/pakker-core/src/model/project.rs b/crates/pakker-core/src/model/project.rs index 145c8d0..4ff7504 100644 --- a/crates/pakker-core/src/model/project.rs +++ b/crates/pakker-core/src/model/project.rs @@ -156,6 +156,32 @@ impl Project { self.name.insert(platform, name); } + pub fn merge(&mut self, other: Self) { + // Merge platform identifiers + for (platform, id) in other.id { + self.id.entry(platform).or_insert(id); + } + for (platform, slug) in other.slug { + self.slug.entry(platform).or_insert(slug); + } + for (platform, name) in other.name { + self.name.entry(platform).or_insert(name); + } + + // Merge pakku links + self.pakku_links.extend(other.pakku_links); + + // Merge files + for file in other.files { + if !self.files.iter().any(|f| f.id == file.id) { + self.files.push(file); + } + } + + // Merge aliases + self.aliases.extend(other.aliases); + } + /// Merge this project with another, returning a new combined project. /// Like Pakku's `Project.plus()`, this is a pure operation that doesn't /// modify either project. diff --git a/crates/pakker-core/src/platform/curseforge.rs b/crates/pakker-core/src/platform/curseforge.rs index 6d3a24b..6fdf453 100644 --- a/crates/pakker-core/src/platform/curseforge.rs +++ b/crates/pakker-core/src/platform/curseforge.rs @@ -322,9 +322,7 @@ impl PlatformClient for CurseForgePlatform { query_params.push(("modLoaderTypes", loader_str)); } - let has_filters = !query_params.is_empty(); - - if has_filters { + if !query_params.is_empty() { let query_string = query_params .iter() .map(|(k, v)| format!("{k}={v}")) @@ -352,30 +350,6 @@ impl PlatformClient for CurseForgePlatform { .map(|f| Self::convert_file(f, project_id)) .collect(); - // If server-side filters eliminated all results, retry without them - if files.is_empty() && has_filters { - let bare_url = format!("{CURSEFORGE_API_BASE}/mods/{project_id}/files"); - let response = self - .client - .get(&bare_url) - .headers(self.get_headers()?) - .send() - .await?; - - if !response.status().is_success() { - return Err(Self::map_http_error(response.status(), project_id)); - } - - let result: CurseForgeFilesResponse = response.json().await?; - return Ok( - result - .data - .into_iter() - .map(|f| Self::convert_file(f, project_id)) - .collect(), - ); - } - Ok(files) } diff --git a/crates/pakker-core/src/platform/modrinth.rs b/crates/pakker-core/src/platform/modrinth.rs index 43912d6..d05330d 100644 --- a/crates/pakker-core/src/platform/modrinth.rs +++ b/crates/pakker-core/src/platform/modrinth.rs @@ -222,16 +222,7 @@ impl PlatformClient for ModrinthPlatform { url.push_str(¶ms.join("&")); } - let files = self.request_project_files_url(&url).await?; - - // If server-side filters eliminated all results, retry without them - if files.is_empty() && !params.is_empty() { - let bare_url = - format!("{MODRINTH_API_BASE}/project/{project_id}/version"); - return self.request_project_files_url(&bare_url).await; - } - - Ok(files) + self.request_project_files_url(&url).await } async fn request_project_with_files( diff --git a/crates/pakker-core/src/resolver.rs b/crates/pakker-core/src/resolver.rs index 4310245..1335b44 100644 --- a/crates/pakker-core/src/resolver.rs +++ b/crates/pakker-core/src/resolver.rs @@ -140,7 +140,7 @@ impl DependencyResolver { } else { let mut merged = projects.remove(0); for project in projects { - merged = merged.merged(project)?; + merged.merge(project); } Ok(merged) }