diff --git a/src/platform.rs b/src/platform.rs index c9e0589..da04627 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -1,6 +1,7 @@ mod curseforge; mod github; mod modrinth; +mod multiplatform; mod traits; use std::sync::Arc; @@ -8,6 +9,7 @@ use std::sync::Arc; pub use curseforge::CurseForgePlatform; pub use github::GitHubPlatform; pub use modrinth::ModrinthPlatform; +pub use multiplatform::MultiplatformPlatform; pub use traits::PlatformClient; use crate::{error::Result, http, rate_limiter::RateLimiter}; @@ -55,6 +57,14 @@ fn create_client( api_key, ))) }, + "multiplatform" => { + let cf = CurseForgePlatform::with_client(get_http_client(), api_key); + let mr = ModrinthPlatform::with_client(get_http_client()); + Ok(Box::new(MultiplatformPlatform::new( + Arc::new(cf), + Arc::new(mr), + ))) + }, _ => { Err(crate::error::PakkerError::ConfigError(format!( "Unknown platform: {platform}" @@ -117,4 +127,12 @@ impl PlatformClient for RateLimitedPlatform { self.rate_limiter.wait_for(&self.platform_name).await; self.platform.lookup_by_hash(hash).await } + + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result> { + self.rate_limiter.wait_for(&self.platform_name).await; + self.platform.request_project_from_slug(slug).await + } } diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index d36efee..4587fc6 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -391,6 +391,18 @@ impl PlatformClient for CurseForgePlatform { Ok(None) } + + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result> { + // Try to fetch project by slug using search API + match self.search_project_by_slug(slug).await { + Ok(cf_project) => Ok(Some(self.convert_project(cf_project))), + Err(PakkerError::ProjectNotFound(_)) => Ok(None), + Err(e) => Err(e), + } + } } // CurseForge API models diff --git a/src/platform/github.rs b/src/platform/github.rs index 0c7a735..57582ea 100644 --- a/src/platform/github.rs +++ b/src/platform/github.rs @@ -403,6 +403,17 @@ impl PlatformClient for GitHubPlatform { Ok(None) } } + + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result> { + match self.request_project(slug, &[], &[]).await { + Ok(project) => Ok(Some(project)), + Err(PakkerError::ProjectNotFound(_)) => Ok(None), + Err(e) => Err(e), + } + } } // GitHub API models diff --git a/src/platform/modrinth.rs b/src/platform/modrinth.rs index 69f81a2..5663165 100644 --- a/src/platform/modrinth.rs +++ b/src/platform/modrinth.rs @@ -254,6 +254,28 @@ impl PlatformClient for ModrinthPlatform { let url = format!("{MODRINTH_API_BASE}/version_file/{hash}"); self.lookup_by_hash_url(&url).await } + + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result> { + let url = format!("{MODRINTH_API_BASE}/project/{slug}"); + let response = self.client.get(&url).send().await?; + + if response.status().as_u16() == 404 { + return Ok(None); + } + + if !response.status().is_success() { + return Err(PakkerError::PlatformApiError(format!( + "Modrinth API error: {}", + response.status() + ))); + } + + let mr_project: ModrinthProject = response.json().await?; + Ok(Some(self.convert_project(mr_project))) + } } // Modrinth API models diff --git a/src/platform/multiplatform.rs b/src/platform/multiplatform.rs new file mode 100644 index 0000000..21a76ee --- /dev/null +++ b/src/platform/multiplatform.rs @@ -0,0 +1,205 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use super::traits::PlatformClient; +use crate::{ + error::{PakkerError, Result}, + model::{Project, ProjectFile}, +}; + +/// Multiplatform platform client that aggregates CurseForge and Modrinth. +/// It attempts to resolve projects on both platforms and cross-references +/// them via slugs when a project exists on only one platform. +pub struct MultiplatformPlatform { + curseforge: Arc, + modrinth: Arc, +} + +impl MultiplatformPlatform { + pub fn new( + curseforge: Arc, + modrinth: Arc, + ) -> Self { + Self { + curseforge, + modrinth, + } + } + + /// Try to fetch a project, returning Ok(None) for "not found" errors. + async fn try_request_project( + &self, + client: &Arc, + identifier: &str, + ) -> Result> { + match client.request_project(identifier, &[], &[]).await { + Ok(project) => Ok(Some(project)), + Err(e) => { + let is_not_found = matches!( + e, + PakkerError::ProjectNotFound(_) | PakkerError::InvalidResponse(_) + ); + if is_not_found { Ok(None) } else { Err(e) } + }, + } + } +} + +#[async_trait] +impl PlatformClient for MultiplatformPlatform { + async fn request_project( + &self, + identifier: &str, + _mc_versions: &[String], + _loaders: &[String], + ) -> Result { + // Try both platforms in parallel + let cf_future = self.try_request_project(&self.curseforge, identifier); + let mr_future = self.try_request_project(&self.modrinth, identifier); + + let (cf_result, mr_result) = tokio::join!(cf_future, mr_future); + + // Handle results - extract Options, propagate first error if both fail + let (cf_project, mr_project) = match (cf_result, mr_result) { + (Ok(Some(cf)), Ok(Some(mr))) => (Some(cf), Some(mr)), + (Ok(None), Ok(None)) => (None, None), + (Ok(None), Ok(Some(mr))) => (None, Some(mr)), + (Ok(Some(cf)), Ok(None)) => (Some(cf), None), + (Err(_e), Ok(None)) | (Ok(None), Err(_e)) => (None, None), + (Err(e), Ok(Some(_))) | (Ok(Some(_)), Err(e)) => { + return Err(e); + }, + (Err(e), Err(_)) => return Err(e), + }; + + // Cross-reference: if project exists on only one platform, fetch from the + // other using the slug + 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 + && let Some(ref mr) = mr_project + && let Some(cf_slug) = mr.slug.get("curseforge") + && let Ok(Some(cf)) = + self.curseforge.request_project_from_slug(cf_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 + && let Some(ref cf) = cf_project + && let Some(mr_slug) = cf.slug.get("modrinth") + && let Ok(Some(mr)) = + self.modrinth.request_project_from_slug(mr_slug).await + { + mr_project = Some(mr); + } + + // Merge projects or return whichever was found + let combined = match (cf_project, mr_project) { + (Some(cf), Some(mr)) => cf.merged(mr)?, + (Some(cf), None) => cf, + (None, Some(mr)) => mr, + (None, None) => { + return Err(PakkerError::ProjectNotFound(identifier.to_string())); + }, + }; + + Ok(combined) + } + + async fn request_project_files( + &self, + project_id: &str, + mc_versions: &[String], + loaders: &[String], + ) -> Result> { + // Multiplatform doesn't directly support files - use + // request_project_with_files + let project = self + .request_project_with_files(project_id, mc_versions, loaders) + .await?; + Ok(project.files) + } + + async fn request_project_with_files( + &self, + identifier: &str, + mc_versions: &[String], + loaders: &[String], + ) -> Result { + // First get the combined project from both platforms + let mut project = self + .request_project(identifier, mc_versions, loaders) + .await?; + + // Now fetch files from both platforms in parallel + let cf_project_id = project.id.get("curseforge").cloned(); + let mr_project_id = project.id.get("modrinth").cloned(); + + let cf_files_future = async { + if let Some(ref id) = cf_project_id { + self + .curseforge + .request_project_files(id, mc_versions, loaders) + .await + } else { + Ok(Vec::new()) + } + }; + + let mr_files_future = async { + if let Some(ref id) = mr_project_id { + self + .modrinth + .request_project_files(id, mc_versions, loaders) + .await + } else { + Ok(Vec::new()) + } + }; + + let (cf_files, mr_files) = tokio::join!(cf_files_future, mr_files_future); + + let mut all_files = cf_files?; + all_files.extend(mr_files?); + + project.files = all_files; + Ok(project) + } + + async fn lookup_by_hash(&self, hash: &str) -> Result> { + // Try both platforms in parallel + let cf_future = self.curseforge.lookup_by_hash(hash); + let mr_future = self.modrinth.lookup_by_hash(hash); + + let (cf_result, mr_result) = tokio::join!(cf_future, mr_future); + + match (cf_result?, mr_result?) { + (Some(cf), Some(mr)) => cf.merged(mr).map(Some), + (Some(project), None) | (None, Some(project)) => Ok(Some(project)), + (None, None) => Ok(None), + } + } + + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result> { + let cf_future = self.curseforge.request_project_from_slug(slug); + let mr_future = self.modrinth.request_project_from_slug(slug); + + let (cf_result, mr_result) = tokio::join!(cf_future, mr_future); + + match (cf_result, mr_result) { + (Ok(Some(cf)), Ok(Some(mr))) => cf.merged(mr).map(Some), + (Ok(Some(project)), Ok(None)) | (Ok(None), Ok(Some(project))) => { + Ok(Some(project)) + }, + (Ok(None), Ok(None)) => Ok(None), + (Err(e), _) | (_, Err(e)) => Err(e), + } + } +} diff --git a/src/platform/traits.rs b/src/platform/traits.rs index 598a72a..db115c9 100644 --- a/src/platform/traits.rs +++ b/src/platform/traits.rs @@ -29,4 +29,12 @@ pub trait PlatformClient: Send + Sync { ) -> Result; async fn lookup_by_hash(&self, hash: &str) -> Result>; + + /// Request a project using its platform-specific slug. + /// This is used by Multiplatform to cross-reference projects between + /// platforms. + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result>; }