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

66
src/export/cache.rs Normal file
View 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(())
}
}

View 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
View 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
View 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"]);
}
}