export: add text replacement and missing projects rules

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I3f404448278e8b1d492fa5d1cf7397736a6a6964
This commit is contained in:
raf 2026-02-12 23:20:22 +03:00
commit 977beccf01
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 733 additions and 34 deletions

View file

@ -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<String>>,
) -> 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 {

View file

@ -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),
]
}
}

View file

@ -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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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<PathBuf> {
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<Box<dyn Effect>> {
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<Box<dyn Effect>> {
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::<Vec<_>>()
.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));
}
}