diff --git a/src/platform.rs b/src/platform.rs index af4a9a0..e64ebc1 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -8,13 +8,12 @@ use std::sync::Arc; pub use curseforge::CurseForgePlatform; pub use github::GitHubPlatform; pub use modrinth::ModrinthPlatform; -use once_cell::sync::Lazy; pub use traits::PlatformClient; use crate::{error::Result, rate_limiter::RateLimiter}; -static RATE_LIMITER: Lazy> = - Lazy::new(|| Arc::new(RateLimiter::new(None))); +static RATE_LIMITER: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Arc::new(RateLimiter::new(None))); pub fn create_platform( platform: &str, diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index 5419501..a08c191 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -66,11 +66,81 @@ impl CurseForgePlatform { } } + /// 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)); - let mut project = Project::new(pakku_id, project_type, ProjectSide::Both); + // 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(), @@ -317,11 +387,20 @@ impl PlatformClient for CurseForgePlatform { // CurseForge API models #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeProject { - id: u32, - name: String, - slug: String, + id: u32, + name: String, + slug: String, #[serde(rename = "classId")] - class_id: Option, + 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)] @@ -381,3 +460,112 @@ struct CurseForgeFilesResponse { 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 + } +}