platform: add CurseForge side detection from categories

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I62c5117ed97bbc2389330720b4761a716a6a6964
This commit is contained in:
raf 2026-02-12 23:22:16 +03:00
commit 27160a1eda
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
2 changed files with 195 additions and 8 deletions

View file

@ -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<Arc<RateLimiter>> =
Lazy::new(|| Arc::new(RateLimiter::new(None)));
static RATE_LIMITER: std::sync::LazyLock<Arc<RateLimiter>> =
std::sync::LazyLock::new(|| Arc::new(RateLimiter::new(None)));
pub fn create_platform(
platform: &str,

View file

@ -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<u32>,
class_id: Option<u32>,
#[serde(default)]
categories: Vec<CurseForgeCategory>,
}
#[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<CurseForgeProject>,
}
#[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
}
}