diff --git a/src/model/project.rs b/src/model/project.rs index 1e2f464..96b98a6 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -168,6 +168,65 @@ impl Project { 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; + } + + // Check if all providers have the same latest file name + // (simplified check - in reality would compare semantic versions) + let file_names: Vec<_> = versions_by_provider + .values() + .filter_map(|files| files.first().copied()) + .collect(); + + // All file names should be the same for versions to match + file_names.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], @@ -254,6 +313,39 @@ impl ProjectFile { 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)] @@ -436,4 +528,230 @@ mod tests { 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()); + } }