initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ife1391ed23a1e7f388b1b5eca90b9ea76a6a6964
This commit is contained in:
commit
ef28bdaeb4
63 changed files with 17292 additions and 0 deletions
66
src/export/cache.rs
Normal file
66
src/export/cache.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CacheEntry {
|
||||
hash: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
pub struct ExportCache {
|
||||
cache_dir: PathBuf,
|
||||
entries: HashMap<String, CacheEntry>,
|
||||
}
|
||||
|
||||
impl ExportCache {
|
||||
pub fn new(cache_dir: PathBuf) -> Self {
|
||||
let entries = Self::load_cache(&cache_dir).unwrap_or_default();
|
||||
|
||||
Self { cache_dir, entries }
|
||||
}
|
||||
|
||||
fn load_cache(cache_dir: &Path) -> Result<HashMap<String, CacheEntry>> {
|
||||
let cache_file = cache_dir.join("export-cache.json");
|
||||
|
||||
if !cache_file.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(cache_file)?;
|
||||
let entries = serde_json::from_str(&content)?;
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&CacheEntry> {
|
||||
self.entries.get(key)
|
||||
}
|
||||
|
||||
pub fn put(&mut self, key: String, hash: String, path: PathBuf) {
|
||||
self.entries.insert(key, CacheEntry { hash, path });
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
fs::create_dir_all(&self.cache_dir)?;
|
||||
|
||||
let cache_file = self.cache_dir.join("export-cache.json");
|
||||
let content = serde_json::to_string_pretty(&self.entries)?;
|
||||
|
||||
fs::write(cache_file, content)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) -> Result<()> {
|
||||
self.entries.clear();
|
||||
|
||||
if self.cache_dir.exists() {
|
||||
fs::remove_dir_all(&self.cache_dir)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
161
src/export/profile_config.rs
Normal file
161
src/export/profile_config.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Profile-specific export configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileConfig {
|
||||
/// Custom override paths for this profile
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overrides: Option<Vec<String>>,
|
||||
|
||||
/// Custom server override paths for this profile
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub server_overrides: Option<Vec<String>>,
|
||||
|
||||
/// Custom client override paths for this profile
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_overrides: Option<Vec<String>>,
|
||||
|
||||
/// Platform filter - only include projects available on this platform
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub filter_platform: Option<String>,
|
||||
|
||||
/// Include non-redistributable projects (default: false for `CurseForge`,
|
||||
/// true for others)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_non_redistributable: Option<bool>,
|
||||
|
||||
/// Include client-only mods in server exports (default: false)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_client_only: Option<bool>,
|
||||
|
||||
/// Custom project-specific settings for this profile
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub project_overrides: Option<HashMap<String, ProjectOverride>>,
|
||||
}
|
||||
|
||||
/// Project-specific overrides for a profile
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectOverride {
|
||||
/// Whether to export this project in this profile
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub export: Option<bool>,
|
||||
|
||||
/// Custom subpath for this project in this profile
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subpath: Option<String>,
|
||||
}
|
||||
|
||||
impl ProfileConfig {
|
||||
/// Get effective override paths, falling back to global config
|
||||
pub fn get_overrides<'a>(
|
||||
&'a self,
|
||||
global_overrides: &'a [String],
|
||||
) -> &'a [String] {
|
||||
self.overrides.as_deref().unwrap_or(global_overrides)
|
||||
}
|
||||
|
||||
/// Get effective server override paths, falling back to global config
|
||||
pub fn get_server_overrides<'a>(
|
||||
&'a self,
|
||||
global_server_overrides: Option<&'a Vec<String>>,
|
||||
) -> Option<&'a [String]> {
|
||||
self
|
||||
.server_overrides
|
||||
.as_deref()
|
||||
.or(global_server_overrides.map(std::vec::Vec::as_slice))
|
||||
}
|
||||
|
||||
/// Get default config for `CurseForge` profile
|
||||
pub fn curseforge_default() -> Self {
|
||||
Self {
|
||||
filter_platform: Some("curseforge".to_string()),
|
||||
include_non_redistributable: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get default config for Modrinth profile
|
||||
pub fn modrinth_default() -> Self {
|
||||
Self {
|
||||
filter_platform: Some("modrinth".to_string()),
|
||||
include_non_redistributable: Some(true),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get default config for `ServerPack` profile
|
||||
pub fn serverpack_default() -> Self {
|
||||
Self {
|
||||
include_client_only: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_profile_config() {
|
||||
let config = ProfileConfig::default();
|
||||
assert!(config.overrides.is_none());
|
||||
assert!(config.filter_platform.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_curseforge_default() {
|
||||
let config = ProfileConfig::curseforge_default();
|
||||
assert_eq!(config.filter_platform, Some("curseforge".to_string()));
|
||||
assert_eq!(config.include_non_redistributable, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modrinth_default() {
|
||||
let config = ProfileConfig::modrinth_default();
|
||||
assert_eq!(config.filter_platform, Some("modrinth".to_string()));
|
||||
assert_eq!(config.include_non_redistributable, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serverpack_default() {
|
||||
let config = ProfileConfig::serverpack_default();
|
||||
assert_eq!(config.include_client_only, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_overrides_with_custom() {
|
||||
let mut config = ProfileConfig::default();
|
||||
config.overrides = Some(vec!["custom-overrides".to_string()]);
|
||||
|
||||
let global = vec!["overrides".to_string()];
|
||||
assert_eq!(config.get_overrides(&global), &["custom-overrides"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_overrides_fallback_to_global() {
|
||||
let config = ProfileConfig::default();
|
||||
let global = vec!["overrides".to_string()];
|
||||
assert_eq!(config.get_overrides(&global), &["overrides"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
let mut config = ProfileConfig::default();
|
||||
config.filter_platform = Some("modrinth".to_string());
|
||||
config.include_non_redistributable = Some(true);
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: ProfileConfig = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.filter_platform, config.filter_platform);
|
||||
assert_eq!(
|
||||
deserialized.include_non_redistributable,
|
||||
config.include_non_redistributable
|
||||
);
|
||||
}
|
||||
}
|
||||
65
src/export/profiles.rs
Normal file
65
src/export/profiles.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
use super::rules::Rule;
|
||||
use crate::error::{PakkerError, Result};
|
||||
|
||||
pub trait ExportProfile {
|
||||
fn name(&self) -> &str;
|
||||
fn rules(&self) -> Vec<Box<dyn Rule>>;
|
||||
}
|
||||
|
||||
pub struct CurseForgeProfile;
|
||||
pub struct ModrinthProfile;
|
||||
pub struct ServerPackProfile;
|
||||
|
||||
impl ExportProfile for CurseForgeProfile {
|
||||
fn name(&self) -> &'static str {
|
||||
"curseforge"
|
||||
}
|
||||
|
||||
fn rules(&self) -> Vec<Box<dyn Rule>> {
|
||||
vec![
|
||||
Box::new(super::rules::CopyProjectFilesRule),
|
||||
Box::new(super::rules::FilterByPlatformRule),
|
||||
Box::new(super::rules::CopyOverridesRule),
|
||||
Box::new(super::rules::GenerateManifestRule::curseforge()),
|
||||
Box::new(super::rules::FilterNonRedistributableRule),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl ExportProfile for ModrinthProfile {
|
||||
fn name(&self) -> &'static str {
|
||||
"modrinth"
|
||||
}
|
||||
|
||||
fn rules(&self) -> Vec<Box<dyn Rule>> {
|
||||
vec![
|
||||
Box::new(super::rules::CopyProjectFilesRule),
|
||||
Box::new(super::rules::FilterByPlatformRule),
|
||||
Box::new(super::rules::CopyOverridesRule),
|
||||
Box::new(super::rules::GenerateManifestRule::modrinth()),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl ExportProfile for ServerPackProfile {
|
||||
fn name(&self) -> &'static str {
|
||||
"serverpack"
|
||||
}
|
||||
|
||||
fn rules(&self) -> Vec<Box<dyn Rule>> {
|
||||
vec![
|
||||
Box::new(super::rules::CopyProjectFilesRule),
|
||||
Box::new(super::rules::CopyServerOverridesRule),
|
||||
Box::new(super::rules::FilterClientOnlyRule),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_profile(name: &str) -> Result<Box<dyn ExportProfile>> {
|
||||
match name {
|
||||
"curseforge" => Ok(Box::new(CurseForgeProfile)),
|
||||
"modrinth" => Ok(Box::new(ModrinthProfile)),
|
||||
"serverpack" => Ok(Box::new(ServerPackProfile)),
|
||||
_ => Err(PakkerError::InvalidExportProfile(name.to_string())),
|
||||
}
|
||||
}
|
||||
849
src/export/rules.rs
Normal file
849
src/export/rules.rs
Normal file
|
|
@ -0,0 +1,849 @@
|
|||
use std::{fs, path::PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
error::Result,
|
||||
model::{Config, LockFile, ProjectSide},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RuleContext {
|
||||
pub lockfile: LockFile,
|
||||
pub config: Config,
|
||||
pub profile_config: Option<crate::export::ProfileConfig>,
|
||||
pub export_path: PathBuf,
|
||||
pub base_path: PathBuf,
|
||||
pub ui: Option<indicatif::ProgressBar>,
|
||||
}
|
||||
|
||||
pub trait Rule: Send + Sync {
|
||||
fn matches(&self, context: &RuleContext) -> bool;
|
||||
fn effects(&self) -> Vec<Box<dyn Effect>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Effect: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
async fn execute(&self, context: &RuleContext) -> Result<()>;
|
||||
}
|
||||
|
||||
// Rule: Copy project files
|
||||
pub struct CopyProjectFilesRule;
|
||||
|
||||
impl Rule for CopyProjectFilesRule {
|
||||
fn matches(&self, _context: &RuleContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn effects(&self) -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(CopyProjectFilesEffect)]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CopyProjectFilesEffect;
|
||||
|
||||
#[async_trait]
|
||||
impl Effect for CopyProjectFilesEffect {
|
||||
fn name(&self) -> &'static str {
|
||||
"Downloading and copying mod files"
|
||||
}
|
||||
|
||||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||
use crate::model::ResolvedCredentials;
|
||||
|
||||
// Resolve credentials (env -> keyring -> Pakker file -> Pakku file).
|
||||
let credentials = ResolvedCredentials::load()?;
|
||||
let curseforge_key =
|
||||
credentials.curseforge_api_key().map(ToOwned::to_owned);
|
||||
let modrinth_token = credentials.modrinth_token().map(ToOwned::to_owned);
|
||||
|
||||
let mods_dir = context.export_path.join("mods");
|
||||
fs::create_dir_all(&mods_dir)?;
|
||||
|
||||
for project in &context.lockfile.projects {
|
||||
if !project.export {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(file) = project.files.first() {
|
||||
let source = context.base_path.join("mods").join(&file.file_name);
|
||||
let dest = mods_dir.join(&file.file_name);
|
||||
|
||||
if source.exists() {
|
||||
fs::copy(&source, &dest)?;
|
||||
if let Some(ui) = &context.ui {
|
||||
ui.println(format!("fetched {} (local)", file.file_name));
|
||||
}
|
||||
log::info!("fetched {} (local)", file.file_name);
|
||||
} else if !file.url.is_empty() {
|
||||
download_file(
|
||||
&context.base_path,
|
||||
&file.file_name,
|
||||
&file.url,
|
||||
curseforge_key.as_deref(),
|
||||
modrinth_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Copy into export mods/ after ensuring it is present in base mods/
|
||||
let downloaded = context.base_path.join("mods").join(&file.file_name);
|
||||
if downloaded.exists() {
|
||||
fs::copy(&downloaded, &dest)?;
|
||||
if let Some(ui) = &context.ui {
|
||||
ui.println(format!("fetched {} (download)", file.file_name));
|
||||
}
|
||||
log::info!("fetched {} (download)", file.file_name);
|
||||
} else {
|
||||
return Err(crate::error::PakkerError::InternalError(format!(
|
||||
"download reported success but file is missing: {}",
|
||||
file.file_name
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
return Err(crate::error::PakkerError::InternalError(format!(
|
||||
"missing mod file and no download url: {}",
|
||||
file.file_name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum DownloadFailure {
|
||||
Auth(String),
|
||||
Retryable(String),
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
fn classify_response(
|
||||
status: reqwest::StatusCode,
|
||||
body: &str,
|
||||
) -> DownloadFailure {
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED
|
||||
|| status == reqwest::StatusCode::FORBIDDEN
|
||||
{
|
||||
return DownloadFailure::Auth(format!(
|
||||
"http {}: {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS
|
||||
|| status.is_server_error()
|
||||
{
|
||||
return DownloadFailure::Retryable(format!(
|
||||
"http {}: {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
DownloadFailure::Fatal(format!("http {}: {}", status.as_u16(), body))
|
||||
}
|
||||
|
||||
fn classify_reqwest_error(err: &reqwest::Error) -> DownloadFailure {
|
||||
if err.is_timeout() || err.is_connect() {
|
||||
return DownloadFailure::Retryable(err.to_string());
|
||||
}
|
||||
|
||||
DownloadFailure::Fatal(err.to_string())
|
||||
}
|
||||
|
||||
async fn download_file(
|
||||
base_path: &std::path::Path,
|
||||
file_name: &str,
|
||||
url: &str,
|
||||
curseforge_key: Option<&str>,
|
||||
modrinth_token: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if url.is_empty() {
|
||||
return Err(crate::error::PakkerError::InternalError(format!(
|
||||
"cannot download empty url for {file_name}"
|
||||
)));
|
||||
}
|
||||
|
||||
let client = reqwest::ClientBuilder::new()
|
||||
.redirect(reqwest::redirect::Policy::default())
|
||||
.build()?;
|
||||
|
||||
let mut request_builder = client.get(url);
|
||||
|
||||
// Credentials are optional for direct file downloads; only attach them when
|
||||
// available. Hard failures are determined via HTTP status codes (401/403)
|
||||
// during the request.
|
||||
if url.contains("curseforge") {
|
||||
if let Some(key) = curseforge_key {
|
||||
request_builder = request_builder.header("x-api-key", key);
|
||||
}
|
||||
} else if url.contains("modrinth")
|
||||
&& let Some(token) = modrinth_token
|
||||
{
|
||||
request_builder = request_builder.header("Authorization", token);
|
||||
}
|
||||
|
||||
let attempts: usize = 5;
|
||||
|
||||
for attempt in 1..=attempts {
|
||||
let response = request_builder.try_clone().unwrap().send().await;
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let bytes = resp.bytes().await?;
|
||||
let mods_dir = base_path.join("mods");
|
||||
fs::create_dir_all(&mods_dir)?;
|
||||
let dest = mods_dir.join(file_name);
|
||||
std::fs::write(&dest, &bytes)?;
|
||||
return Ok(());
|
||||
},
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
match classify_response(status, &body) {
|
||||
DownloadFailure::Auth(msg) => {
|
||||
return Err(crate::error::PakkerError::InternalError(format!(
|
||||
"authentication error while downloading {file_name}: {msg}"
|
||||
)));
|
||||
},
|
||||
DownloadFailure::Retryable(msg) => {
|
||||
if attempt == attempts {
|
||||
return Err(crate::error::PakkerError::InternalError(format!(
|
||||
"retryable download error (attempts exhausted) for \
|
||||
{file_name}: {msg}"
|
||||
)));
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
250u64.saturating_mul(attempt as u64),
|
||||
))
|
||||
.await;
|
||||
},
|
||||
DownloadFailure::Fatal(msg) => {
|
||||
return Err(crate::error::PakkerError::InternalError(format!(
|
||||
"download failed for {file_name}: {msg}"
|
||||
)));
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
match classify_reqwest_error(&err) {
|
||||
DownloadFailure::Retryable(msg) => {
|
||||
if attempt == attempts {
|
||||
return Err(crate::error::PakkerError::InternalError(format!(
|
||||
"retryable download error (attempts exhausted) for \
|
||||
{file_name}: {msg}"
|
||||
)));
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
250u64.saturating_mul(attempt as u64),
|
||||
))
|
||||
.await;
|
||||
},
|
||||
DownloadFailure::Fatal(msg) | DownloadFailure::Auth(msg) => {
|
||||
return Err(crate::error::PakkerError::InternalError(format!(
|
||||
"download error for {file_name}: {msg}"
|
||||
)));
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Err(crate::error::PakkerError::InternalError(format!(
|
||||
"download failed for {file_name} (unknown error)"
|
||||
)))
|
||||
}
|
||||
|
||||
// Rule: Copy overrides
|
||||
pub struct CopyOverridesRule;
|
||||
|
||||
impl Rule for CopyOverridesRule {
|
||||
fn matches(&self, _context: &RuleContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn effects(&self) -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(CopyOverridesEffect)]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CopyOverridesEffect;
|
||||
|
||||
#[async_trait]
|
||||
impl Effect for CopyOverridesEffect {
|
||||
fn name(&self) -> &'static str {
|
||||
"Copying override files"
|
||||
}
|
||||
|
||||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||
// Use profile-specific overrides if available, otherwise use global config
|
||||
let overrides = if let Some(profile_config) = &context.profile_config {
|
||||
profile_config.get_overrides(&context.config.overrides)
|
||||
} else {
|
||||
&context.config.overrides
|
||||
};
|
||||
|
||||
for override_path in overrides {
|
||||
let source = context.base_path.join(override_path);
|
||||
if !source.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dest = context.export_path.join(override_path);
|
||||
copy_recursive(&source, &dest)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: Copy server overrides
|
||||
pub struct CopyServerOverridesRule;
|
||||
|
||||
impl Rule for CopyServerOverridesRule {
|
||||
fn matches(&self, context: &RuleContext) -> bool {
|
||||
context.config.server_overrides.is_some()
|
||||
}
|
||||
|
||||
fn effects(&self) -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(CopyServerOverridesEffect)]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CopyServerOverridesEffect;
|
||||
|
||||
#[async_trait]
|
||||
impl Effect for CopyServerOverridesEffect {
|
||||
fn name(&self) -> &'static str {
|
||||
"Copying server override files"
|
||||
}
|
||||
|
||||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||
// Use profile-specific server overrides if available, otherwise use global
|
||||
// config
|
||||
let server_overrides = if let Some(profile_config) = &context.profile_config
|
||||
{
|
||||
profile_config
|
||||
.get_server_overrides(context.config.server_overrides.as_ref())
|
||||
} else {
|
||||
context.config.server_overrides.as_deref()
|
||||
};
|
||||
|
||||
if let Some(overrides) = server_overrides {
|
||||
for override_path in overrides {
|
||||
let source = context.base_path.join(override_path);
|
||||
if !source.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dest = context.export_path.join(override_path);
|
||||
copy_recursive(&source, &dest)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: Filter client-only projects
|
||||
pub struct FilterClientOnlyRule;
|
||||
|
||||
impl Rule for FilterClientOnlyRule {
|
||||
fn matches(&self, _context: &RuleContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn effects(&self) -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(FilterClientOnlyEffect)]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FilterClientOnlyEffect;
|
||||
|
||||
#[async_trait]
|
||||
impl Effect for FilterClientOnlyEffect {
|
||||
fn name(&self) -> &'static str {
|
||||
"Filtering client-only mods"
|
||||
}
|
||||
|
||||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||
// Check if we should include client-only mods (profile config can override)
|
||||
let include_client_only = context
|
||||
.profile_config
|
||||
.as_ref()
|
||||
.and_then(|pc| pc.include_client_only)
|
||||
.unwrap_or(false);
|
||||
|
||||
if include_client_only {
|
||||
// Don't filter anything
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mods_dir = context.export_path.join("mods");
|
||||
|
||||
for project in &context.lockfile.projects {
|
||||
if project.side == ProjectSide::Client
|
||||
&& let Some(file) = project.files.first()
|
||||
{
|
||||
let file_path = mods_dir.join(&file.file_name);
|
||||
if file_path.exists() {
|
||||
fs::remove_file(file_path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: Filter non-redistributable
|
||||
pub struct FilterNonRedistributableRule;
|
||||
|
||||
impl Rule for FilterNonRedistributableRule {
|
||||
fn matches(&self, _context: &RuleContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn effects(&self) -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(FilterNonRedistributableEffect)]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FilterNonRedistributableEffect;
|
||||
|
||||
#[async_trait]
|
||||
impl Effect for FilterNonRedistributableEffect {
|
||||
fn name(&self) -> &'static str {
|
||||
"Filtering non-redistributable mods"
|
||||
}
|
||||
|
||||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||
// Check if we should include non-redistributable mods (profile config can
|
||||
// override)
|
||||
let include_non_redistributable = context
|
||||
.profile_config
|
||||
.as_ref()
|
||||
.and_then(|pc| pc.include_non_redistributable)
|
||||
.unwrap_or(false);
|
||||
|
||||
if include_non_redistributable {
|
||||
// Don't filter anything
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mods_dir = context.export_path.join("mods");
|
||||
|
||||
for project in &context.lockfile.projects {
|
||||
if !project.redistributable
|
||||
&& let Some(file) = project.files.first()
|
||||
{
|
||||
let file_path = mods_dir.join(&file.file_name);
|
||||
if file_path.exists() {
|
||||
fs::remove_file(file_path)?;
|
||||
log::info!("Filtered non-redistributable: {}", file.file_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: Generate manifest
|
||||
pub struct GenerateManifestRule {
|
||||
platform: String,
|
||||
}
|
||||
|
||||
impl GenerateManifestRule {
|
||||
pub fn curseforge() -> Self {
|
||||
Self {
|
||||
platform: "curseforge".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn modrinth() -> Self {
|
||||
Self {
|
||||
platform: "modrinth".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Rule for GenerateManifestRule {
|
||||
fn matches(&self, _context: &RuleContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn effects(&self) -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(GenerateManifestEffect {
|
||||
platform: self.platform.clone(),
|
||||
})]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GenerateManifestEffect {
|
||||
platform: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Effect for GenerateManifestEffect {
|
||||
fn name(&self) -> &'static str {
|
||||
"Generating manifest file"
|
||||
}
|
||||
|
||||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||
let (manifest, filename) = if self.platform == "curseforge" {
|
||||
(generate_curseforge_manifest(context)?, "manifest.json")
|
||||
} else if self.platform == "modrinth" {
|
||||
(generate_modrinth_manifest(context)?, "modrinth.index.json")
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let manifest_path = context.export_path.join(filename);
|
||||
fs::write(manifest_path, manifest)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_curseforge_manifest(context: &RuleContext) -> Result<String> {
|
||||
use serde_json::json;
|
||||
|
||||
let files: Vec<_> = context
|
||||
.lockfile
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|p| p.export)
|
||||
.filter_map(|p| {
|
||||
p.get_platform_id("curseforge").and_then(|id| {
|
||||
p.files.first().map(|f| {
|
||||
json!({
|
||||
"projectID": id.parse::<u32>().unwrap_or(0),
|
||||
"fileID": f.id.parse::<u32>().unwrap_or(0),
|
||||
"required": true
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let manifest = json!({
|
||||
"minecraft": {
|
||||
"version": context.lockfile.mc_versions.first().unwrap_or(&"1.20.1".to_string()),
|
||||
"modLoaders": context.lockfile.loaders.iter().map(|(name, version)| {
|
||||
json!({
|
||||
"id": format!("{}-{}", name, version),
|
||||
"primary": true
|
||||
})
|
||||
}).collect::<Vec<_>>()
|
||||
},
|
||||
"manifestType": "minecraftModpack",
|
||||
"manifestVersion": 1,
|
||||
"name": context.config.name,
|
||||
"version": context.config.version,
|
||||
"author": context.config.author.clone().unwrap_or_default(),
|
||||
"files": files,
|
||||
"overrides": "overrides"
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&manifest)?)
|
||||
}
|
||||
|
||||
fn generate_modrinth_manifest(context: &RuleContext) -> Result<String> {
|
||||
use serde_json::json;
|
||||
|
||||
let files: Vec<_> = context
|
||||
.lockfile
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|p| p.export)
|
||||
.filter_map(|p| {
|
||||
p.get_platform_id("modrinth").and_then(|_id| {
|
||||
p.files.first().map(|f| {
|
||||
let mut env = serde_json::Map::new();
|
||||
match p.side {
|
||||
crate::model::ProjectSide::Client => {
|
||||
env.insert("client".to_string(), json!("required"));
|
||||
env.insert("server".to_string(), json!("unsupported"));
|
||||
},
|
||||
crate::model::ProjectSide::Server => {
|
||||
env.insert("client".to_string(), json!("unsupported"));
|
||||
env.insert("server".to_string(), json!("required"));
|
||||
},
|
||||
crate::model::ProjectSide::Both => {
|
||||
env.insert("client".to_string(), json!("required"));
|
||||
env.insert("server".to_string(), json!("required"));
|
||||
},
|
||||
}
|
||||
|
||||
json!({
|
||||
"path": format!("mods/{}", f.file_name),
|
||||
"hashes": f.hashes,
|
||||
"env": env,
|
||||
"downloads": [f.url.clone()],
|
||||
"fileSize": f.size
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build dependencies dynamically based on loaders present
|
||||
let mut dependencies = serde_json::Map::new();
|
||||
dependencies.insert(
|
||||
"minecraft".to_string(),
|
||||
json!(
|
||||
context
|
||||
.lockfile
|
||||
.mc_versions
|
||||
.first()
|
||||
.unwrap_or(&"1.20.1".to_string())
|
||||
),
|
||||
);
|
||||
|
||||
for (loader_name, loader_version) in &context.lockfile.loaders {
|
||||
let dep_key = format!("{loader_name}-loader");
|
||||
dependencies.insert(dep_key, json!(loader_version));
|
||||
}
|
||||
|
||||
let manifest = json!({
|
||||
"formatVersion": 1,
|
||||
"game": "minecraft",
|
||||
"versionId": context.config.version,
|
||||
"name": context.config.name,
|
||||
"summary": context.config.description.clone().unwrap_or_default(),
|
||||
"files": files,
|
||||
"dependencies": dependencies
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&manifest)?)
|
||||
}
|
||||
|
||||
fn copy_recursive(
|
||||
source: &std::path::Path,
|
||||
dest: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
if source.is_file() {
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(source, dest)?;
|
||||
} else if source.is_dir() {
|
||||
fs::create_dir_all(dest)?;
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let target = dest.join(entry.file_name());
|
||||
copy_recursive(&entry.path(), &target)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Rule: Filter projects by platform
|
||||
pub struct FilterByPlatformRule;
|
||||
|
||||
impl Rule for FilterByPlatformRule {
|
||||
fn matches(&self, context: &RuleContext) -> bool {
|
||||
// Only match if profile config specifies a platform filter
|
||||
context
|
||||
.profile_config
|
||||
.as_ref()
|
||||
.and_then(|pc| pc.filter_platform.as_ref())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn effects(&self) -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(FilterByPlatformEffect)]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FilterByPlatformEffect;
|
||||
|
||||
#[async_trait]
|
||||
impl Effect for FilterByPlatformEffect {
|
||||
fn name(&self) -> &'static str {
|
||||
"Filtering projects by platform availability"
|
||||
}
|
||||
|
||||
async fn execute(&self, context: &RuleContext) -> Result<()> {
|
||||
if let Some(profile_config) = &context.profile_config
|
||||
&& let Some(platform) = &profile_config.filter_platform
|
||||
{
|
||||
let mods_dir = context.export_path.join("mods");
|
||||
|
||||
for project in &context.lockfile.projects {
|
||||
// Check if project is available on the target platform
|
||||
let has_platform = project.get_platform_id(platform).is_some();
|
||||
|
||||
if !has_platform {
|
||||
// Remove the file if it was copied
|
||||
if let Some(file) = project.files.first() {
|
||||
let file_path = mods_dir.join(&file.file_name);
|
||||
if file_path.exists() {
|
||||
fs::remove_file(file_path)?;
|
||||
log::info!(
|
||||
"Filtered {} (not available on {})",
|
||||
file.file_name,
|
||||
platform
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
use crate::{export::ProfileConfig, model::LockFile};
|
||||
|
||||
fn create_test_context(profile_config: Option<ProfileConfig>) -> RuleContext {
|
||||
let mut loaders = HashMap::new();
|
||||
loaders.insert("fabric".to_string(), "0.15.0".to_string());
|
||||
|
||||
RuleContext {
|
||||
lockfile: LockFile {
|
||||
target: None,
|
||||
projects: vec![],
|
||||
mc_versions: vec!["1.20.1".to_string()],
|
||||
loaders,
|
||||
lockfile_version: 1,
|
||||
},
|
||||
config: Config {
|
||||
name: "Test Pack".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: None,
|
||||
author: None,
|
||||
overrides: vec!["overrides".to_string()],
|
||||
server_overrides: Some(vec!["server-overrides".to_string()]),
|
||||
client_overrides: Some(vec!["client-overrides".to_string()]),
|
||||
paths: HashMap::new(),
|
||||
projects: None,
|
||||
export_profiles: None,
|
||||
},
|
||||
profile_config,
|
||||
export_path: PathBuf::from("/tmp/export"),
|
||||
base_path: PathBuf::from("/tmp/base"),
|
||||
ui: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_by_platform_rule_matches_with_platform_filter() {
|
||||
let profile_config = ProfileConfig {
|
||||
filter_platform: Some("modrinth".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let context = create_test_context(Some(profile_config));
|
||||
let rule = FilterByPlatformRule;
|
||||
|
||||
assert!(rule.matches(&context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_by_platform_rule_no_match_without_platform_filter() {
|
||||
let context = create_test_context(None);
|
||||
let rule = FilterByPlatformRule;
|
||||
|
||||
assert!(!rule.matches(&context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_by_platform_rule_no_match_with_empty_profile_config() {
|
||||
let profile_config = ProfileConfig::default();
|
||||
let context = create_test_context(Some(profile_config));
|
||||
let rule = FilterByPlatformRule;
|
||||
|
||||
assert!(!rule.matches(&context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_overrides_uses_profile_config() {
|
||||
let profile_config = ProfileConfig {
|
||||
overrides: Some(vec!["custom-overrides".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let context = create_test_context(Some(profile_config));
|
||||
|
||||
assert!(context.profile_config.is_some());
|
||||
assert_eq!(
|
||||
context
|
||||
.profile_config
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.overrides
|
||||
.as_ref()
|
||||
.unwrap(),
|
||||
&vec!["custom-overrides".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_non_redistributable_respects_profile_config() {
|
||||
let profile_config = ProfileConfig {
|
||||
include_non_redistributable: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let context = create_test_context(Some(profile_config));
|
||||
|
||||
assert_eq!(
|
||||
context
|
||||
.profile_config
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.include_non_redistributable,
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_client_only_respects_profile_config() {
|
||||
let profile_config = ProfileConfig {
|
||||
include_client_only: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let context = create_test_context(Some(profile_config));
|
||||
|
||||
assert_eq!(
|
||||
context.profile_config.as_ref().unwrap().include_client_only,
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_overrides_uses_profile_config() {
|
||||
let profile_config = ProfileConfig {
|
||||
server_overrides: Some(vec!["custom-server-overrides".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let context = create_test_context(Some(profile_config));
|
||||
|
||||
let server_overrides = context
|
||||
.profile_config
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get_server_overrides(context.config.server_overrides.as_ref());
|
||||
|
||||
assert!(server_overrides.is_some());
|
||||
assert_eq!(server_overrides.unwrap(), &["custom-server-overrides"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_config_fallback_to_global() {
|
||||
let context = create_test_context(None);
|
||||
|
||||
assert!(context.profile_config.is_none());
|
||||
assert_eq!(context.config.overrides, vec!["overrides"]);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue