use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; use super::traits::PlatformClient; use crate::{ error::{PakkerError, Result}, model::{Project, ProjectFile, ProjectSide, ProjectType, ReleaseType}, utils::generate_pakku_id, }; const CURSEFORGE_API_BASE: &str = "https://api.curseforge.com/v1"; /// CurseForge game version type ID for loader versions (e.g., "fabric", /// "forge") const LOADER_VERSION_TYPE_ID: i32 = 68441; /// CurseForge relation type ID for "required dependency" (mod embeds or /// requires another mod) const DEPENDENCY_RELATION_TYPE_REQUIRED: u32 = 3; pub struct CurseForgePlatform { client: Arc, api_key: Option, } impl CurseForgePlatform { pub fn new(api_key: Option) -> Self { Self { client: Arc::new(Client::new()), api_key, } } pub fn with_client(client: Arc, api_key: Option) -> Self { Self { client, api_key } } fn get_headers(&self) -> Result { let mut headers = reqwest::header::HeaderMap::new(); if let Some(api_key) = &self.api_key { headers.insert( "x-api-key", reqwest::header::HeaderValue::from_str(api_key).map_err(|_| { PakkerError::ConfigError("Invalid API key".to_string()) })?, ); } else { return Err(PakkerError::ConfigError( "CurseForge API key required".to_string(), )); } Ok(headers) } const fn map_class_id(class_id: u32) -> ProjectType { match class_id { 6 => ProjectType::Mod, 12 => ProjectType::ResourcePack, 6945 => ProjectType::DataPack, 6552 => ProjectType::Shader, 17 => ProjectType::World, _ => ProjectType::Mod, } } const fn map_release_type(release_type: u32) -> ReleaseType { match release_type { 1 => ReleaseType::Release, 2 => ReleaseType::Beta, 3 => ReleaseType::Alpha, _ => ReleaseType::Release, } } /// Determine project side based on `CurseForge` categories. /// `CurseForge` doesn't have explicit client/server fields like Modrinth, /// so we infer from category names and IDs. fn detect_side_from_categories( categories: &[CurseForgeCategory], ) -> ProjectSide { // Known client-only category indicators (slugs and partial name matches) const CLIENT_INDICATORS: &[&str] = &[ "client", "hud", "gui", "cosmetic", "shader", "optifine", "resource-pack", "texture", "minimap", "tooltip", "inventory", "quality-of-life", // Often client-side QoL ]; // Known server-only category indicators const SERVER_INDICATORS: &[&str] = &[ "server-utility", "bukkit", "spigot", "paper", "admin-tools", "anti-grief", "economy", "permissions", "chat", ]; let mut client_score = 0; let mut server_score = 0; for category in categories { let slug_lower = category.slug.to_lowercase(); let name_lower = category.name.to_lowercase(); for indicator in CLIENT_INDICATORS { if slug_lower.contains(indicator) || name_lower.contains(indicator) { client_score += 1; } } for indicator in SERVER_INDICATORS { if slug_lower.contains(indicator) || name_lower.contains(indicator) { server_score += 1; } } } // Only assign a specific side if there's clear indication // and not conflicting signals if client_score > 0 && server_score == 0 { ProjectSide::Client } else if server_score > 0 && client_score == 0 { ProjectSide::Server } else { // Default to Both - works on both client and server ProjectSide::Both } } fn convert_project(&self, cf_project: CurseForgeProject) -> Project { let pakku_id = generate_pakku_id(); let project_type = Self::map_class_id(cf_project.class_id.unwrap_or(6)); // Detect side from categories let side = Self::detect_side_from_categories(&cf_project.categories); let mut project = Project::new(pakku_id, project_type, side); project.add_platform( "curseforge".to_string(), cf_project.id.to_string(), cf_project.slug.clone(), cf_project.name, ); project.redistributable = false; project } fn convert_file( &self, cf_file: CurseForgeFile, project_id: &str, ) -> ProjectFile { let mut hashes = HashMap::new(); for hash in cf_file.hashes { hashes.insert(hash.algo.to_lowercase(), hash.value.clone()); } let mc_versions: Vec = cf_file.game_versions.clone(); // Extract loaders from sortableGameVersions with LOADER_VERSION_TYPE_ID let loaders: Vec = cf_file .sortable_game_versions .iter() .filter(|v| v.game_version_type_id == Some(LOADER_VERSION_TYPE_ID)) .map(|v| v.game_version_name.to_lowercase()) .collect(); ProjectFile { file_type: "mod".to_string(), file_name: cf_file.file_name.clone(), mc_versions, loaders, release_type: Self::map_release_type(cf_file.release_type.unwrap_or(1)), url: cf_file.download_url.clone().unwrap_or_else(|| { format!( "https://edge.forgecdn.net/files/{}/{}/{}", cf_file.id / 1000, cf_file.id % 1000, cf_file.file_name ) }), id: cf_file.id.to_string(), parent_id: project_id.to_string(), hashes, required_dependencies: cf_file .dependencies .iter() .filter(|d| d.relation_type == DEPENDENCY_RELATION_TYPE_REQUIRED) .map(|d| d.mod_id.to_string()) .collect(), size: cf_file.file_length, date_published: cf_file.file_date.clone(), } } async fn search_project_by_slug( &self, slug: &str, ) -> Result { let url = format!("{CURSEFORGE_API_BASE}/mods/search?gameId=432&slug={slug}"); let response = self .client .get(&url) .headers(self.get_headers()?) .send() .await?; if !response.status().is_success() { return Err(PakkerError::ProjectNotFound(slug.to_string())); } let result: CurseForgeSearchResponse = response.json().await?; result .data .into_iter() .find(|p| p.slug == slug) .ok_or_else(|| PakkerError::ProjectNotFound(slug.to_string())) } } #[async_trait] impl PlatformClient for CurseForgePlatform { async fn request_project( &self, identifier: &str, _mc_versions: &[String], _loaders: &[String], ) -> Result { if let Ok(mod_id) = identifier.parse::() { let url = format!("{CURSEFORGE_API_BASE}/mods/{mod_id}"); let response = self .client .get(&url) .headers(self.get_headers()?) .send() .await?; if response.status().is_success() { let result: CurseForgeProjectResponse = response.json().await?; return Ok(self.convert_project(result.data)); } } let cf_project = self.search_project_by_slug(identifier).await?; Ok(self.convert_project(cf_project)) } async fn request_project_files( &self, project_id: &str, mc_versions: &[String], loaders: &[String], ) -> Result> { let mut url = format!("{CURSEFORGE_API_BASE}/mods/{project_id}/files"); // Add query parameters for server-side filtering (Pakku-compatible) let mut query_params = Vec::new(); // Add gameVersionTypeId for each MC version (requires lookup) if !mc_versions.is_empty() { // Fetch game version type IDs // Add MC version gameVersionTypeId = 73250 for Minecraft versions for mc_version in mc_versions { query_params.push(("gameVersion", mc_version.clone())); } query_params.push(("gameVersionTypeId", "73250".to_string())); } // Add mod loader types if !loaders.is_empty() { let loader_str = loaders.join(","); query_params.push(("modLoaderTypes", loader_str)); } if !query_params.is_empty() { let query_string = query_params .iter() .map(|(k, v)| format!("{k}={v}")) .collect::>() .join("&"); url = format!("{url}?{query_string}"); } let response = self .client .get(&url) .headers(self.get_headers()?) .send() .await?; if !response.status().is_success() { return Err(PakkerError::ProjectNotFound(project_id.to_string())); } let result: CurseForgeFilesResponse = response.json().await?; let files: Vec = result .data .into_iter() .map(|f| self.convert_file(f, project_id)) .collect(); Ok(files) } async fn request_project_with_files( &self, identifier: &str, mc_versions: &[String], loaders: &[String], ) -> Result { let mut project = self .request_project(identifier, mc_versions, loaders) .await?; let project_id = project .get_platform_id("curseforge") .ok_or_else(|| { PakkerError::InternalError("Missing curseforge ID".to_string()) })? .clone(); let files = self .request_project_files(&project_id, mc_versions, loaders) .await?; project.files = files; Ok(project) } async fn lookup_by_hash(&self, hash: &str) -> Result> { // CurseForge uses Murmur2 hash for file fingerprints let fingerprint = hash .parse::() .map_err(|_| PakkerError::InvalidHash(hash.to_string()))?; let url = format!("{CURSEFORGE_API_BASE}/fingerprints"); let response = self .client .post(&url) .headers(self.get_headers()?) .json(&serde_json::json!({ "fingerprints": [fingerprint] })) .send() .await?; if !response.status().is_success() { return Ok(None); } let response_data: serde_json::Value = response.json().await?; if let Some(matches) = response_data["data"]["exactMatches"].as_array() && let Some(first_match) = matches.first() && let Some(file) = first_match["file"].as_object() { let mod_id = file["modId"] .as_u64() .ok_or_else(|| { PakkerError::InvalidResponse("Missing modId".to_string()) })? .to_string(); return self .request_project_with_files(&mod_id, &[], &[]) .await .map(Some); } 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), } } /// Uses CurseForge's `/fingerprints/432` endpoint to resolve projects by /// their hashes in batch. async fn request_projects_from_hashes( &self, hashes: &[String], _algorithm: &str, ) -> Result> { if hashes.is_empty() { return Ok(Vec::new()); } let fingerprints: Vec = hashes .iter() .filter_map(|h| h.parse::().ok()) .collect(); if fingerprints.is_empty() { return Ok(Vec::new()); } #[derive(Serialize)] struct FingerprintRequest { fingerprints: Vec, } let url = format!("{CURSEFORGE_API_BASE}/fingerprints/432"); let response = self .client .post(&url) .headers(self.get_headers()?) .json(&FingerprintRequest { fingerprints: fingerprints.clone(), }) .send() .await?; if !response.status().is_success() { return Err(PakkerError::PlatformApiError(format!( "CurseForge batch API error: {}", response.status() ))); } let response_data: serde_json::Value = response.json().await?; let matches = response_data["data"]["exactMatches"] .as_array() .cloned() .unwrap_or_default(); let mut projects = Vec::new(); let mut seen_ids = std::collections::HashSet::new(); for m in matches { if let Some(file) = m["file"].as_object() { if let Some(mod_id) = file["modId"].as_u64() { let mod_id_str = mod_id.to_string(); if seen_ids.contains(&mod_id_str) { continue; } seen_ids.insert(mod_id_str.clone()); if let Ok(project) = self.request_project_with_files(&mod_id_str, &[], &[]).await { projects.push(project); } } } } Ok(projects) } } // CurseForge API models #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeProject { id: u32, name: String, slug: String, #[serde(rename = "classId")] class_id: Option, #[serde(default)] categories: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeCategory { id: u32, name: String, slug: String, } #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeGameVersion { #[serde(rename = "gameVersionName")] game_version_name: String, #[serde(rename = "gameVersionTypeId")] game_version_type_id: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeHash { algo: String, value: String, } #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeDependency { #[serde(rename = "modId")] mod_id: u32, #[serde(rename = "relationType")] relation_type: u32, } #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeProjectResponse { data: CurseForgeProject, } #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeFile { id: u32, #[serde(rename = "fileName")] file_name: String, #[serde(rename = "downloadUrl")] download_url: Option, #[serde(rename = "gameVersions")] game_versions: Vec, #[serde(rename = "sortableGameVersions")] sortable_game_versions: Vec, #[serde(rename = "releaseType")] release_type: Option, #[serde(rename = "fileLength")] file_length: u64, #[serde(rename = "fileDate")] file_date: String, hashes: Vec, dependencies: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeFilesResponse { data: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeSearchResponse { data: Vec, } #[cfg(test)] mod tests { use super::*; fn make_category(id: u32, name: &str, slug: &str) -> CurseForgeCategory { CurseForgeCategory { id, name: name.to_string(), slug: slug.to_string(), } } #[test] fn test_detect_side_client_only() { // HUD mod should be client-only let categories = vec![ make_category(1, "HUD Mods", "hud"), make_category(2, "Fabric", "fabric"), ]; let side = CurseForgePlatform::detect_side_from_categories(&categories); assert_eq!(side, ProjectSide::Client); } #[test] fn test_detect_side_server_only() { // Server utility should be server-only let categories = vec![ make_category(1, "Server Utility", "server-utility"), make_category(2, "Bukkit Plugins", "bukkit"), ]; let side = CurseForgePlatform::detect_side_from_categories(&categories); assert_eq!(side, ProjectSide::Server); } #[test] fn test_detect_side_both() { // Generic mod categories should be both let categories = vec![ make_category(1, "Technology", "technology"), make_category(2, "Fabric", "fabric"), ]; let side = CurseForgePlatform::detect_side_from_categories(&categories); assert_eq!(side, ProjectSide::Both); } #[test] fn test_detect_side_conflicting_signals() { // Mixed categories should default to both let categories = vec![ make_category(1, "Client HUD", "client-hud"), make_category(2, "Server Utility", "server-utility"), ]; let side = CurseForgePlatform::detect_side_from_categories(&categories); assert_eq!(side, ProjectSide::Both); } #[test] fn test_detect_side_empty_categories() { let categories = vec![]; let side = CurseForgePlatform::detect_side_from_categories(&categories); assert_eq!(side, ProjectSide::Both); } #[test] fn test_detect_side_gui_client() { let categories = vec![make_category(1, "GUI Enhancement", "gui-enhancement")]; let side = CurseForgePlatform::detect_side_from_categories(&categories); assert_eq!(side, ProjectSide::Client); } #[test] fn test_detect_side_permissions_server() { let categories = vec![make_category(1, "Permissions", "permissions")]; let side = CurseForgePlatform::detect_side_from_categories(&categories); assert_eq!(side, ProjectSide::Server); } #[test] fn test_map_class_id() { assert_eq!(CurseForgePlatform::map_class_id(6), ProjectType::Mod); assert_eq!( CurseForgePlatform::map_class_id(12), ProjectType::ResourcePack ); assert_eq!( CurseForgePlatform::map_class_id(6945), ProjectType::DataPack ); assert_eq!(CurseForgePlatform::map_class_id(6552), ProjectType::Shader); assert_eq!(CurseForgePlatform::map_class_id(17), ProjectType::World); assert_eq!(CurseForgePlatform::map_class_id(9999), ProjectType::Mod); // Unknown } #[test] fn test_map_release_type() { assert_eq!( CurseForgePlatform::map_release_type(1), ReleaseType::Release ); assert_eq!(CurseForgePlatform::map_release_type(2), ReleaseType::Beta); assert_eq!(CurseForgePlatform::map_release_type(3), ReleaseType::Alpha); assert_eq!( CurseForgePlatform::map_release_type(99), ReleaseType::Release ); // Unknown } }