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

383
src/model/config.rs Normal file
View file

@ -0,0 +1,383 @@
use std::{collections::HashMap, path::Path};
use serde::{Deserialize, Serialize};
use super::enums::{ProjectSide, ProjectType, UpdateStrategy};
use crate::error::{PakkerError, Result};
const CONFIG_NAME: &str = "pakker.json";
// Pakker config wrapper - supports both Pakker (direct) and Pakku (wrapped)
// formats
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ConfigWrapper {
Pakker(Config),
Pakku { pakku: PakkerWrappedConfig },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PakkerWrappedConfig {
pub parent: Option<ParentConfig>,
#[serde(default)]
pub parent_lock_hash: String,
#[serde(default)]
pub patches: Vec<serde_json::Value>,
#[serde(default)]
pub projects: HashMap<String, ProjectConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParentConfig {
pub id: String,
pub r#ref: String,
pub ref_type: String,
pub remote_name: String,
#[serde(rename = "type")]
pub type_: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Config {
pub name: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default)]
pub overrides: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_overrides: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_overrides: Option<Vec<String>>,
#[serde(default)]
pub paths: HashMap<String, String>,
#[serde(default)]
pub projects: Option<HashMap<String, ProjectConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub export_profiles: Option<HashMap<String, crate::export::ProfileConfig>>,
}
impl Default for Config {
fn default() -> Self {
Self {
name: String::new(),
version: String::new(),
description: None,
author: None,
overrides: vec!["overrides".to_string()],
server_overrides: None,
client_overrides: None,
paths: HashMap::new(),
projects: Some(HashMap::new()),
export_profiles: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ProjectConfig {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub r#type: Option<ProjectType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub side: Option<ProjectSide>,
#[serde(skip_serializing_if = "Option::is_none")]
pub update_strategy: Option<UpdateStrategy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redistributable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subpath: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aliases: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub export: Option<bool>,
}
impl Config {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref().join(CONFIG_NAME);
let content =
std::fs::read_to_string(&path).map_err(PakkerError::IoError)?;
// Try to parse as ConfigWrapper (supports both Pakker and Pakku formats)
match serde_json::from_str::<ConfigWrapper>(&content) {
Ok(ConfigWrapper::Pakker(config)) => {
config.validate()?;
Ok(config)
},
Ok(ConfigWrapper::Pakku { pakku }) => {
// Convert Pakku format to Pakker format
// Pakku format doesn't have name/version, use parent repo info as
// fallback
let name = pakku
.parent
.as_ref()
.map(|p| {
// Extract repo name from URL
p.id
.split('/')
.next_back()
.unwrap_or(&p.id)
.trim_end_matches(".git")
.to_string()
})
.unwrap_or_else(|| "unknown".to_string());
let version = pakku
.parent
.as_ref()
.map_or_else(|| "unknown".to_string(), |p| p.version.clone());
Ok(Self {
name,
version,
description: None,
author: None,
overrides: vec!["overrides".to_string()],
server_overrides: None,
client_overrides: None,
paths: HashMap::new(),
projects: Some(pakku.projects),
export_profiles: None,
})
},
Err(e) => Err(PakkerError::InvalidConfigFile(e.to_string())),
}
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
self.validate()?;
let path = path.as_ref().join(CONFIG_NAME);
// Write to temporary file first (atomic write)
let temp_path = path.with_extension("tmp");
let content = serde_json::to_string_pretty(self)
.map_err(PakkerError::SerializationError)?;
std::fs::write(&temp_path, content)?;
std::fs::rename(temp_path, path)?;
Ok(())
}
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
return Err(PakkerError::InvalidConfigFile(
"Config name cannot be empty".to_string(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
#[test]
fn test_config_new() {
let config = Config {
name: "test-pack".to_string(),
version: "1.0.0".to_string(),
description: None,
author: None,
overrides: vec!["overrides".to_string()],
server_overrides: None,
client_overrides: None,
paths: HashMap::new(),
projects: None,
export_profiles: None,
};
assert_eq!(config.name, "test-pack");
assert_eq!(config.version, "1.0.0");
assert_eq!(config.overrides, vec!["overrides"]);
assert!(config.projects.is_none());
}
#[test]
fn test_config_serialization() {
let mut config = Config {
name: "test-pack".to_string(),
version: "1.0.0".to_string(),
description: None,
author: None,
overrides: vec!["overrides".to_string()],
server_overrides: None,
client_overrides: None,
paths: HashMap::new(),
projects: None,
export_profiles: None,
};
config.description = Some("A test modpack".to_string());
config.author = Some("Test Author".to_string());
let json = serde_json::to_string(&config).unwrap();
let deserialized: Config = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, config.name);
assert_eq!(deserialized.version, config.version);
assert_eq!(deserialized.description, config.description);
assert_eq!(deserialized.author, config.author);
}
#[test]
fn test_config_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let mut config = Config {
name: "test-pack".to_string(),
version: "1.0.0".to_string(),
description: None,
author: None,
overrides: vec!["overrides".to_string()],
server_overrides: None,
client_overrides: None,
paths: HashMap::new(),
projects: None,
export_profiles: None,
};
config.description = Some("Test description".to_string());
config.save(temp_dir.path()).unwrap();
let loaded = Config::load(temp_dir.path()).unwrap();
assert_eq!(loaded.name, config.name);
assert_eq!(loaded.version, config.version);
assert_eq!(loaded.description, config.description);
}
#[test]
fn test_config_compatibility_with_pakku() {
// Test basic config loading with projects
let config = Config {
name: "test-modpack".to_string(),
version: "1.0.0".to_string(),
description: None,
author: None,
overrides: vec!["overrides".to_string()],
server_overrides: None,
client_overrides: None,
paths: HashMap::new(),
projects: None,
export_profiles: None,
};
assert_eq!(config.name, "test-modpack");
assert_eq!(config.version, "1.0.0");
assert!(config.projects.is_none());
}
#[test]
fn test_config_wrapped_format() {
let mut projects = HashMap::new();
projects.insert("sodium".to_string(), ProjectConfig {
r#type: Some(ProjectType::Mod),
side: Some(ProjectSide::Client),
update_strategy: None,
redistributable: None,
subpath: None,
aliases: None,
export: None,
});
let wrapped = PakkerWrappedConfig {
parent: None,
parent_lock_hash: String::new(),
patches: vec![],
projects,
};
let json = serde_json::to_string(&wrapped).unwrap();
assert!(json.contains("\"projects\""));
let deserialized: PakkerWrappedConfig =
serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.projects.len(), 1);
}
#[test]
fn test_config_wrapped_format_old() {
use crate::model::fork::{LocalConfig, LocalProjectConfig};
let mut projects = HashMap::new();
projects.insert("sodium".to_string(), LocalProjectConfig {
version: None,
r#type: Some(ProjectType::Mod),
side: Some(ProjectSide::Client),
update_strategy: None,
redistributable: None,
subpath: None,
aliases: None,
export: None,
});
let wrapped_inner = LocalConfig {
parent: None,
projects,
parent_lock_hash: None,
parent_config_hash: None,
patches: vec![],
};
// Just verify we can create the struct
assert_eq!(wrapped_inner.projects.len(), 1);
}
#[test]
fn test_config_validate() {
let config = Config {
name: "test".to_string(),
version: "1.0.0".to_string(),
description: None,
author: None,
overrides: vec!["overrides".to_string()],
server_overrides: None,
client_overrides: None,
paths: HashMap::new(),
projects: None,
export_profiles: None,
};
assert!(config.validate().is_ok());
let invalid = Config {
name: "".to_string(),
version: "1.0.0".to_string(),
description: None,
author: None,
overrides: vec![],
server_overrides: None,
client_overrides: None,
paths: HashMap::new(),
projects: None,
export_profiles: None,
};
assert!(invalid.validate().is_err());
}
}
impl Config {
pub fn get_project_config(&self, identifier: &str) -> Option<&ProjectConfig> {
self.projects.as_ref()?.get(identifier)
}
pub fn set_project_config(
&mut self,
identifier: String,
config: ProjectConfig,
) {
if self.projects.is_none() {
self.projects = Some(HashMap::new());
}
if let Some(ref mut projects) = self.projects {
projects.insert(identifier, config);
}
}
}