diff --git a/src/cli/commands/add.rs b/src/cli/commands/add.rs index bc1d79c..ae0b140 100644 --- a/src/cli/commands/add.rs +++ b/src/cli/commands/add.rs @@ -35,8 +35,10 @@ async fn resolve_input( platforms: &HashMap>, lockfile: &LockFile, ) -> Result { - for platform in platforms.values() { - if let Ok(project) = platform + let mut projects = Vec::new(); + + for (platform_name, client) in platforms { + match client .request_project_with_files( input, &lockfile.mc_versions, @@ -44,11 +46,29 @@ async fn resolve_input( ) .await { - return Ok(project); + Ok(project) => { + log::debug!("Resolved '{input}' on {platform_name}"); + projects.push(project); + }, + Err(e) => { + log::debug!("Could not resolve '{input}' on {platform_name}: {e}"); + }, } } - Err(PakkerError::ProjectNotFound(input.to_string())) + if projects.is_empty() { + return Err(PakkerError::ProjectNotFound(input.to_string())); + } + + if projects.len() == 1 { + return Ok(projects.remove(0)); + } + + let mut merged = projects.remove(0); + for project in projects { + merged.merge(project); + } + Ok(merged) } use std::path::Path; @@ -111,16 +131,24 @@ pub async fn execute( } // Load parent lockfile to get metadata - let parent_lockfile = parent_paths + let parent_lock_path = parent_paths .iter() .find(|path| path.exists()) - .and_then(|path| LockFile::load(path.parent()?).ok()) .ok_or_else(|| { PakkerError::IoError(std::io::Error::new( std::io::ErrorKind::NotFound, - "Failed to load parent lockfile metadata", + "Parent lockfile not found at expected paths", )) })?; + let parent_lockfile = LockFile::load_with_validation( + parent_lock_path.parent().ok_or_else(|| { + PakkerError::IoError(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Parent lockfile path has no parent directory", + )) + })?, + false, + )?; let minimal_lockfile = LockFile { target: parent_lockfile.target, diff --git a/src/cli/commands/fork.rs b/src/cli/commands/fork.rs index 11cc79b..8db8c49 100644 --- a/src/cli/commands/fork.rs +++ b/src/cli/commands/fork.rs @@ -702,7 +702,11 @@ fn execute_promote(projects: &[String]) -> Result<(), PakkerError> { })?; // Load or create local lockfile - let lockfile_path = config_dir.join("pakku-lock.json"); + let lockfile_path = if config_dir.join("pakker-lock.json").exists() { + config_dir.join("pakker-lock.json") + } else { + config_dir.join("pakku-lock.json") + }; let mut local_lockfile = if lockfile_path.exists() { LockFile::load_with_validation(config_dir, false).map_err(|e| { PakkerError::Fork(format!("Failed to load local lockfile: {e}")) diff --git a/src/ipc.rs b/src/ipc.rs index 646e1b9..9f6787d 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -120,7 +120,7 @@ impl IpcCoordinator { pakku_path } else { return Err(IpcError::PakkuJsonReadFailed( - "pakku.json not found in working directory".to_string(), + "pakker.json or pakku.json not found in working directory".to_string(), )); }; diff --git a/src/platform/github.rs b/src/platform/github.rs index b4a7460..7b8b54a 100644 --- a/src/platform/github.rs +++ b/src/platform/github.rs @@ -533,10 +533,7 @@ mod tests { for (tag, asset, expected) in cases { let result = extract_mc_versions(tag, asset); - assert_eq!( - result, expected, - "Failed for tag: {tag}, asset: {asset}" - ); + assert_eq!(result, expected, "Failed for tag: {tag}, asset: {asset}"); } } @@ -567,10 +564,7 @@ mod tests { for (tag, asset, expected) in cases { let result = extract_loaders(tag, asset); - assert_eq!( - result, expected, - "Failed for tag: {tag}, asset: {asset}" - ); + assert_eq!(result, expected, "Failed for tag: {tag}, asset: {asset}"); } } diff --git a/src/platform/multiplatform.rs b/src/platform/multiplatform.rs index 734c633..54dac2d 100644 --- a/src/platform/multiplatform.rs +++ b/src/platform/multiplatform.rs @@ -78,21 +78,22 @@ impl PlatformClient for MultiplatformPlatform { let mut cf_project = cf_project; let mut mr_project = mr_project; - let mr_found_and_cf_missing = mr_project.is_some() && cf_project.is_none(); - if mr_found_and_cf_missing + // Cross-reference using each platform's own slug on the other platform. + // Modrinth projects store their slug under "modrinth"; CurseForge under + // "curseforge". Many mods share the same slug across platforms. + if cf_project.is_none() && let Some(ref mr) = mr_project - && let Some(cf_slug) = mr.slug.get("curseforge") + && let Some(mr_slug) = mr.slug.get("modrinth") && let Ok(Some(cf)) = - self.curseforge.request_project_from_slug(cf_slug).await + self.curseforge.request_project_from_slug(mr_slug).await { cf_project = Some(cf); } - let cf_found_and_mr_missing = cf_project.is_some() && mr_project.is_none(); - if cf_found_and_mr_missing + if mr_project.is_none() && let Some(ref cf) = cf_project - && let Some(mr_slug) = cf.slug.get("modrinth") + && let Some(cf_slug) = cf.slug.get("curseforge") && let Ok(Some(mr)) = - self.modrinth.request_project_from_slug(mr_slug).await + self.modrinth.request_project_from_slug(cf_slug).await { mr_project = Some(mr); } @@ -232,3 +233,183 @@ impl PlatformClient for MultiplatformPlatform { Ok(all_projects) } } + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, sync::Arc}; + + use async_trait::async_trait; + + use super::*; + use crate::{ + error::{PakkerError, Result}, + model::{Project, ProjectFile, ProjectSide, ProjectType}, + }; + + struct MockPlatform { + projects: HashMap, + slug_map: HashMap, + } + + impl MockPlatform { + fn new() -> Self { + Self { + projects: HashMap::new(), + slug_map: HashMap::new(), + } + } + + fn with_project(mut self, id: &str, project: Project) -> Self { + self.projects.insert(id.to_string(), project); + self + } + + fn with_slug(mut self, slug: &str, project: Project) -> Self { + self.slug_map.insert(slug.to_string(), project); + self + } + } + + #[async_trait] + impl PlatformClient for MockPlatform { + async fn request_project( + &self, + project_id: &str, + _mc_versions: &[String], + _loaders: &[String], + ) -> Result { + self + .projects + .get(project_id) + .cloned() + .ok_or_else(|| PakkerError::ProjectNotFound(project_id.to_string())) + } + + async fn request_project_files( + &self, + _project_id: &str, + _mc_versions: &[String], + _loaders: &[String], + ) -> Result> { + Ok(vec![]) + } + + async fn request_project_with_files( + &self, + project_id: &str, + mc_versions: &[String], + loaders: &[String], + ) -> Result { + self.request_project(project_id, mc_versions, loaders).await + } + + async fn lookup_by_hash(&self, _hash: &str) -> Result> { + Ok(None) + } + + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result> { + Ok(self.slug_map.get(slug).cloned()) + } + + async fn request_projects_from_hashes( + &self, + _hashes: &[String], + _algorithm: &str, + ) -> Result> { + Ok(vec![]) + } + } + + fn make_project(platform: &str, id: &str, slug: &str) -> Project { + let mut project = + Project::new(slug.to_string(), ProjectType::Mod, ProjectSide::Both); + project.id.insert(platform.to_string(), id.to_string()); + project.slug.insert(platform.to_string(), slug.to_string()); + project.name.insert(platform.to_string(), slug.to_string()); + project + } + + #[tokio::test] + async fn test_cross_reference_modrinth_to_curseforge() { + let mr_project = make_project("modrinth", "mr-abc", "sodium"); + let cf_project = make_project("curseforge", "12345", "sodium"); + + let modrinth = + Arc::new(MockPlatform::new().with_project("sodium", mr_project.clone())); + let curseforge = + Arc::new(MockPlatform::new().with_slug("sodium", cf_project.clone())); + + let platform = MultiplatformPlatform::new(curseforge, modrinth); + let result = platform.request_project("sodium", &[], &[]).await.unwrap(); + + assert_eq!(result.id.get("modrinth"), Some(&"mr-abc".to_string())); + assert_eq!(result.id.get("curseforge"), Some(&"12345".to_string())); + } + + #[tokio::test] + async fn test_cross_reference_curseforge_to_modrinth() { + let cf_project = make_project("curseforge", "12345", "sodium"); + let mr_project = make_project("modrinth", "mr-abc", "sodium"); + + let modrinth = + Arc::new(MockPlatform::new().with_slug("sodium", mr_project.clone())); + let curseforge = + Arc::new(MockPlatform::new().with_project("sodium", cf_project.clone())); + + let platform = MultiplatformPlatform::new(curseforge, modrinth); + let result = platform.request_project("sodium", &[], &[]).await.unwrap(); + + assert_eq!(result.id.get("curseforge"), Some(&"12345".to_string())); + assert_eq!(result.id.get("modrinth"), Some(&"mr-abc".to_string())); + } + + #[tokio::test] + async fn test_found_on_both_platforms_merged() { + let cf_project = make_project("curseforge", "12345", "sodium"); + let mr_project = make_project("modrinth", "mr-abc", "sodium"); + + let modrinth = + Arc::new(MockPlatform::new().with_project("sodium", mr_project)); + let curseforge = + Arc::new(MockPlatform::new().with_project("sodium", cf_project)); + + let platform = MultiplatformPlatform::new(curseforge, modrinth); + let result = platform.request_project("sodium", &[], &[]).await.unwrap(); + + assert_eq!(result.id.get("curseforge"), Some(&"12345".to_string())); + assert_eq!(result.id.get("modrinth"), Some(&"mr-abc".to_string())); + } + + #[tokio::test] + async fn test_not_found_on_either_platform() { + let modrinth = Arc::new(MockPlatform::new()); + let curseforge = Arc::new(MockPlatform::new()); + + let platform = MultiplatformPlatform::new(curseforge, modrinth); + let result = platform.request_project("nonexistent", &[], &[]).await; + + assert!(matches!(result, Err(PakkerError::ProjectNotFound(_)))); + } + + #[tokio::test] + async fn test_no_cross_reference_when_slug_absent() { + // CurseForge returns a project, but slug lookup on Modrinth finds nothing + let cf_project = make_project("curseforge", "12345", "rare-mod"); + + let modrinth = Arc::new(MockPlatform::new()); + let curseforge = + Arc::new(MockPlatform::new().with_project("rare-mod", cf_project)); + + let platform = MultiplatformPlatform::new(curseforge, modrinth); + let result = platform + .request_project("rare-mod", &[], &[]) + .await + .unwrap(); + + assert_eq!(result.id.get("curseforge"), Some(&"12345".to_string())); + assert!(result.id.get("modrinth").is_none()); + } +}