use std::collections::HashMap; 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 MODRINTH_API_BASE: &str = "https://api.modrinth.com/v2"; pub struct ModrinthPlatform { client: Client, } impl ModrinthPlatform { pub fn new() -> Self { Self { client: Client::new(), } } async fn request_project_url(&self, url: &str) -> Result { let response = self.client.get(url).send().await?; if !response.status().is_success() { return Err(PakkerError::ProjectNotFound(url.to_string())); } let mr_project: ModrinthProject = response.json().await?; Ok(self.convert_project(mr_project)) } async fn request_project_files_url( &self, url: &str, ) -> Result> { let response = self.client.get(url).send().await?; if !response.status().is_success() { return Err(PakkerError::ProjectNotFound(url.to_string())); } let mr_versions: Vec = response.json().await?; let project_id = url .split('/') .nth(4) .ok_or_else(|| { PakkerError::InvalidResponse( "Cannot parse project ID from URL".to_string(), ) })? .to_string(); Ok( mr_versions .into_iter() .map(|v| self.convert_version(v, &project_id)) .collect(), ) } async fn lookup_by_hash_url(&self, url: &str) -> Result> { 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 version_data: serde_json::Value = response.json().await?; let project_id = version_data["project_id"].as_str().ok_or_else(|| { PakkerError::InvalidResponse("Missing project_id".to_string()) })?; self .request_project_with_files(project_id, &[], &[]) .await .map(Some) } fn map_project_type(type_str: &str) -> ProjectType { match type_str { "mod" => ProjectType::Mod, "resourcepack" => ProjectType::ResourcePack, "datapack" => ProjectType::DataPack, "shader" => ProjectType::Shader, _ => ProjectType::Mod, } } const fn map_side(client: bool, server: bool) -> ProjectSide { match (client, server) { (true, true) => ProjectSide::Both, (true, false) => ProjectSide::Client, (false, true) => ProjectSide::Server, _ => ProjectSide::Both, } } fn map_release_type(type_str: &str) -> ReleaseType { match type_str { "release" => ReleaseType::Release, "beta" => ReleaseType::Beta, "alpha" => ReleaseType::Alpha, _ => ReleaseType::Release, } } fn convert_project(&self, mr_project: ModrinthProject) -> Project { let pakku_id = generate_pakku_id(); let mut project = Project::new( pakku_id, Self::map_project_type(&mr_project.project_type), Self::map_side( mr_project.client_side != "unsupported", mr_project.server_side != "unsupported", ), ); project.add_platform( "modrinth".to_string(), mr_project.id.clone(), mr_project.slug.clone(), mr_project.title, ); project } fn convert_version( &self, mr_version: ModrinthVersion, project_id: &str, ) -> ProjectFile { let mut hashes = HashMap::new(); // Get primary file let primary_file = mr_version .files .iter() .find(|f| f.primary) .or_else(|| mr_version.files.first()) .expect("Version must have at least one file"); for (algo, hash) in &primary_file.hashes { hashes.insert(algo.clone(), hash.clone()); } ProjectFile { file_type: "mod".to_string(), file_name: primary_file.filename.clone(), mc_versions: mr_version.game_versions.clone(), loaders: mr_version.loaders.clone(), release_type: Self::map_release_type(&mr_version.version_type), url: primary_file.url.clone(), id: mr_version.id.clone(), parent_id: project_id.to_string(), hashes, required_dependencies: mr_version .dependencies .iter() .filter(|d| d.dependency_type == "required") .filter_map(|d| d.project_id.clone()) .collect(), size: primary_file.size, date_published: mr_version.date_published.clone(), } } } #[async_trait] impl PlatformClient for ModrinthPlatform { async fn request_project( &self, identifier: &str, _mc_versions: &[String], _loaders: &[String], ) -> Result { let url = format!("{MODRINTH_API_BASE}/project/{identifier}"); self.request_project_url(&url).await } async fn request_project_files( &self, project_id: &str, mc_versions: &[String], loaders: &[String], ) -> Result> { let mut url = format!("{MODRINTH_API_BASE}/project/{project_id}/version"); // Add query parameters let mut params = vec![]; if !mc_versions.is_empty() { params.push(format!( "game_versions=[{}]", mc_versions .iter() .map(|v| format!("\"{v}\"")) .collect::>() .join(",") )); } if !loaders.is_empty() { params.push(format!( "loaders=[{}]", loaders .iter() .map(|l| format!("\"{l}\"")) .collect::>() .join(",") )); } if !params.is_empty() { url.push('?'); url.push_str(¶ms.join("&")); } self.request_project_files_url(&url).await } 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("modrinth") .ok_or_else(|| { PakkerError::InternalError("Missing modrinth 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> { // Modrinth uses SHA-1 hash for file lookups let url = format!("{MODRINTH_API_BASE}/version_file/{hash}"); self.lookup_by_hash_url(&url).await } } // Modrinth API models #[derive(Debug, Clone, Deserialize, Serialize)] struct ModrinthProject { id: String, slug: String, title: String, #[serde(rename = "project_type")] project_type: String, client_side: String, server_side: String, } #[derive(Debug, Clone, Deserialize, Serialize)] struct ModrinthVersion { id: String, project_id: String, name: String, version_number: String, game_versions: Vec, version_type: String, loaders: Vec, date_published: String, files: Vec, dependencies: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] struct ModrinthFile { hashes: HashMap, url: String, filename: String, primary: bool, size: u64, } #[derive(Debug, Clone, Deserialize, Serialize)] struct ModrinthDependency { project_id: Option, dependency_type: String, } #[cfg(test)] mod tests { use reqwest::Client; use super::*; impl ModrinthPlatform { fn with_client(client: Client) -> Self { Self { client } } } async fn create_platform_with_mock() -> (ModrinthPlatform, mockito::ServerGuard) { let server = mockito::Server::new_async().await; let client = Client::new(); let platform = ModrinthPlatform::with_client(client); (platform, server) } #[tokio::test] async fn test_request_project_success() { let (platform, mut server) = create_platform_with_mock().await; let url = format!("{}/project/test-mod", server.url()); let _mock = server .mock("GET", "/project/test-mod") .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{ "id": "abc123", "slug": "test-mod", "title": "Test Mod", "project_type": "mod", "client_side": "required", "server_side": "required" }"#, ) .create(); let result = platform.request_project_url(&url).await; assert!(result.is_ok()); let project = result.unwrap(); assert!(project.get_platform_id("modrinth").is_some()); } #[tokio::test] async fn test_request_project_not_found() { let (platform, mut server) = create_platform_with_mock().await; let url = format!("{}/project/nonexistent", server.url()); let _mock = server .mock("GET", "/project/nonexistent") .with_status(404) .create(); let result = platform.request_project_url(&url).await; assert!(result.is_err()); } #[tokio::test] async fn test_request_project_files() { let (platform, mut server) = create_platform_with_mock().await; let url = format!("{}/project/abc123/version", server.url()); let _mock = server .mock("GET", "/project/abc123/version") .with_status(200) .with_header("content-type", "application/json") .with_body( r#"[ { "id": "v1", "project_id": "abc123", "name": "Test Mod v1.0.0", "version_number": "1.0.0", "game_versions": ["1.20.1"], "version_type": "release", "loaders": ["fabric"], "date_published": "2024-01-01T00:00:00Z", "files": [{ "hashes": {"sha1": "abc123def456"}, "url": "https://example.com/mod.jar", "filename": "test-mod-1.0.0.jar", "primary": true, "size": 1024 }], "dependencies": [] } ]"#, ) .create(); let result = platform.request_project_files_url(&url).await; assert!(result.is_ok()); let files = result.unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].file_name, "test-mod-1.0.0.jar"); } #[tokio::test] async fn test_lookup_by_hash_not_found() { let (platform, mut server) = create_platform_with_mock().await; let url = format!("{}/version_file/unknownhash123", server.url()); let _mock = server .mock("GET", "/version_file/unknownhash123") .with_status(404) .create(); let result = platform.lookup_by_hash_url(&url).await; assert!(result.is_ok()); assert!(result.unwrap().is_none()); } }