use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; use super::enums::{ProjectSide, ProjectType, ReleaseType, UpdateStrategy}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { #[serde(skip_serializing_if = "Option::is_none")] pub pakku_id: Option, #[serde(skip_serializing_if = "HashSet::is_empty", default)] pub pakku_links: HashSet, #[serde(rename = "type")] pub r#type: ProjectType, #[serde(default = "default_side")] pub side: ProjectSide, pub slug: HashMap, pub name: HashMap, pub id: HashMap, #[serde( default = "default_update_strategy", skip_serializing_if = "is_default_update_strategy" )] pub update_strategy: UpdateStrategy, #[serde( default = "default_redistributable", skip_serializing_if = "is_default_redistributable" )] pub redistributable: bool, #[serde(skip_serializing_if = "Option::is_none")] pub subpath: Option, #[serde(default, skip_serializing_if = "HashSet::is_empty")] pub aliases: HashSet, #[serde( default = "default_export", skip_serializing_if = "is_default_export" )] pub export: bool, pub files: Vec, } const fn default_export() -> bool { true } const fn default_side() -> ProjectSide { ProjectSide::Both } const fn default_update_strategy() -> UpdateStrategy { UpdateStrategy::Latest } const fn default_redistributable() -> bool { true } const fn is_default_update_strategy(strategy: &UpdateStrategy) -> bool { matches!(strategy, UpdateStrategy::Latest) } const fn is_default_redistributable(redistributable: &bool) -> bool { *redistributable } const fn is_default_export(export: &bool) -> bool { *export } impl Project { pub fn new(pakku_id: String, typ: ProjectType, side: ProjectSide) -> Self { Self { pakku_id: Some(pakku_id), pakku_links: HashSet::new(), r#type: typ, side, slug: HashMap::new(), name: HashMap::new(), id: HashMap::new(), update_strategy: UpdateStrategy::Latest, redistributable: true, subpath: None, aliases: HashSet::new(), export: true, files: Vec::new(), } } pub fn get_platform_id(&self, platform: &str) -> Option<&String> { self.id.get(platform) } pub fn get_name(&self) -> String { self.name.values().next().cloned().unwrap_or_else(|| { self .pakku_id .clone() .unwrap_or_else(|| "unknown".to_string()) }) } pub fn matches_input(&self, input: &str) -> bool { // Check pakku_id if let Some(ref pakku_id) = self.pakku_id && pakku_id == input { return true; } // Check slugs if self.slug.values().any(|s| s == input) { return true; } // Check names (case-insensitive) if self.name.values().any(|n| n.eq_ignore_ascii_case(input)) { return true; } // Check IDs if self.id.values().any(|i| i == input) { return true; } // Check aliases if self.aliases.contains(input) { return true; } false } pub fn add_platform( &mut self, platform: String, id: String, slug: String, name: String, ) { self.id.insert(platform.clone(), id); self.slug.insert(platform.clone(), slug); self.name.insert(platform, name); } pub fn merge(&mut self, other: Self) { // Merge platform identifiers for (platform, id) in other.id { self.id.entry(platform.clone()).or_insert(id); } for (platform, slug) in other.slug { self.slug.entry(platform.clone()).or_insert(slug); } for (platform, name) in other.name { self.name.entry(platform).or_insert(name); } // Merge pakku links self.pakku_links.extend(other.pakku_links); // Merge files for file in other.files { if !self.files.iter().any(|f| f.id == file.id) { self.files.push(file); } } // Merge aliases self.aliases.extend(other.aliases); } /// Check if versions match across all providers. /// Returns true if all provider files have the same version/file, /// or if there's only one provider. pub fn versions_match_across_providers(&self) -> bool { if self.files.len() <= 1 { return true; } // Group files by provider (using parent_id as proxy) let mut versions_by_provider: HashMap> = HashMap::new(); for file in &self.files { // Extract provider from file type or use parent_id let provider = &file.file_type; versions_by_provider .entry(provider.clone()) .or_default() .push(&file.file_name); } // If only one provider, versions match if versions_by_provider.len() <= 1 { return true; } // Compare semantic versions extracted from file names let parse_version = |name: &str| { // Try to extract version from patterns like "mod-1.0.0.jar" or // "mod_v1.0.0" let version_str = name .rsplit_once('-') .and_then(|(_, v)| v.strip_suffix("jar")) .or_else(|| { name .rsplit_once('_') .and_then(|(_, v)| v.strip_suffix("jar")) }) .unwrap_or(name); semver::Version::parse(version_str).ok() }; let versions: Vec<_> = versions_by_provider .values() .filter_map(|files| files.first().copied().and_then(parse_version)) .collect(); // All versions should be the same versions.windows(2).all(|w| w[0] == w[1]) } /// Check if versions do NOT match across providers. /// Returns Some with details if there's a mismatch, None if versions match. pub fn check_version_mismatch(&self) -> Option { if self.versions_match_across_providers() { return None; } // Collect version info by provider let mut provider_versions: Vec<(String, String)> = Vec::new(); for file in &self.files { provider_versions.push((file.file_type.clone(), file.file_name.clone())); } Some(format!( "Version mismatch for {}: {}", self.get_name(), provider_versions .iter() .map(|(p, v)| format!("{p}={v}")) .collect::>() .join(", ") )) } pub fn select_file( &mut self, mc_versions: &[String], loaders: &[String], ) -> crate::error::Result<()> { // Filter compatible files let compatible_files: Vec<_> = self .files .iter() .filter(|f| f.is_compatible(mc_versions, loaders)) .collect(); if compatible_files.is_empty() { return Err(crate::error::PakkerError::FileSelectionError(format!( "No compatible files found for {}", self.get_name() ))); } // Sort by release type (release > beta > alpha) and date let mut sorted_files = compatible_files.clone(); sorted_files.sort_by(|a, b| { use super::enums::ReleaseType; let type_order = |rt: &ReleaseType| { match rt { ReleaseType::Release => 0, ReleaseType::Beta => 1, ReleaseType::Alpha => 2, } }; type_order(&a.release_type) .cmp(&type_order(&b.release_type)) .then_with(|| b.date_published.cmp(&a.date_published)) }); // Keep only the best file if let Some(best_file) = sorted_files.first() { self.files = vec![(*best_file).clone()]; } Ok(()) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectFile { #[serde(rename = "type")] pub file_type: String, pub file_name: String, pub mc_versions: Vec, #[serde(default)] pub loaders: Vec, pub release_type: ReleaseType, pub url: String, pub id: String, pub parent_id: String, pub hashes: HashMap, pub required_dependencies: Vec, pub size: u64, pub date_published: String, } impl ProjectFile { pub fn is_compatible( &self, mc_versions: &[String], loaders: &[String], ) -> bool { const VALID_LOADERS: &[&str] = &["minecraft", "iris", "optifine", "datapack"]; let mc_compatible = self.mc_versions.iter().any(|v| mc_versions.contains(v)); // Accept files with empty loaders, OR loaders matching request, OR valid // special loaders let loader_compatible = self.loaders.is_empty() || self.loaders.iter().any(|l| loaders.contains(l)) || self .loaders .iter() .any(|l| VALID_LOADERS.contains(&l.as_str())); mc_compatible && loader_compatible } /// Generate a viewable URL for this file based on its provider. /// Returns None if the URL cannot be determined. pub fn get_site_url(&self, project: &Project) -> Option { // Determine provider from file type match self.file_type.as_str() { "modrinth" => { // Format: https://modrinth.com/mod/{slug}/version/{file_id} let slug = project.slug.get("modrinth")?; Some(format!( "https://modrinth.com/mod/{}/version/{}", slug, self.id )) }, "curseforge" => { // Format: https://www.curseforge.com/minecraft/mc-mods/{slug}/files/{file_id} let slug = project.slug.get("curseforge")?; Some(format!( "https://www.curseforge.com/minecraft/mc-mods/{}/files/{}", slug, self.id )) }, "github" => { // Format: https://github.com/{owner}/{repo}/releases/tag/{tag} // parent_id contains owner/repo, id contains the tag/version Some(format!( "https://github.com/{}/releases/tag/{}", self.parent_id, self.id )) }, _ => None, } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_project_new() { let project = Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both); assert_eq!(project.pakku_id, Some("test-id".to_string())); assert_eq!(project.r#type, ProjectType::Mod); assert_eq!(project.side, ProjectSide::Both); assert!(project.pakku_links.is_empty()); assert!(project.files.is_empty()); } #[test] fn test_project_serialization() { let mut project = Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both); project .slug .insert("modrinth".to_string(), "test-slug".to_string()); project .name .insert("modrinth".to_string(), "Test Mod".to_string()); project .id .insert("modrinth".to_string(), "abc123".to_string()); let json = serde_json::to_string(&project).unwrap(); let deserialized: Project = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.pakku_id, project.pakku_id); assert_eq!(deserialized.r#type, project.r#type); assert_eq!(deserialized.side, project.side); assert_eq!( deserialized.slug.get("modrinth"), Some(&"test-slug".to_string()) ); } #[test] fn test_project_file_is_compatible_with_empty_loaders() { let file = ProjectFile { file_type: "mod".to_string(), file_name: "test.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec![], // Empty loaders should be accepted release_type: ReleaseType::Release, url: "https://example.com/test.jar".to_string(), id: "file123".to_string(), parent_id: "mod123".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }; let lockfile_mc = vec!["1.20.1".to_string()]; let lockfile_loaders = vec!["fabric".to_string()]; assert!(file.is_compatible(&lockfile_mc, &lockfile_loaders)); } #[test] fn test_project_file_is_compatible_with_matching_loaders() { let file = ProjectFile { file_type: "mod".to_string(), file_name: "test.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], release_type: ReleaseType::Release, url: "https://example.com/test.jar".to_string(), id: "file123".to_string(), parent_id: "mod123".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }; let lockfile_mc = vec!["1.20.1".to_string()]; let lockfile_loaders = vec!["fabric".to_string()]; assert!(file.is_compatible(&lockfile_mc, &lockfile_loaders)); } #[test] fn test_project_file_is_compatible_with_valid_loaders() { for loader in ["minecraft", "iris", "optifine", "datapack"] { let file = ProjectFile { file_type: "mod".to_string(), file_name: "test.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec![loader.to_string()], release_type: ReleaseType::Release, url: "https://example.com/test.jar".to_string(), id: "file123".to_string(), parent_id: "mod123".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }; let lockfile_mc = vec!["1.20.1".to_string()]; let lockfile_loaders = vec!["fabric".to_string()]; assert!( file.is_compatible(&lockfile_mc, &lockfile_loaders), "Failed for valid loader: {}", loader ); } } #[test] fn test_project_file_incompatible() { let file = ProjectFile { file_type: "mod".to_string(), file_name: "test.jar".to_string(), mc_versions: vec!["1.19.4".to_string()], loaders: vec!["forge".to_string()], release_type: ReleaseType::Release, url: "https://example.com/test.jar".to_string(), id: "file123".to_string(), parent_id: "mod123".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }; let lockfile_mc = vec!["1.20.1".to_string()]; let lockfile_loaders = vec!["fabric".to_string()]; assert!(!file.is_compatible(&lockfile_mc, &lockfile_loaders)); } #[test] fn test_project_select_file() { let mut project = Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both); project.files.push(ProjectFile { file_type: "mod".to_string(), file_name: "alpha.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], release_type: ReleaseType::Alpha, url: "https://example.com/alpha.jar".to_string(), id: "file1".to_string(), parent_id: "mod123".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-03T00:00:00Z".to_string(), }); project.files.push(ProjectFile { file_type: "mod".to_string(), file_name: "release.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], release_type: ReleaseType::Release, url: "https://example.com/release.jar".to_string(), id: "file2".to_string(), parent_id: "mod123".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }); let lockfile_mc = vec!["1.20.1".to_string()]; let lockfile_loaders = vec!["fabric".to_string()]; let result = project.select_file(&lockfile_mc, &lockfile_loaders); assert!(result.is_ok()); } #[test] fn test_versions_match_across_providers_single_file() { let mut project = Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both); project.files.push(ProjectFile { file_type: "modrinth".to_string(), file_name: "test-1.0.0.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], release_type: ReleaseType::Release, url: "https://example.com/test.jar".to_string(), id: "file1".to_string(), parent_id: "mod123".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }); assert!(project.versions_match_across_providers()); assert!(project.check_version_mismatch().is_none()); } #[test] fn test_versions_match_across_providers_same_file() { let mut project = Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both); // Same file name from different providers project.files.push(ProjectFile { file_type: "modrinth".to_string(), file_name: "test-1.0.0.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], release_type: ReleaseType::Release, url: "https://modrinth.com/test.jar".to_string(), id: "mr-file1".to_string(), parent_id: "mod123".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }); project.files.push(ProjectFile { file_type: "curseforge".to_string(), file_name: "test-1.0.0.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], release_type: ReleaseType::Release, url: "https://curseforge.com/test.jar".to_string(), id: "cf-file1".to_string(), parent_id: "mod456".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }); assert!(project.versions_match_across_providers()); } #[test] fn test_versions_mismatch_across_providers() { let mut project = Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both); project .name .insert("test".to_string(), "Test Mod".to_string()); // Different file names from different providers project.files.push(ProjectFile { file_type: "modrinth".to_string(), file_name: "test-1.0.0.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], release_type: ReleaseType::Release, url: "https://modrinth.com/test.jar".to_string(), id: "mr-file1".to_string(), parent_id: "mod123".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }); project.files.push(ProjectFile { file_type: "curseforge".to_string(), file_name: "test-0.9.0.jar".to_string(), // Different version mc_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], release_type: ReleaseType::Release, url: "https://curseforge.com/test.jar".to_string(), id: "cf-file1".to_string(), parent_id: "mod456".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }); assert!(!project.versions_match_across_providers()); let mismatch = project.check_version_mismatch(); assert!(mismatch.is_some()); let msg = mismatch.unwrap(); assert!(msg.contains("Version mismatch")); } #[test] fn test_get_site_url_modrinth() { let mut project = Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both); project .slug .insert("modrinth".to_string(), "sodium".to_string()); let file = ProjectFile { file_type: "modrinth".to_string(), file_name: "sodium-1.0.0.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], release_type: ReleaseType::Release, url: "https://modrinth.com/sodium.jar".to_string(), id: "abc123".to_string(), parent_id: "sodium".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }; let url = file.get_site_url(&project); assert!(url.is_some()); let url = url.unwrap(); assert!(url.contains("modrinth.com")); assert!(url.contains("sodium")); assert!(url.contains("abc123")); } #[test] fn test_get_site_url_curseforge() { let mut project = Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both); project .slug .insert("curseforge".to_string(), "jei".to_string()); let file = ProjectFile { file_type: "curseforge".to_string(), file_name: "jei-1.0.0.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec!["forge".to_string()], release_type: ReleaseType::Release, url: "https://curseforge.com/jei.jar".to_string(), id: "12345".to_string(), parent_id: "jei".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }; let url = file.get_site_url(&project); assert!(url.is_some()); let url = url.unwrap(); assert!(url.contains("curseforge.com")); assert!(url.contains("jei")); assert!(url.contains("12345")); } #[test] fn test_get_site_url_github() { let project = Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both); let file = ProjectFile { file_type: "github".to_string(), file_name: "mod-1.0.0.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], release_type: ReleaseType::Release, url: "https://github.com/owner/repo/releases/download/v1.0.0/mod.jar" .to_string(), id: "v1.0.0".to_string(), parent_id: "owner/repo".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }; let url = file.get_site_url(&project); assert!(url.is_some()); let url = url.unwrap(); assert!(url.contains("github.com")); assert!(url.contains("owner/repo")); assert!(url.contains("v1.0.0")); } #[test] fn test_get_site_url_unknown_type() { let project = Project::new("test-id".to_string(), ProjectType::Mod, ProjectSide::Both); let file = ProjectFile { file_type: "unknown".to_string(), file_name: "mod.jar".to_string(), mc_versions: vec!["1.20.1".to_string()], loaders: vec!["fabric".to_string()], release_type: ReleaseType::Release, url: "https://example.com/mod.jar".to_string(), id: "123".to_string(), parent_id: "mod".to_string(), hashes: HashMap::new(), required_dependencies: vec![], size: 1024, date_published: "2024-01-01T00:00:00Z".to_string(), }; let url = file.get_site_url(&project); assert!(url.is_none()); } }