Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ia27c829dbcc21a7fcfc8e6f67f9e33276a6a6964
1534 lines
42 KiB
Rust
1534 lines
42 KiB
Rust
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<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 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<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
|
|
};
|
|
|
|
// 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<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 {
|
|
// 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<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 {
|
|
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 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<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
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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<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(())
|
|
}
|
|
|
|
/// 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;
|
|
|
|
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
|
|
{
|
|
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<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;
|
|
|
|
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,
|
|
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));
|
|
}
|
|
}
|