diff --git a/src/model/project.rs b/src/model/project.rs index f4da4aa..424dec4 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; use super::enums::{ProjectSide, ProjectType, ReleaseType, UpdateStrategy}; +use crate::error::{PakkerError, Result}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { @@ -169,6 +170,80 @@ impl Project { self.aliases.extend(other.aliases); } + /// Merge this project with another, returning a new combined project. + /// Like Pakku's `Project.plus()`, this is a pure operation that doesn't + /// modify either project. + /// + /// # Errors + /// Returns `PakkerError::InvalidProject` if the projects have different types + /// or conflicting pakku_links. + pub fn merged(&self, other: Self) -> Result { + if self.r#type != other.r#type { + return Err(PakkerError::InvalidProject(format!( + "Cannot merge projects of different types: {:?} vs {:?}", + self.r#type, other.r#type + ))); + } + + if !other.pakku_links.is_empty() && self.pakku_links != other.pakku_links { + return Err(PakkerError::InvalidProject( + "Cannot merge projects with conflicting pakku_links".to_string(), + )); + } + + // Prefer non-default side + let side = if self.side != ProjectSide::Both { + self.side + } else { + other.side + }; + + let mut id = self.id.clone(); + for (platform, other_id) in other.id { + id.entry(platform).or_insert(other_id); + } + + let mut slug = self.slug.clone(); + for (platform, other_slug) in other.slug { + slug.entry(platform).or_insert(other_slug); + } + + let mut name = self.name.clone(); + for (platform, other_name) in other.name { + name.entry(platform).or_insert(other_name); + } + + let mut files = self.files.clone(); + for file in other.files { + if !files.iter().any(|f| f.id == file.id) { + files.push(file); + } + } + + let mut aliases = self.aliases.clone(); + aliases.extend(other.aliases); + + Ok(Self { + pakku_id: self.pakku_id.clone(), + pakku_links: self.pakku_links.clone(), + r#type: self.r#type, + side, + slug, + name, + id, + update_strategy: self.update_strategy, + redistributable: self.redistributable && other.redistributable, + subpath: self.subpath.clone().or(other.subpath.clone()), + aliases, + export: if self.export { + self.export + } else { + other.export + }, + files, + }) + } + /// 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. @@ -760,4 +835,112 @@ mod tests { let url = file.get_site_url(&project); assert!(url.is_none()); } + + #[test] + fn test_merged_different_types_returns_error() { + let mut p1 = + Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both); + p1.name.insert("modrinth".to_string(), "Mod1".to_string()); + + let mut p2 = Project::new( + "id2".to_string(), + ProjectType::ResourcePack, + ProjectSide::Both, + ); + p2.name.insert("modrinth".to_string(), "RP1".to_string()); + + assert!(p1.merged(p2).is_err()); + } + + #[test] + fn test_merged_combines_ids_and_slugs() { + let mut p1 = + Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both); + p1.add_platform( + "modrinth".to_string(), + "mr1".to_string(), + "mod1".to_string(), + "Mod 1".to_string(), + ); + + let mut p2 = + Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both); + p2.add_platform( + "curseforge".to_string(), + "cf1".to_string(), + "mod1".to_string(), + "Mod 1".to_string(), + ); + + let merged = p1.merged(p2).unwrap(); + assert_eq!(merged.id.get("modrinth"), Some(&"mr1".to_string())); + assert_eq!(merged.id.get("curseforge"), Some(&"cf1".to_string())); + assert_eq!(merged.slug.get("modrinth"), Some(&"mod1".to_string())); + assert_eq!(merged.slug.get("curseforge"), Some(&"mod1".to_string())); + } + + #[test] + fn test_merged_prefers_non_both_side() { + let p1 = + Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Client); + let p2 = + Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both); + + let merged = p1.merged(p2.clone()).unwrap(); + assert_eq!(merged.side, ProjectSide::Client); + + let merged2 = p2.merged(p1).unwrap(); + assert_eq!(merged2.side, ProjectSide::Client); + } + + #[test] + fn test_merged_preserves_pakku_id() { + let p1 = + Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both); + let p2 = + Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both); + + let merged = p1.merged(p2).unwrap(); + assert_eq!(merged.pakku_id, Some("id1".to_string())); + } + + #[test] + fn test_merged_deduplicates_files() { + let mut p1 = + Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both); + p1.files.push(ProjectFile { + file_type: "modrinth".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://example.com/mod.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(), + }); + + let mut p2 = + Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both); + p2.files.push(ProjectFile { + file_type: "modrinth".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://example.com/mod.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(), + }); + + let merged = p1.merged(p2).unwrap(); + assert_eq!(merged.files.len(), 1); + } }