use crate::commands::validate::{print_validation_report, run_validation};
use crate::config::CobbleConfig;
use crate::pack_format::{
PackFormat, COBBLE_VERSION, SUPPORTED_MINECRAFT_VERSION, SUPPORTED_PACK_FORMAT,
};
use crate::parser::parse;
use crate::transpiler::{BuildManifestInput, BuildManifestValidation, Transpiler};
use crate::validator::ValidationReport;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipWriter};
pub struct BuildOptions {
pub input: Option<PathBuf>,
pub output: Option<PathBuf>,
pub namespace: Option<String>,
pub pack_format: Option<String>,
pub description: Option<String>,
pub verbose: bool,
pub quiet: bool,
pub zip: bool,
pub validate: bool,
pub dry_run: bool,
pub commands_json: PathBuf,
}
pub fn build(options: BuildOptions) -> Result<(), String> {
if options.quiet && options.verbose {
return Err("--quiet cannot be combined with --verbose".to_string());
}
if options.dry_run && options.zip {
return Err(
"--dry-run cannot be combined with --zip because no final output is written"
.to_string(),
);
}
let (config, config_dir) = if let Some(config_path) = find_config(&options.input) {
let config = if options.pack_format.is_some() {
CobbleConfig::load_unvalidated(&config_path)?
} else {
CobbleConfig::load(&config_path)?
};
let config_dir = config_path.parent().unwrap().to_path_buf();
(Some(config), config_dir)
} else {
(
None,
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
)
};
let source_path = if let Some(ref input) = options.input {
input.clone()
} else if let Some(ref cfg) = config {
config_dir.join(&cfg.build.source)
} else {
return Err("No input specified and no cobble.toml found".to_string());
};
let output_dir = if let Some(ref output) = options.output {
output.clone()
} else if let Some(ref cfg) = config {
config_dir.join(&cfg.build.output)
} else {
PathBuf::from("output")
};
let namespace = options
.namespace
.clone()
.or_else(|| config.as_ref().map(|c| c.project.namespace.clone()))
.unwrap_or_else(|| "cobble".to_string());
validate_namespace(&namespace)?;
let description = options
.description
.clone()
.or_else(|| config.as_ref().map(|c| c.project.description.clone()))
.unwrap_or_else(|| "Generated by Cobble".to_string());
let pack_format = if let Some(ref pack_fmt_str) = options.pack_format {
PackFormat::parse_format(pack_fmt_str)?
} else if let Some(ref cfg) = config {
PackFormat::parse_format(&cfg.project.pack_format)?
} else {
SUPPORTED_PACK_FORMAT
};
if !pack_format.is_supported() {
return Err(format!(
"pack_format must be {} (Minecraft Java Edition {}), got {}.\n\
Cobble v{} exclusively supports Minecraft Java Edition {}.\n\
See https://minecraft.wiki/w/Pack_format for version compatibility.",
SUPPORTED_PACK_FORMAT,
SUPPORTED_MINECRAFT_VERSION,
pack_format,
COBBLE_VERSION,
SUPPORTED_MINECRAFT_VERSION
));
}
let configured_entry_points = if options.input.is_none() {
config
.as_ref()
.map(|cfg| cfg.build.entry_points.clone())
.unwrap_or_default()
} else {
Vec::new()
};
let files_to_compile = if source_path.is_file() {
vec![source_path.clone()]
} else if source_path.is_dir() {
if options.input.is_none() {
if !configured_entry_points.is_empty() {
resolve_entry_points(&source_path, &configured_entry_points)?
} else {
find_cobble_files(&source_path)?
}
} else {
find_cobble_files(&source_path)?
}
} else {
return Err(format!("Source path does not exist: {:?}", source_path));
};
let source_display_root = source_display_root(&source_path, &config_dir);
if options.verbose {
println!("Building {} file(s)...", files_to_compile.len());
println!("Namespace: {}", namespace);
println!("Pack format: {}", pack_format);
println!("Description: {}", description);
} else if !options.quiet {
println!("Building {} file(s)...", files_to_compile.len());
}
if options.dry_run && !options.quiet {
println!("Dry run: final output will not be written");
}
let final_output_dir = output_dir.clone();
let build_output_dir = if options.validate && !files_to_compile.is_empty() {
staging_output_dir(&final_output_dir)?
} else {
final_output_dir.clone()
};
let mut transpiler = Transpiler::new(namespace.clone(), build_output_dir.clone());
transpiler.set_description(description);
transpiler.set_pack_format(pack_format);
transpiler.set_source_display_root(source_display_root.clone());
transpiler.set_build_input(BuildManifestInput {
source: path_display_relative(&source_path, &source_display_root),
entry_points: configured_entry_points,
compiled_files: files_to_compile
.iter()
.map(|path| path_display_relative(path, &source_display_root))
.collect(),
});
if files_to_compile.is_empty() {
if !options.dry_run {
transpiler
.write_data_pack()
.map_err(|e| format!("Failed to clean data pack output: {}", e))?;
}
return Err("No Cobble files found to compile".to_string());
}
for file_path in &files_to_compile {
if !options.quiet {
println!(
" • Compiling: {:?}",
file_path.file_name().unwrap_or_default()
);
}
let src = fs::read_to_string(file_path)
.map_err(|e| format!("Failed to read {:?}: {}", file_path, e))?;
let program = parse(&src).map_err(|errors| {
format!(
"Parse failed for {:?}:\n {}",
file_path,
errors.join("\n ")
)
})?;
transpiler.set_current_file_with_source(file_path, &src);
transpiler
.transpile(&program)
.map_err(|e| format!("Transpilation failed for {:?}: {}", file_path, e))?;
}
if !options.dry_run || options.validate {
transpiler
.write_data_pack()
.map_err(|e| format!("Failed to write data pack: {}", e))?;
}
let mut validation_summary = None;
if options.validate {
if !options.quiet {
println!("Validating generated commands...");
}
let report = match run_validation(&build_output_dir, &options.commands_json) {
Ok(report) => report,
Err(error) => {
if build_output_dir != final_output_dir {
let _ = fs::remove_dir_all(&build_output_dir);
}
return Err(error);
}
};
let has_validation_errors =
!report.errors.is_empty() || !report.source_map_errors.is_empty();
if !options.quiet || has_validation_errors {
print_validation_report(&report, &options.commands_json, &build_output_dir);
}
validation_summary = Some(validation_summary_from_report(
&options.commands_json,
&report,
));
if has_validation_errors {
if build_output_dir != final_output_dir {
let _ = fs::remove_dir_all(&build_output_dir);
}
return Err(format!(
"{} validation error(s) found",
report.errors.len() + report.source_map_errors.len()
));
}
transpiler.set_validation_summary(validation_summary.clone());
if !options.dry_run {
transpiler
.write_data_pack()
.map_err(|e| format!("Failed to write validation metadata: {}", e))?;
}
}
if options.dry_run {
if build_output_dir != final_output_dir {
let _ = fs::remove_dir_all(&build_output_dir);
}
if !options.quiet {
println!("✓ Dry run completed; no output written");
}
if options.validate && !options.quiet {
println!("✓ All commands valid");
}
if !options.quiet {
print_build_summary(
&transpiler,
files_to_compile.len(),
validation_summary.as_ref(),
None,
true,
);
}
return Ok(());
}
if options.validate {
if build_output_dir != final_output_dir {
replace_output_dir(&build_output_dir, &final_output_dir)?;
}
if !options.quiet {
println!("✓ Data pack generated at {:?}", final_output_dir);
println!("✓ All commands valid");
}
} else if !options.quiet {
println!("✓ Data pack generated at {:?}", final_output_dir);
}
let zip_path = if options.zip {
let zip_path = create_zip(&final_output_dir, &namespace)?;
if !options.quiet {
println!("✓ Created {}", zip_path.display());
}
Some(zip_path)
} else {
None
};
if !options.quiet {
print_build_summary(
&transpiler,
files_to_compile.len(),
validation_summary.as_ref(),
zip_path.as_deref(),
false,
);
}
Ok(())
}
fn path_display(path: &Path) -> String {
path.display().to_string()
}
fn path_display_relative(path: &Path, root: &Path) -> String {
let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
if let Ok(relative_path) = canonical_path.strip_prefix(&canonical_root) {
if relative_path.as_os_str().is_empty() {
return ".".to_string();
}
return path_display(relative_path);
}
if path.is_relative() {
return path_display(path);
}
path_display(&canonical_path)
}
fn source_display_root(source_path: &Path, config_dir: &Path) -> PathBuf {
let source_path = source_path
.canonicalize()
.unwrap_or_else(|_| source_path.to_path_buf());
let config_dir = config_dir
.canonicalize()
.unwrap_or_else(|_| config_dir.to_path_buf());
if source_path.strip_prefix(&config_dir).is_ok() {
return config_dir;
}
if source_path.is_file() {
return source_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
}
source_path
}
fn validation_summary_from_report(
commands_json: &Path,
report: &ValidationReport,
) -> BuildManifestValidation {
BuildManifestValidation {
enabled: true,
commands_json: path_display(commands_json),
files_checked: report.files_checked,
commands_checked: report.commands_checked,
macro_commands_checked: report.macro_commands_checked,
commands_skipped: report.commands_skipped,
errors: report.errors.len(),
source_map_errors: report.source_map_errors.len(),
}
}
fn print_build_summary(
transpiler: &Transpiler,
source_files: usize,
validation: Option<&BuildManifestValidation>,
zip_path: Option<&Path>,
dry_run: bool,
) {
let generated = transpiler.data_pack.generated_counts();
println!("Build summary:");
println!(" Source files: {}", source_files);
println!(" Functions: {}", generated.functions);
println!(" Commands: {}", generated.commands);
println!(" Function tags: {}", generated.function_tags);
println!(" JSON resources: {}", generated.total_json_resources);
if let Some(validation) = validation {
println!(
" Validation: {} commands in {} files ({} macro checked, {} skipped)",
validation.commands_checked,
validation.files_checked,
validation.macro_commands_checked,
validation.commands_skipped
);
}
if let Some(zip_path) = zip_path {
println!(" ZIP: {}", zip_path.display());
}
if dry_run {
println!(" Output: not written (--dry-run)");
}
}
fn validate_namespace(namespace: &str) -> Result<(), String> {
if namespace.is_empty() {
return Err("Namespace cannot be empty".to_string());
}
if namespace.len() > 64 {
return Err(format!(
"Namespace too long: {} chars (max 64)",
namespace.len()
));
}
if !namespace
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' || c == '.')
{
return Err(format!(
"Invalid namespace '{}': Can only contain lowercase letters, digits, underscores, hyphens, and dots.\n\
Example: 'my_datapack', 'cool-pack.v2'",
namespace
));
}
Ok(())
}
fn staging_output_dir(output_dir: &Path) -> Result<PathBuf, String> {
let parent = output_dir.parent().unwrap_or_else(|| Path::new("."));
let name = output_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("output");
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("System clock error while creating staging output: {}", e))?
.as_nanos();
let staging = parent.join(format!(
".{}.cobble-staging-{}-{}",
name,
std::process::id(),
stamp
));
if staging.exists() {
fs::remove_dir_all(&staging)
.map_err(|e| format!("Failed to clean staging output {:?}: {}", staging, e))?;
}
Ok(staging)
}
fn replace_output_dir(staging_dir: &Path, output_dir: &Path) -> Result<(), String> {
if output_dir.exists() {
if output_dir.is_dir() {
fs::remove_dir_all(output_dir)
.map_err(|e| format!("Failed to replace output {:?}: {}", output_dir, e))?;
} else {
fs::remove_file(output_dir)
.map_err(|e| format!("Failed to replace output {:?}: {}", output_dir, e))?;
}
}
fs::rename(staging_dir, output_dir).map_err(|e| {
format!(
"Failed to move validated data pack from {:?} to {:?}: {}",
staging_dir, output_dir, e
)
})
}
fn find_config(input: &Option<PathBuf>) -> Option<PathBuf> {
if let Some(path) = input {
if path.is_file() {
if let Some(parent) = path.parent() {
return CobbleConfig::find_in_path(parent);
}
} else {
return CobbleConfig::find_in_path(path);
}
}
CobbleConfig::find_in_path(".")
}
fn resolve_entry_points(
source_dir: &Path,
entry_points: &[String],
) -> Result<Vec<PathBuf>, String> {
let mut files = Vec::new();
for entry_point in entry_points {
let entry_path = Path::new(entry_point);
let path = if entry_path.is_absolute() {
entry_path.to_path_buf()
} else {
source_dir.join(entry_path)
};
if path.is_file() {
files.push(path);
} else if path.is_dir() {
files.extend(find_cobble_files(&path)?);
} else {
return Err(format!("Entry point does not exist: {}", path.display()));
}
}
Ok(files)
}
fn find_cobble_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut files = Vec::new();
for entry in WalkDir::new(dir)
.follow_links(false) .into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_symlink() {
eprintln!("⚠️ Warning: Skipping symlink: {:?}", path);
continue;
}
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "cbl" || ext == "cobble" {
files.push(path.to_path_buf());
}
}
}
}
files.sort();
Ok(files)
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use super::*;
use std::sync::Mutex;
static CWD_LOCK: Mutex<()> = Mutex::new(());
struct CurrentDirGuard {
previous: PathBuf,
}
impl CurrentDirGuard {
fn push(path: &Path) -> Self {
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(path).unwrap();
Self { previous }
}
}
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
std::env::set_current_dir(&self.previous).unwrap();
}
}
#[test]
fn resolves_entry_points_relative_to_source_dir() {
let temp_dir = tempfile::TempDir::new().unwrap();
let source_dir = temp_dir.path().join("src");
fs::create_dir_all(&source_dir).unwrap();
fs::write(source_dir.join("main.cbl"), "def main():\n pass\n").unwrap();
fs::write(source_dir.join("utils.cbl"), "def helper():\n pass\n").unwrap();
let files = resolve_entry_points(&source_dir, &["main.cbl".to_string()]).unwrap();
assert_eq!(files, vec![source_dir.join("main.cbl")]);
}
#[test]
fn build_validate_reports_missing_command_tree() {
let temp_dir = tempfile::TempDir::new().unwrap();
let input_file = temp_dir.path().join("test.cbl");
let output_dir = temp_dir.path().join("output");
let commands_json = temp_dir.path().join("missing_commands.json");
fs::write(&input_file, "def test():\n /say hello\n").unwrap();
let error = build(BuildOptions {
input: Some(input_file),
output: Some(output_dir.clone()),
namespace: None,
pack_format: None,
description: None,
verbose: false,
quiet: false,
zip: false,
validate: true,
dry_run: false,
commands_json,
})
.unwrap_err();
assert!(error.contains("Command tree not found"));
assert!(error.contains("scripts/setup_commands_json.sh 26.1.2"));
assert!(!temp_dir
.path()
.read_dir()
.unwrap()
.filter_map(|entry| entry.ok())
.any(|entry| entry
.file_name()
.to_string_lossy()
.contains(".output.cobble-staging-")));
}
#[test]
fn build_validate_fails_on_invalid_generated_command() {
let temp_dir = tempfile::TempDir::new().unwrap();
let input_file = temp_dir.path().join("test.cbl");
let output_dir = temp_dir.path().join("output");
let commands_json = temp_dir.path().join("commands.json");
fs::write(&input_file, "def test():\n /say hello\n").unwrap();
fs::write(&commands_json, r#"{"type":"root","children":{}}"#).unwrap();
let error = build(BuildOptions {
input: Some(input_file),
output: Some(output_dir.clone()),
namespace: None,
pack_format: None,
description: None,
verbose: false,
quiet: false,
zip: false,
validate: true,
dry_run: false,
commands_json,
})
.unwrap_err();
assert!(error.contains("validation error(s) found"));
assert!(!output_dir.exists());
}
#[test]
fn build_validate_preserves_previous_output_on_validation_failure() {
let temp_dir = tempfile::TempDir::new().unwrap();
let input_file = temp_dir.path().join("test.cbl");
let output_dir = temp_dir.path().join("output");
let valid_commands_json = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("data")
.join("commands.json");
if !valid_commands_json.exists() {
return;
}
fs::write(&input_file, "def test():\n /say valid\n").unwrap();
build(BuildOptions {
input: Some(input_file.clone()),
output: Some(output_dir.clone()),
namespace: None,
pack_format: None,
description: None,
verbose: false,
quiet: false,
zip: false,
validate: true,
dry_run: false,
commands_json: valid_commands_json.clone(),
})
.unwrap();
fs::write(&input_file, "def test():\n /titel @a actionbar bad\n").unwrap();
let error = build(BuildOptions {
input: Some(input_file),
output: Some(output_dir.clone()),
namespace: None,
pack_format: None,
description: None,
verbose: false,
quiet: false,
zip: false,
validate: true,
dry_run: false,
commands_json: valid_commands_json,
})
.unwrap_err();
assert!(error.contains("validation error(s) found"));
let content =
fs::read_to_string(output_dir.join("data/cobble/function/test.mcfunction")).unwrap();
assert_eq!(content.trim(), "say valid");
}
#[test]
fn dry_run_does_not_write_output() {
let temp_dir = tempfile::TempDir::new().unwrap();
let input_file = temp_dir.path().join("test.cbl");
let output_dir = temp_dir.path().join("output");
fs::write(&input_file, "def test():\n /say dry run\n").unwrap();
build(BuildOptions {
input: Some(input_file),
output: Some(output_dir.clone()),
namespace: Some("dry_run".to_string()),
pack_format: None,
description: None,
verbose: false,
quiet: false,
zip: false,
validate: false,
dry_run: true,
commands_json: PathBuf::from("data/commands.json"),
})
.unwrap();
assert!(!output_dir.exists());
}
#[test]
fn quiet_build_succeeds_and_verbose_quiet_is_rejected() {
let temp_dir = tempfile::TempDir::new().unwrap();
let input_file = temp_dir.path().join("test.cbl");
let output_dir = temp_dir.path().join("output");
fs::write(&input_file, "def test():\n /say quiet\n").unwrap();
build(BuildOptions {
input: Some(input_file.clone()),
output: Some(output_dir.clone()),
namespace: Some("quiet".to_string()),
pack_format: None,
description: None,
verbose: false,
quiet: true,
zip: false,
validate: false,
dry_run: false,
commands_json: PathBuf::from("data/commands.json"),
})
.unwrap();
assert!(output_dir
.join("data/quiet/function/test.mcfunction")
.exists());
let error = build(BuildOptions {
input: Some(input_file),
output: Some(output_dir),
namespace: Some("quiet".to_string()),
pack_format: None,
description: None,
verbose: true,
quiet: true,
zip: false,
validate: false,
dry_run: false,
commands_json: PathBuf::from("data/commands.json"),
})
.unwrap_err();
assert!(error.contains("--quiet cannot be combined with --verbose"));
}
#[test]
fn dry_run_validate_preserves_existing_output_and_cleans_staging() {
let temp_dir = tempfile::TempDir::new().unwrap();
let input_file = temp_dir.path().join("test.cbl");
let output_dir = temp_dir.path().join("output");
let valid_commands_json = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("data")
.join("commands.json");
if !valid_commands_json.exists() {
return;
}
fs::write(&input_file, "def test():\n /say valid\n").unwrap();
fs::create_dir_all(&output_dir).unwrap();
fs::write(output_dir.join("marker.txt"), "keep\n").unwrap();
build(BuildOptions {
input: Some(input_file),
output: Some(output_dir.clone()),
namespace: Some("dry_run".to_string()),
pack_format: None,
description: None,
verbose: false,
quiet: false,
zip: false,
validate: true,
dry_run: true,
commands_json: valid_commands_json,
})
.unwrap();
assert_eq!(
fs::read_to_string(output_dir.join("marker.txt")).unwrap(),
"keep\n"
);
assert!(!temp_dir
.path()
.read_dir()
.unwrap()
.filter_map(|entry| entry.ok())
.any(|entry| entry
.file_name()
.to_string_lossy()
.contains(".output.cobble-staging-")));
}
#[test]
fn build_validate_writes_validation_summary_to_manifest() {
let temp_dir = tempfile::TempDir::new().unwrap();
let input_file = temp_dir.path().join("test.cbl");
let output_dir = temp_dir.path().join("output");
let valid_commands_json = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("data")
.join("commands.json");
if !valid_commands_json.exists() {
return;
}
fs::write(&input_file, "def test():\n /say valid\n").unwrap();
build(BuildOptions {
input: Some(input_file),
output: Some(output_dir.clone()),
namespace: Some("manifest_validate".to_string()),
pack_format: None,
description: None,
verbose: false,
quiet: false,
zip: false,
validate: true,
dry_run: false,
commands_json: valid_commands_json,
})
.unwrap();
let manifest_path = output_dir.join(".cobble/build_manifest.json");
let manifest: serde_json::Value =
serde_json::from_str(&fs::read_to_string(manifest_path).unwrap()).unwrap();
assert_eq!(manifest["validation"]["enabled"], true);
assert_eq!(manifest["validation"]["commands_checked"], 1);
assert_eq!(manifest["validation"]["errors"], 0);
}
#[test]
fn build_fails_on_missing_import_with_importing_file() {
let temp_dir = tempfile::TempDir::new().unwrap();
let input_file = temp_dir.path().join("main.cbl");
let output_dir = temp_dir.path().join("output");
fs::write(&input_file, "import missing\n\ndef test():\n pass\n").unwrap();
let error = build(BuildOptions {
input: Some(input_file.clone()),
output: Some(output_dir),
namespace: None,
pack_format: None,
description: None,
verbose: false,
quiet: false,
zip: false,
validate: false,
dry_run: false,
commands_json: PathBuf::from("data/commands.json"),
})
.unwrap_err();
assert!(error.contains("Cannot import 'missing'"));
assert!(error.contains(&input_file.display().to_string()));
}
#[test]
fn build_fails_on_import_cycle_with_chain() {
let temp_dir = tempfile::TempDir::new().unwrap();
let main_file = temp_dir.path().join("main.cbl");
let helper_file = temp_dir.path().join("helper.cbl");
let output_dir = temp_dir.path().join("output");
fs::write(&main_file, "import helper\n\ndef main():\n /say main\n").unwrap();
fs::write(
&helper_file,
"import main\n\ndef helper():\n /say helper\n",
)
.unwrap();
let error = build(BuildOptions {
input: Some(main_file.clone()),
output: Some(output_dir),
namespace: None,
pack_format: None,
description: None,
verbose: false,
quiet: false,
zip: false,
validate: false,
dry_run: false,
commands_json: PathBuf::from("data/commands.json"),
})
.unwrap_err();
assert!(error.contains("Circular import detected"));
assert!(error.contains(&main_file.display().to_string()));
assert!(error.contains(&helper_file.display().to_string()));
}
#[test]
fn cli_pack_format_overrides_invalid_config_value() {
let temp_dir = tempfile::TempDir::new().unwrap();
let source_dir = temp_dir.path().join("src");
let output_dir = temp_dir.path().join("output");
fs::create_dir_all(&source_dir).unwrap();
fs::write(source_dir.join("main.cbl"), "def main():\n /say hi\n").unwrap();
fs::write(
temp_dir.path().join("cobble.toml"),
r#"
[project]
name = "Override"
description = "Override"
namespace = "override"
pack_format = "18"
[build]
source = "src"
output = "output"
"#,
)
.unwrap();
build(BuildOptions {
input: Some(source_dir),
output: Some(output_dir.clone()),
namespace: None,
pack_format: Some("101.1".to_string()),
description: None,
verbose: false,
quiet: false,
zip: false,
validate: false,
dry_run: false,
commands_json: PathBuf::from("data/commands.json"),
})
.unwrap();
assert!(output_dir
.join("data/override/function/main.mcfunction")
.exists());
}
#[test]
fn empty_source_directory_cleans_previous_output() {
let temp_dir = tempfile::TempDir::new().unwrap();
let source_dir = temp_dir.path().join("src");
let output_dir = temp_dir.path().join("output");
let input_file = source_dir.join("main.cbl");
fs::create_dir_all(&source_dir).unwrap();
fs::write(&input_file, "def main():\n /say hi\n").unwrap();
let build_once = || {
build(BuildOptions {
input: Some(source_dir.clone()),
output: Some(output_dir.clone()),
namespace: Some("stale".to_string()),
pack_format: None,
description: None,
verbose: false,
quiet: false,
zip: false,
validate: false,
dry_run: false,
commands_json: PathBuf::from("data/commands.json"),
})
};
build_once().unwrap();
assert!(output_dir
.join("data/stale/function/main.mcfunction")
.exists());
fs::remove_file(input_file).unwrap();
let error = build_once().unwrap_err();
assert!(error.contains("No Cobble files found"));
assert!(!output_dir
.join("data/stale/function/main.mcfunction")
.exists());
}
#[test]
fn zip_contains_only_datapack_files_when_output_is_current_dir() {
let temp_dir = tempfile::TempDir::new().unwrap();
let _lock = CWD_LOCK.lock().unwrap();
let _guard = CurrentDirGuard::push(temp_dir.path());
fs::write("main.cbl", "def main():\n /say hi\n").unwrap();
build(BuildOptions {
input: Some(PathBuf::from("main.cbl")),
output: Some(PathBuf::from(".")),
namespace: Some("zipped".to_string()),
pack_format: None,
description: None,
verbose: false,
quiet: false,
zip: true,
validate: false,
dry_run: false,
commands_json: PathBuf::from("data/commands.json"),
})
.unwrap();
let zip_file = fs::File::open(temp_dir.path().join("zipped.zip")).unwrap();
let mut archive = zip::ZipArchive::new(zip_file).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|index| archive.by_index(index).unwrap().name().to_string())
.collect();
assert!(names.iter().any(|name| name == "pack.mcmeta"));
assert!(names
.iter()
.any(|name| name == "data/zipped/function/main.mcfunction"));
assert!(!names.iter().any(|name| name == "main.cbl"));
assert!(!names.iter().any(|name| name == "zipped.zip"));
assert!(!names.iter().any(|name| name.starts_with(".cobble/")));
}
}
fn create_zip(output_dir: &Path, namespace: &str) -> Result<PathBuf, String> {
let zip_path = output_dir.with_file_name(format!("{}.zip", namespace));
let file =
fs::File::create(&zip_path).map_err(|e| format!("Failed to create zip file: {}", e))?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
for entry in WalkDir::new(output_dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_file() {
let relative_path = path
.strip_prefix(output_dir)
.map_err(|e| format!("Failed to get relative path: {}", e))?;
let zip_path = relative_path.to_string_lossy().replace('\\', "/");
if zip_path != "pack.mcmeta" && !zip_path.starts_with("data/") {
continue;
}
let file_data =
fs::read(path).map_err(|e| format!("Failed to read file for zip: {}", e))?;
zip.start_file(zip_path, options)
.map_err(|e| format!("Failed to add file to zip: {}", e))?;
zip.write_all(&file_data)
.map_err(|e| format!("Failed to write file to zip: {}", e))?;
}
}
zip.finish()
.map_err(|e| format!("Failed to finalize zip: {}", e))?;
Ok(zip_path)
}