initial commit

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ife1391ed23a1e7f388b1b5eca90b9ea76a6a6964
This commit is contained in:
raf 2026-01-29 19:36:25 +03:00
commit ef28bdaeb4
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
63 changed files with 17292 additions and 0 deletions

439
src/model/project.rs Normal file
View file

@ -0,0 +1,439 @@
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<String>,
#[serde(skip_serializing_if = "HashSet::is_empty", default)]
pub pakku_links: HashSet<String>,
#[serde(rename = "type")]
pub r#type: ProjectType,
#[serde(default = "default_side")]
pub side: ProjectSide,
pub slug: HashMap<String, String>,
pub name: HashMap<String, String>,
pub id: HashMap<String, String>,
#[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<String>,
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
pub aliases: HashSet<String>,
#[serde(
default = "default_export",
skip_serializing_if = "is_default_export"
)]
pub export: bool,
pub files: Vec<ProjectFile>,
}
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);
}
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<String>,
#[serde(default)]
pub loaders: Vec<String>,
pub release_type: ReleaseType,
pub url: String,
pub id: String,
pub parent_id: String,
pub hashes: HashMap<String, String>,
pub required_dependencies: Vec<String>,
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
}
}
#[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());
}
}