From 977beccf01ab6ab9bcdf0942ece30bf4a251716e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Feb 2026 23:20:22 +0300 Subject: [PATCH] export: add text replacement and missing projects rules Signed-off-by: NotAShelf Change-Id: I3f404448278e8b1d492fa5d1cf7397736a6a6964 --- src/export/profile_config.rs | 11 + src/export/profiles.rs | 13 + src/export/rules.rs | 743 +++++++++++++++++++++++++++++++++-- 3 files changed, 733 insertions(+), 34 deletions(-) diff --git a/src/export/profile_config.rs b/src/export/profile_config.rs index 411c80a..b51758c 100644 --- a/src/export/profile_config.rs +++ b/src/export/profile_config.rs @@ -69,6 +69,17 @@ impl ProfileConfig { .or(global_server_overrides.map(std::vec::Vec::as_slice)) } + /// Get effective client override paths, falling back to global config + pub fn get_client_overrides<'a>( + &'a self, + global_client_overrides: Option<&'a Vec>, + ) -> Option<&'a [String]> { + self + .client_overrides + .as_deref() + .or(global_client_overrides.map(std::vec::Vec::as_slice)) + } + /// Get default config for `CurseForge` profile pub fn curseforge_default() -> Self { Self { diff --git a/src/export/profiles.rs b/src/export/profiles.rs index f5a272c..f132f07 100644 --- a/src/export/profiles.rs +++ b/src/export/profiles.rs @@ -19,9 +19,15 @@ impl ExportProfile for CurseForgeProfile { vec![ Box::new(super::rules::CopyProjectFilesRule), Box::new(super::rules::FilterByPlatformRule), + Box::new(super::rules::MissingProjectsAsOverridesRule::new( + "curseforge", + )), Box::new(super::rules::CopyOverridesRule), + Box::new(super::rules::CopyClientOverridesRule), + Box::new(super::rules::FilterServerOnlyRule), Box::new(super::rules::GenerateManifestRule::curseforge()), Box::new(super::rules::FilterNonRedistributableRule), + Box::new(super::rules::TextReplacementRule), ] } } @@ -35,8 +41,14 @@ impl ExportProfile for ModrinthProfile { vec![ Box::new(super::rules::CopyProjectFilesRule), Box::new(super::rules::FilterByPlatformRule), + Box::new(super::rules::MissingProjectsAsOverridesRule::new( + "modrinth", + )), Box::new(super::rules::CopyOverridesRule), + Box::new(super::rules::CopyClientOverridesRule), + Box::new(super::rules::FilterServerOnlyRule), Box::new(super::rules::GenerateManifestRule::modrinth()), + Box::new(super::rules::TextReplacementRule), ] } } @@ -51,6 +63,7 @@ impl ExportProfile for ServerPackProfile { Box::new(super::rules::CopyProjectFilesRule), Box::new(super::rules::CopyServerOverridesRule), Box::new(super::rules::FilterClientOnlyRule), + Box::new(super::rules::TextReplacementRule), ] } } diff --git a/src/export/rules.rs b/src/export/rules.rs index aeb0cbe..582a702 100644 --- a/src/export/rules.rs +++ b/src/export/rules.rs @@ -1,10 +1,11 @@ use std::{fs, path::PathBuf}; use async_trait::async_trait; +use glob::glob; use crate::{ error::Result, - model::{Config, LockFile, ProjectSide}, + model::{Config, LockFile, ProjectSide, ProjectType}, }; #[derive(Clone)] @@ -46,7 +47,7 @@ pub struct CopyProjectFilesEffect; #[async_trait] impl Effect for CopyProjectFilesEffect { fn name(&self) -> &'static str { - "Downloading and copying mod files" + "Downloading and copying project files" } async fn execute(&self, context: &RuleContext) -> Result<()> { @@ -58,17 +59,27 @@ impl Effect for CopyProjectFilesEffect { 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); + // 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)?; @@ -79,6 +90,7 @@ impl Effect for CopyProjectFilesEffect { } else if !file.url.is_empty() { download_file( &context.base_path, + &type_dir, &file.file_name, &file.url, curseforge_key.as_deref(), @@ -86,8 +98,9 @@ impl Effect for CopyProjectFilesEffect { ) .await?; - // Copy into export mods/ after ensuring it is present in base mods/ - let downloaded = context.base_path.join("mods").join(&file.file_name); + // 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 { @@ -102,7 +115,7 @@ impl Effect for CopyProjectFilesEffect { } } else { return Err(crate::error::PakkerError::InternalError(format!( - "missing mod file and no download url: {}", + "missing project file and no download url: {}", file.file_name ))); } @@ -157,6 +170,7 @@ fn classify_reqwest_error(err: &reqwest::Error) -> DownloadFailure { async fn download_file( base_path: &std::path::Path, + type_dir: &str, file_name: &str, url: &str, curseforge_key: Option<&str>, @@ -195,9 +209,9 @@ async fn download_file( 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); + 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(()); }, @@ -287,13 +301,16 @@ impl Effect for CopyOverridesEffect { &context.config.overrides }; - for override_path in overrides { - let source = context.base_path.join(override_path); + // 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); + let dest = context.export_path.join(&override_path); copy_recursive(&source, &dest)?; } @@ -334,13 +351,16 @@ impl Effect for CopyServerOverridesEffect { }; if let Some(overrides) = server_overrides { - for override_path in overrides { - let source = context.base_path.join(override_path); + // 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); + let dest = context.export_path.join(&override_path); copy_recursive(&source, &dest)?; } } @@ -349,7 +369,58 @@ impl Effect for CopyServerOverridesEffect { } } -// Rule: Filter client-only projects +// 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 { @@ -367,7 +438,7 @@ pub struct FilterClientOnlyEffect; #[async_trait] impl Effect for FilterClientOnlyEffect { fn name(&self) -> &'static str { - "Filtering client-only mods" + "Filtering client-only projects" } async fn execute(&self, context: &RuleContext) -> Result<()> { @@ -383,15 +454,77 @@ impl Effect for FilterClientOnlyEffect { 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); + // 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)?; + 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 + ); } } } @@ -418,7 +551,7 @@ pub struct FilterNonRedistributableEffect; #[async_trait] impl Effect for FilterNonRedistributableEffect { fn name(&self) -> &'static str { - "Filtering non-redistributable mods" + "Filtering non-redistributable projects" } async fn execute(&self, context: &RuleContext) -> Result<()> { @@ -435,15 +568,17 @@ impl Effect for FilterNonRedistributableEffect { 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); + // 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)?; + fs::remove_file(&file_path)?; log::info!("Filtered non-redistributable: {}", file.file_name); } } @@ -644,6 +779,69 @@ fn copy_recursive( 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; @@ -674,8 +872,6 @@ impl Effect for FilterByPlatformEffect { 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(); @@ -683,9 +879,14 @@ impl Effect for FilterByPlatformEffect { 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); + // 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)?; + fs::remove_file(&file_path)?; log::info!( "Filtered {} (not available on {})", file.file_name, @@ -701,6 +902,301 @@ impl Effect for FilterByPlatformEffect { } } +// 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; @@ -851,4 +1347,183 @@ mod tests { 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, + }; + + 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, + }; + + 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, + }; + + 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, + }; + + 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)); + } }