model/project: add Project::merged for pure combining

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Idf955432e57d87352dffa961e145fcb76a6a6964
This commit is contained in:
raf 2026-04-18 22:18:33 +03:00
commit c0c9d741c1
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -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<Self> {
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);
}
}