use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use regex::Regex; 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 GITHUB_API_BASE: &str = "https://api.github.com"; pub struct GitHubPlatform { client: Client, token: Option, } impl GitHubPlatform { pub fn with_client(client: Arc, token: Option) -> Self { Self { client: (*client).clone(), token, } } fn get_headers(&self) -> Result { let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_static("Pakker"), ); if let Some(token) = &self.token { headers.insert( reqwest::header::AUTHORIZATION, reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) .map_err(|_| { PakkerError::ConfigError("Invalid GitHub token".to_string()) })?, ); } Ok(headers) } fn parse_repo_identifier(identifier: &str) -> Result<(String, String)> { // Expected formats: // - "owner/repo" // - "github:owner/repo" // - "https://github.com/owner/repo" let identifier = identifier .trim_start_matches("github:") .trim_start_matches("https://github.com/") .trim_start_matches("http://github.com/") .trim_end_matches(".git"); let parts: Vec<&str> = identifier.split('/').collect(); if parts.len() >= 2 { Ok((parts[0].to_string(), parts[1].to_string())) } else { Err(PakkerError::InvalidInput(format!( "Invalid GitHub repository identifier: {identifier}" ))) } } fn convert_release( &self, owner: &str, repo: &str, release: GitHubRelease, ) -> Project { let pakku_id = generate_pakku_id(); let mut project = Project::new(pakku_id, ProjectType::Mod, ProjectSide::Both); let repo_full = format!("{owner}/{repo}"); project.add_platform( "github".to_string(), repo_full.clone(), repo_full, release.name.unwrap_or_else(|| repo.to_string()), ); project } } // Helper functions for extracting metadata from GitHub releases fn extract_mc_versions(tag: &str, asset_name: &str) -> Vec { let re = Regex::new(r"(?:^|[^\d.])(\d+\.\d+(?:\.\d+)?)(?:[^\d]|$)").unwrap(); let mut versions = Vec::new(); log::debug!("Extracting MC versions from tag='{tag}', asset='{asset_name}'"); for text in &[tag, asset_name] { for cap in re.captures_iter(text) { if let Some(version) = cap.get(1) { let v = version.as_str().to_string(); if !versions.contains(&v) { log::debug!(" Found MC version: {v}"); versions.push(v); } } } } log::debug!("Extracted MC versions: {versions:?}"); versions } fn extract_loaders(tag: &str, asset_name: &str) -> Vec { let mut loaders = Vec::new(); let text = format!("{} {}", tag.to_lowercase(), asset_name.to_lowercase()); log::debug!("Extracting loaders from: '{text}'"); if text.contains("fabric") { log::debug!(" Found loader: fabric"); loaders.push("fabric".to_string()); } if text.contains("forge") && !text.contains("neoforge") { log::debug!(" Found loader: forge"); loaders.push("forge".to_string()); } if text.contains("neoforge") { log::debug!(" Found loader: neoforge"); loaders.push("neoforge".to_string()); } if text.contains("quilt") { log::debug!(" Found loader: quilt"); loaders.push("quilt".to_string()); } log::debug!("Extracted loaders: {loaders:?}"); loaders } fn detect_project_type(asset_name: &str, repo_name: &str) -> ProjectType { let name_lower = asset_name.to_lowercase(); let repo_lower = repo_name.to_lowercase(); // Check for resourcepack indicators if name_lower.contains("resourcepack") || name_lower.contains("resource-pack") || name_lower.contains("texture") || repo_lower.contains("resourcepack") || repo_lower.contains("texture") { return ProjectType::ResourcePack; } // Check for datapack indicators if name_lower.contains("datapack") || name_lower.contains("data-pack") || repo_lower.contains("datapack") { return ProjectType::DataPack; } // Check for shader indicators if name_lower.contains("shader") || repo_lower.contains("shader") { return ProjectType::Shader; } // Check for world/save indicators if name_lower.contains("world") || name_lower.contains("save") || repo_lower.contains("world") { return ProjectType::World; } // Default to mod for .jar files ProjectType::Mod } impl GitHubPlatform { fn convert_asset( &self, asset: GitHubAsset, release: &GitHubRelease, repo_id: &str, repo_name: &str, ) -> ProjectFile { let hashes = HashMap::new(); // Extract MC versions and loaders from tag and asset name let mc_versions = extract_mc_versions(&release.tag_name, &asset.name); let loaders = extract_loaders(&release.tag_name, &asset.name); // Detect project type from asset name and repo let file_type = match detect_project_type(&asset.name, repo_name) { ProjectType::Mod => "mod", ProjectType::ResourcePack => "resourcepack", ProjectType::DataPack => "datapack", ProjectType::Shader => "shader", ProjectType::World => "world", }; ProjectFile { file_type: file_type.to_string(), file_name: asset.name.clone(), mc_versions, loaders, release_type: if release.prerelease { ReleaseType::Beta } else { ReleaseType::Release }, url: asset.browser_download_url.clone(), id: asset.id.to_string(), parent_id: repo_id.to_string(), hashes, required_dependencies: vec![], size: asset.size, date_published: release.published_at.clone().unwrap_or_default(), } } async fn get_latest_release( &self, owner: &str, repo: &str, ) -> Result { let url = format!("{GITHUB_API_BASE}/repos/{owner}/{repo}/releases/latest"); let response = self .client .get(&url) .headers(self.get_headers()?) .send() .await?; if !response.status().is_success() { return Err(PakkerError::ProjectNotFound(format!("{owner}/{repo}"))); } let release: GitHubRelease = response.json().await?; Ok(release) } async fn get_all_releases( &self, owner: &str, repo: &str, ) -> Result> { let url = format!("{GITHUB_API_BASE}/repos/{owner}/{repo}/releases"); let response = self .client .get(&url) .headers(self.get_headers()?) .send() .await?; if !response.status().is_success() { return Err(PakkerError::ProjectNotFound(format!("{owner}/{repo}"))); } let releases: Vec = response.json().await?; Ok(releases) } } #[async_trait] impl PlatformClient for GitHubPlatform { async fn request_project( &self, identifier: &str, _mc_versions: &[String], _loaders: &[String], ) -> Result { let (owner, repo) = Self::parse_repo_identifier(identifier)?; let release = self.get_latest_release(&owner, &repo).await?; Ok(self.convert_release(&owner, &repo, release)) } async fn request_project_files( &self, project_id: &str, _mc_versions: &[String], _loaders: &[String], ) -> Result> { let (owner, repo) = Self::parse_repo_identifier(project_id)?; let releases = self.get_all_releases(&owner, &repo).await?; let mut files = Vec::new(); for release in releases { for asset in &release.assets { // Filter for .jar files (mods) or .zip files (modpacks) if asset.name.ends_with(".jar") || asset.name.ends_with(".zip") { let file = self.convert_asset(asset.clone(), &release, project_id, &repo); files.push(file); } } } 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("github") .ok_or_else(|| { PakkerError::InternalError("Missing github 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> { log::debug!("GitHub lookup_by_hash: searching for hash={hash}"); // GitHub Code Search API: search for files containing the hash // Note: This is rate-limited (10 req/min without auth, 30 req/min with // auth) let url = format!("{GITHUB_API_BASE}/search/code?q={hash}+in:file"); log::debug!("GitHub search URL: {url}"); let response = match self .client .get(&url) .headers(self.get_headers()?) .send() .await { Ok(resp) => { log::debug!("GitHub search response status: {}", resp.status()); resp }, Err(e) => { log::warn!("GitHub hash lookup failed: {e}"); return Ok(None); }, }; // Handle rate limiting gracefully if response.status().as_u16() == 403 { log::warn!("GitHub API rate limit exceeded for hash lookup"); return Ok(None); } if !response.status().is_success() { log::debug!( "GitHub search returned non-success status: {}", response.status() ); return Ok(None); } let search_result: GitHubCodeSearchResult = match response.json().await { Ok(result) => result, Err(e) => { log::warn!("Failed to parse GitHub search result: {e}"); return Ok(None); }, }; log::debug!("GitHub search found {} items", search_result.items.len()); // If we found matches, try to extract repo info from first result if let Some(item) = search_result.items.first() { let repo_full = item.repository.full_name.clone(); log::info!("GitHub hash lookup found match in repo: {repo_full}"); // Try to get the latest release for this repo match self.request_project(&repo_full, &[], &[]).await { Ok(project) => { log::info!("GitHub hash lookup succeeded for {repo_full}"); Ok(Some(project)) }, Err(e) => { log::warn!("Failed to fetch project for {repo_full}: {e}"); Ok(None) }, } } else { log::debug!("GitHub hash lookup found no matches"); Ok(None) } } } // GitHub API models #[derive(Debug, Clone, Deserialize, Serialize)] struct GitHubRelease { id: u64, tag_name: String, name: Option, prerelease: bool, published_at: Option, assets: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] struct GitHubAsset { id: u64, name: String, browser_download_url: String, size: u64, } #[derive(Debug, Deserialize)] struct GitHubCodeSearchResult { items: Vec, } #[derive(Debug, Deserialize)] struct GitHubCodeSearchItem { repository: GitHubRepository, } #[derive(Debug, Deserialize)] struct GitHubRepository { full_name: String, } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_repo_identifier() { let cases = vec![ ("owner/repo", ("owner", "repo")), ("github:owner/repo", ("owner", "repo")), ("https://github.com/owner/repo", ("owner", "repo")), ("https://github.com/owner/repo.git", ("owner", "repo")), ]; for (input, (expected_owner, expected_repo)) in cases { let (owner, repo) = GitHubPlatform::parse_repo_identifier(input).unwrap(); assert_eq!(owner, expected_owner); assert_eq!(repo, expected_repo); } } #[test] fn test_parse_repo_identifier_invalid() { let result = GitHubPlatform::parse_repo_identifier("invalid"); assert!(result.is_err()); } #[test] fn test_extract_mc_versions() { let cases = vec![ ("1.20.4-forge-1.0.0", "", vec!["1.20.4", "1.0.0"]), ("fabric-1.21-1.0.0", "", vec!["1.21"]), ("mc1.20.4", "", vec!["1.20.4"]), ("1.20.1-1.20.2", "", vec!["1.20.1"]), ("mymod-1.0.0", "", vec!["1.0.0"]), ("mc1.20.4-v1.0.0", "", vec!["1.20.4", "1.0.0"]), ("v1.0.0", "mymod-1.20.4.jar", vec!["1.0.0", "1.20.4"]), ("1.20.1-47.1.0", "", vec!["1.20.1"]), ("v0.5.1+1.20.1", "", vec!["0.5.1"]), ("1.20.4-1.0.0+fabric", "", vec!["1.20.4"]), ("mc1.19.2-v2.1.3", "", vec!["1.19.2", "2.1.3"]), ("1.20-Snapshot", "", vec!["1.20"]), ("v3.0.0-beta.2+mc1.20.4", "", vec!["3.0.0", "1.20.4"]), ("1.16.5-1.0", "", vec!["1.16.5"]), ("forge-1.20.1-47.2.0", "", vec!["1.20.1"]), ("1.20.2-neoforge-20.2.59", "", vec!["1.20.2", "20.2.59"]), ("release-1.20.1", "", vec!["1.20.1"]), ("1.19.4_v2.5.0", "", vec!["1.19.4", "2.5.0"]), ("MC1.18.2-v1.0.0", "", vec!["1.18.2", "1.0.0"]), ("1.20.1-forge-v1.2.3", "", vec!["1.20.1", "1.2.3"]), ("Minecraft_1.19.2-v0.8.1", "", vec!["1.19.2", "0.8.1"]), ("build-1.20.4-2.1.0", "", vec!["1.20.4"]), ("1.20.x-1.5.0", "", vec!["1.20", "1.5.0"]), ("1.12.2-14.23.5.2859", "", vec!["1.12.2"]), ]; for (tag, asset, expected) in cases { let result = extract_mc_versions(tag, asset); assert_eq!( result, expected, "Failed for tag: {}, asset: {}", tag, asset ); } } #[test] fn test_extract_loaders() { let cases = vec![ ("1.20.4-forge-1.0.0", "", vec!["forge"]), ("fabric-1.21-1.0.0", "", vec!["fabric"]), ("1.20.1-neoforge", "", vec!["neoforge"]), ("quilt-1.20.4", "", vec!["quilt"]), ("mymod-1.0.0", "", vec![]), ("1.20.4-forge-fabric", "", vec!["fabric", "forge"]), /* Alphabetical * order */ ("v1.0.0", "mymod-fabric-1.20.4.jar", vec!["fabric"]), // Real-world patterns ("1.20.1-forge-47.1.0", "", vec!["forge"]), ("fabric-api-0.92.0+1.20.4", "", vec!["fabric"]), ("1.19.2-neoforge-20.2.59", "", vec!["neoforge"]), ("quilt-loader-0.23.0", "", vec!["quilt"]), ("1.20.4-Fabric-1.0.0", "", vec!["fabric"]), // Capitalized ("forge-1.20.1", "", vec!["forge"]), ("v1.0.0-fabric", "", vec!["fabric"]), ("1.18.2-forge+fabric", "", vec!["fabric", "forge"]), // Both loaders ("NeoForge-1.20.2", "", vec!["neoforge"]), /* Capitalized * NeoForge */ ("1.12.2-forge-14.23.5.2859", "", vec!["forge"]), // Old format ]; for (tag, asset, expected) in cases { let result = extract_loaders(tag, asset); assert_eq!( result, expected, "Failed for tag: {}, asset: {}", tag, asset ); } } #[test] fn test_detect_project_type() { let cases = vec![ ("mymod.jar", "mymod", crate::model::ProjectType::Mod), ( "texture-pack.zip", "texture", crate::model::ProjectType::ResourcePack, ), ( "resourcepack.zip", "resources", crate::model::ProjectType::ResourcePack, ), ( "datapack.zip", "data-stuff", crate::model::ProjectType::DataPack, ), ( "shader.zip", "awesome-shaders", crate::model::ProjectType::Shader, ), ("world.zip", "my-world", crate::model::ProjectType::World), ("save.zip", "survival", crate::model::ProjectType::World), ("unknown.zip", "stuff", crate::model::ProjectType::Mod), ]; for (filename, repo_name, expected) in cases { let result = detect_project_type(filename, repo_name); assert_eq!( result, expected, "Failed for filename: {}, repo: {}", filename, repo_name ); } } }