use std::{fs, path::PathBuf}; use async_trait::async_trait; use glob::glob; use crate::{ error::Result, model::{Config, LockFile, ProjectSide, ProjectType}, }; #[derive(Clone)] pub struct RuleContext { pub lockfile: LockFile, pub config: Config, pub profile_config: Option, pub export_path: PathBuf, pub base_path: PathBuf, pub ui: Option, } pub trait Rule: Send + Sync { fn matches(&self, context: &RuleContext) -> bool; fn effects(&self) -> Vec>; } #[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> { vec![Box::new(CopyProjectFilesEffect)] } } pub struct CopyProjectFilesEffect; #[async_trait] impl Effect for CopyProjectFilesEffect { fn name(&self) -> &'static str { "Downloading and copying project 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); for project in &context.lockfile.projects { if !project.export { continue; } if let Some(file) = project.files.first() { // Get the target directory based on project type and paths config let type_dir = get_project_type_dir(&project.r#type, &context.config); // Handle subpath if specified let target_subdir = if let Some(subpath) = &project.subpath { PathBuf::from(&type_dir).join(subpath) } else { PathBuf::from(&type_dir) }; let export_dir = context.export_path.join(&target_subdir); fs::create_dir_all(&export_dir)?; let source = context.base_path.join(&type_dir).join(&file.file_name); let dest = export_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, &type_dir, &file.file_name, &file.url, curseforge_key.as_deref(), modrinth_token.as_deref(), ) .await?; // Copy into export dir after ensuring it is present in base dir let downloaded = context.base_path.join(&type_dir).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 project 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, type_dir: &str, 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 target_dir = base_path.join(type_dir); fs::create_dir_all(&target_dir)?; let dest = target_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> { 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 }; // Expand any glob patterns in override paths let expanded_paths = expand_override_globs(&context.base_path, overrides); for override_path in expanded_paths { 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> { 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 { // Expand any glob patterns in override paths let expanded_paths = expand_override_globs(&context.base_path, overrides); for override_path in expanded_paths { 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 client overrides pub struct CopyClientOverridesRule; impl Rule for CopyClientOverridesRule { fn matches(&self, context: &RuleContext) -> bool { context.config.client_overrides.is_some() } fn effects(&self) -> Vec> { vec![Box::new(CopyClientOverridesEffect)] } } pub struct CopyClientOverridesEffect; #[async_trait] impl Effect for CopyClientOverridesEffect { fn name(&self) -> &'static str { "Copying client override files" } async fn execute(&self, context: &RuleContext) -> Result<()> { // Use profile-specific client overrides if available, otherwise use global // config let client_overrides = if let Some(profile_config) = &context.profile_config { profile_config .get_client_overrides(context.config.client_overrides.as_ref()) } else { context.config.client_overrides.as_deref() }; if let Some(overrides) = client_overrides { // Expand any glob patterns in override paths let expanded_paths = expand_override_globs(&context.base_path, overrides); for override_path in expanded_paths { 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 (for server packs) pub struct FilterClientOnlyRule; impl Rule for FilterClientOnlyRule { fn matches(&self, _context: &RuleContext) -> bool { true } fn effects(&self) -> Vec> { vec![Box::new(FilterClientOnlyEffect)] } } pub struct FilterClientOnlyEffect; #[async_trait] impl Effect for FilterClientOnlyEffect { fn name(&self) -> &'static str { "Filtering client-only projects" } 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(()); } for project in &context.lockfile.projects { if project.side == ProjectSide::Client && let Some(file) = project.files.first() { // Get the target directory based on project type and paths config let type_dir = get_project_type_dir(&project.r#type, &context.config); let project_dir = context.export_path.join(&type_dir); let file_path = project_dir.join(&file.file_name); if file_path.exists() { fs::remove_file(&file_path)?; log::info!("Filtered client-only project: {}", file.file_name); } } } Ok(()) } } // Rule: Filter server-only projects (for client packs) // This rule respects the `export_server_side_projects_to_client` config option pub struct FilterServerOnlyRule; impl Rule for FilterServerOnlyRule { fn matches(&self, _context: &RuleContext) -> bool { true } fn effects(&self) -> Vec> { vec![Box::new(FilterServerOnlyEffect)] } } pub struct FilterServerOnlyEffect; #[async_trait] impl Effect for FilterServerOnlyEffect { fn name(&self) -> &'static str { "Filtering server-only projects" } async fn execute(&self, context: &RuleContext) -> Result<()> { // Check config option: if true, include server-side projects in client // exports let export_server_to_client = context .config .export_server_side_projects_to_client .unwrap_or(false); if export_server_to_client { // Don't filter server-only mods - include them in client pack return Ok(()); } for project in &context.lockfile.projects { if project.side == ProjectSide::Server && let Some(file) = project.files.first() { // Get the target directory based on project type and paths config let type_dir = get_project_type_dir(&project.r#type, &context.config); let project_dir = context.export_path.join(&type_dir); let file_path = project_dir.join(&file.file_name); if file_path.exists() { fs::remove_file(&file_path)?; log::info!( "Filtered server-only project: {} \ (export_server_side_projects_to_client=false)", file.file_name ); } } } Ok(()) } } // Rule: Filter non-redistributable pub struct FilterNonRedistributableRule; impl Rule for FilterNonRedistributableRule { fn matches(&self, _context: &RuleContext) -> bool { true } fn effects(&self) -> Vec> { vec![Box::new(FilterNonRedistributableEffect)] } } pub struct FilterNonRedistributableEffect; #[async_trait] impl Effect for FilterNonRedistributableEffect { fn name(&self) -> &'static str { "Filtering non-redistributable projects" } 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(()); } for project in &context.lockfile.projects { if !project.redistributable && let Some(file) = project.files.first() { // Get the target directory based on project type and paths config let type_dir = get_project_type_dir(&project.r#type, &context.config); let project_dir = context.export_path.join(&type_dir); let file_path = project_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> { 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 { 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::().unwrap_or(0), "fileID": f.id.parse::().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::>() }, "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 { 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(()) } /// Get the target directory for a project type, respecting the paths config. /// Falls back to default directories if not configured. fn get_project_type_dir(project_type: &ProjectType, config: &Config) -> String { // Check if there's a custom path configured for this project type let type_key = project_type.to_string(); if let Some(custom_path) = config.paths.get(&type_key) { return custom_path.clone(); } // Fall back to default paths match project_type { ProjectType::Mod => "mods".to_string(), ProjectType::ResourcePack => "resourcepacks".to_string(), ProjectType::DataPack => "datapacks".to_string(), ProjectType::Shader => "shaderpacks".to_string(), ProjectType::World => "saves".to_string(), } } /// Expand glob patterns in override paths and return all matching paths. /// If a path contains no glob characters, it's returned as-is (if it exists). /// Glob patterns are relative to the `base_path`. fn expand_override_globs( base_path: &std::path::Path, override_paths: &[String], ) -> Vec { let mut results = Vec::new(); for override_path in override_paths { // Check if the path contains glob characters let has_glob = override_path.contains('*') || override_path.contains('?') || override_path.contains('['); if has_glob { // Expand the glob pattern relative to base_path let pattern = base_path.join(override_path); let pattern_str = pattern.to_string_lossy(); match glob(&pattern_str) { Ok(paths) => { for entry in paths.flatten() { // Store the path relative to base_path for consistent handling if let Ok(relative) = entry.strip_prefix(base_path) { results.push(relative.to_path_buf()); } else { results.push(entry); } } }, Err(e) => { log::warn!("Invalid glob pattern '{override_path}': {e}"); }, } } else { // Not a glob pattern - use as-is results.push(PathBuf::from(override_path)); } } results } // 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> { 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 { 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() { // Get the target directory based on project type and paths config let type_dir = get_project_type_dir(&project.r#type, &context.config); let project_dir = context.export_path.join(&type_dir); let file_path = project_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(()) } } // Rule: Export missing projects as overrides // When a project is not available on the target platform, download it and // include as an override file instead pub struct MissingProjectsAsOverridesRule { target_platform: String, } impl MissingProjectsAsOverridesRule { pub fn new(target_platform: &str) -> Self { Self { target_platform: target_platform.to_string(), } } } impl Rule for MissingProjectsAsOverridesRule { fn matches(&self, _context: &RuleContext) -> bool { true } fn effects(&self) -> Vec> { vec![Box::new(MissingProjectsAsOverridesEffect { target_platform: self.target_platform.clone(), })] } } pub struct MissingProjectsAsOverridesEffect { target_platform: String, } #[async_trait] impl Effect for MissingProjectsAsOverridesEffect { fn name(&self) -> &'static str { "Exporting missing projects as overrides" } async fn execute(&self, context: &RuleContext) -> Result<()> { use crate::model::ResolvedCredentials; let credentials = ResolvedCredentials::load().ok(); let curseforge_key = credentials .as_ref() .and_then(|c| c.curseforge_api_key().map(ToOwned::to_owned)); let modrinth_token = credentials .as_ref() .and_then(|c| c.modrinth_token().map(ToOwned::to_owned)); for project in &context.lockfile.projects { if !project.export { continue; } // Check if project is available on target platform let has_target_platform = project.get_platform_id(&self.target_platform).is_some(); if has_target_platform { // Project is available on target platform, skip continue; } // Project is missing on target platform - export as override if let Some(file) = project.files.first() { // Find a download URL from any available platform if file.url.is_empty() { log::warn!( "Missing project '{}' has no download URL, skipping", project.get_name() ); continue; } // Download to overrides directory let overrides_dir = context.export_path.join("overrides"); let type_dir = get_project_type_dir(&project.r#type, &context.config); let target_dir = overrides_dir.join(&type_dir); fs::create_dir_all(&target_dir)?; let dest = target_dir.join(&file.file_name); // Download the file let client = reqwest::Client::new(); let mut request = client.get(&file.url); // Add auth headers if needed if file.url.contains("curseforge") { if let Some(ref key) = curseforge_key { request = request.header("x-api-key", key); } } else if file.url.contains("modrinth") && let Some(ref token) = modrinth_token { request = request.header("Authorization", token); } match request.send().await { Ok(resp) if resp.status().is_success() => { let bytes = resp.bytes().await?; fs::write(&dest, &bytes)?; log::info!( "Exported missing project '{}' as override (not on {})", project.get_name(), self.target_platform ); }, Ok(resp) => { log::warn!( "Failed to download missing project '{}': HTTP {}", project.get_name(), resp.status() ); }, Err(e) => { log::warn!( "Failed to download missing project '{}': {}", project.get_name(), e ); }, } } } Ok(()) } } // Rule: Text replacement in exported files // Replaces template variables like ${MC_VERSION}, ${PACK_NAME}, etc. pub struct TextReplacementRule; impl Rule for TextReplacementRule { fn matches(&self, _context: &RuleContext) -> bool { true } fn effects(&self) -> Vec> { vec![Box::new(TextReplacementEffect)] } } pub struct TextReplacementEffect; #[async_trait] impl Effect for TextReplacementEffect { fn name(&self) -> &'static str { "Applying text replacements" } async fn execute(&self, context: &RuleContext) -> Result<()> { // Build replacement map from context let mut replacements: std::collections::HashMap<&str, String> = std::collections::HashMap::new(); // Pack metadata replacements.insert("${PACK_NAME}", context.config.name.clone()); replacements.insert("${PACK_VERSION}", context.config.version.clone()); replacements.insert( "${PACK_AUTHOR}", context.config.author.clone().unwrap_or_default(), ); replacements.insert( "${PACK_DESCRIPTION}", context.config.description.clone().unwrap_or_default(), ); // Minecraft version replacements.insert( "${MC_VERSION}", context .lockfile .mc_versions .first() .cloned() .unwrap_or_default(), ); replacements .insert("${MC_VERSIONS}", context.lockfile.mc_versions.join(", ")); // Loader info if let Some((name, version)) = context.lockfile.loaders.iter().next() { replacements.insert("${LOADER}", name.clone()); replacements.insert("${LOADER_VERSION}", version.clone()); } // All loaders replacements.insert( "${LOADERS}", context .lockfile .loaders .iter() .map(|(k, v)| format!("{k}={v}")) .collect::>() .join(", "), ); // Project count replacements.insert( "${PROJECT_COUNT}", context.lockfile.projects.len().to_string(), ); replacements.insert( "${MOD_COUNT}", context .lockfile .projects .iter() .filter(|p| p.r#type == ProjectType::Mod) .count() .to_string(), ); // Process text files in the export directory process_text_files(&context.export_path, &replacements)?; Ok(()) } } /// Process text files in a directory, applying replacements fn process_text_files( dir: &std::path::Path, replacements: &std::collections::HashMap<&str, String>, ) -> Result<()> { if !dir.exists() { return Ok(()); } // File extensions that should be processed for text replacement const TEXT_EXTENSIONS: &[&str] = &[ "txt", "md", "json", "toml", "yaml", "yml", "cfg", "conf", "properties", "lang", "mcmeta", "html", "htm", "xml", ]; for entry in walkdir::WalkDir::new(dir) .into_iter() .filter_map(std::result::Result::ok) .filter(|e| e.file_type().is_file()) { let path = entry.path(); // Check if file extension is in our list let should_process = path .extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| { TEXT_EXTENSIONS.contains(&ext.to_lowercase().as_str()) }); if !should_process { continue; } // Read file content let content = match fs::read_to_string(path) { Ok(c) => c, Err(_) => continue, // Skip binary files or unreadable files }; // Check if any replacements are needed let needs_replacement = replacements.keys().any(|key| content.contains(*key)); if !needs_replacement { continue; } // Apply replacements let mut new_content = content; for (pattern, replacement) in replacements { new_content = new_content.replace(*pattern, replacement); } // Write back fs::write(path, new_content)?; log::debug!("Applied text replacements to: {}", path.display()); } Ok(()) } #[cfg(test)] mod tests { use std::collections::HashMap; use super::*; use crate::{export::ProfileConfig, model::LockFile}; fn create_test_context(profile_config: Option) -> 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, export_server_side_projects_to_client: None, file_count_preference: 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"]); } #[test] fn test_get_project_type_dir_default_paths() { let config = Config { name: "Test".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, export_server_side_projects_to_client: None, file_count_preference: None, }; assert_eq!(get_project_type_dir(&ProjectType::Mod, &config), "mods"); assert_eq!( get_project_type_dir(&ProjectType::ResourcePack, &config), "resourcepacks" ); assert_eq!( get_project_type_dir(&ProjectType::DataPack, &config), "datapacks" ); assert_eq!( get_project_type_dir(&ProjectType::Shader, &config), "shaderpacks" ); assert_eq!(get_project_type_dir(&ProjectType::World, &config), "saves"); } #[test] fn test_get_project_type_dir_custom_paths() { let mut paths = HashMap::new(); paths.insert("mod".to_string(), "custom-mods".to_string()); paths.insert("resource-pack".to_string(), "custom-rp".to_string()); let config = Config { name: "Test".to_string(), version: "1.0.0".to_string(), description: None, author: None, overrides: vec![], server_overrides: None, client_overrides: None, paths, projects: None, export_profiles: None, export_server_side_projects_to_client: None, file_count_preference: None, }; assert_eq!( get_project_type_dir(&ProjectType::Mod, &config), "custom-mods" ); assert_eq!( get_project_type_dir(&ProjectType::ResourcePack, &config), "custom-rp" ); // Non-customized type should use default assert_eq!( get_project_type_dir(&ProjectType::Shader, &config), "shaderpacks" ); } #[test] fn test_expand_override_globs_no_globs() { let base_path = PathBuf::from("/tmp/test"); let overrides = vec!["overrides".to_string(), "config".to_string()]; let result = expand_override_globs(&base_path, &overrides); assert_eq!(result.len(), 2); assert_eq!(result[0], PathBuf::from("overrides")); assert_eq!(result[1], PathBuf::from("config")); } #[test] fn test_expand_override_globs_detects_glob_characters() { // Just test that glob characters are detected - actual expansion // requires the files to exist let base_path = PathBuf::from("/nonexistent"); let overrides = vec![ "overrides/*.txt".to_string(), "config/**/*.json".to_string(), "data/[abc].txt".to_string(), "simple".to_string(), ]; let result = expand_override_globs(&base_path, &overrides); // Glob patterns that don't match anything return empty // Only the non-glob path should be returned as-is assert!(result.contains(&PathBuf::from("simple"))); } #[test] fn test_client_overrides_rule_matches() { let mut config = Config { name: "Test".to_string(), version: "1.0.0".to_string(), description: None, author: None, overrides: vec![], server_overrides: None, client_overrides: Some(vec![ "client-data".to_string(), ]), paths: HashMap::new(), projects: None, export_profiles: None, export_server_side_projects_to_client: None, file_count_preference: None, }; let mut context = create_test_context(None); context.config = config.clone(); let rule = CopyClientOverridesRule; assert!(rule.matches(&context)); // Without client_overrides, should not match config.client_overrides = None; context.config = config; assert!(!rule.matches(&context)); } #[test] fn test_server_overrides_rule_matches() { let mut config = Config { name: "Test".to_string(), version: "1.0.0".to_string(), description: None, author: None, overrides: vec![], server_overrides: Some(vec![ "server-data".to_string(), ]), client_overrides: None, paths: HashMap::new(), projects: None, export_profiles: None, export_server_side_projects_to_client: None, file_count_preference: None, }; let mut context = create_test_context(None); context.config = config.clone(); let rule = CopyServerOverridesRule; assert!(rule.matches(&context)); // Without server_overrides, should not match config.server_overrides = None; context.config = config; assert!(!rule.matches(&context)); } #[test] fn test_filter_server_only_rule_always_matches() { let context = create_test_context(None); let rule = FilterServerOnlyRule; assert!(rule.matches(&context)); } #[test] fn test_text_replacement_rule_always_matches() { let context = create_test_context(None); let rule = TextReplacementRule; assert!(rule.matches(&context)); } #[test] fn test_missing_projects_rule_always_matches() { let context = create_test_context(None); let rule = MissingProjectsAsOverridesRule::new("modrinth"); assert!(rule.matches(&context)); } }