From 85aa3697938579c579b0fbee292e3f06426c5cb0 Mon Sep 17 00:00:00 2001 From: Blank038 Date: Mon, 4 May 2026 01:31:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20=E5=A2=9E=E5=BC=BA=E6=89=93?= =?UTF-8?q?=E5=8C=85=E6=B5=81=E7=A8=8B,=20=E6=94=AF=E6=8C=81=20--pin?= =?UTF-8?q?=E3=80=81.emod-ignore=E3=80=81.emod-package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 --pin/-P 参数: 保留当前版本号不打补丁, 用于失败后重试发布 - 新增 .emod-ignore: gitignore 风格的打包排除规则, 支持通配符和取反 - 新增 .emod-package: 自定义打包包含规则, 支持通配符匹配 - 添加 preflight_pack_dirs 预检: 在打包前验证行为包/资源包目录和清单文件 - 引入 PackDirs 结构体, 消除重复的目录拼接逻辑 - 修复 ZIP 条目路径在 Windows 使用反斜杠导致网易审核工具报错 - 新增 zip_entry_path 统一使用正斜杠 - 添加 10 个单元测试覆盖核心场景 --- src/commands/mod.rs | 6 +- src/commands/release.rs | 893 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 827 insertions(+), 72 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 281043a..30c2f8c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -34,8 +34,12 @@ pub struct ReleaseArgs { #[arg(short, long)] pub path: Option, /// The version of the project - #[arg(short, long)] + #[arg(short, long, conflicts_with = "pin")] pub ver: Option, + /// Reuse the current version without auto-incrementing. + /// Useful when retrying after a failed release that already wrote new version files. + #[arg(short = 'P', long, conflicts_with = "ver")] + pub pin: bool, } #[derive(Args)] diff --git a/src/commands/release.rs b/src/commands/release.rs index 6745f6d..af6fb40 100644 --- a/src/commands/release.rs +++ b/src/commands/release.rs @@ -4,14 +4,15 @@ use std::{ path::{Path, PathBuf}, }; +use regex::Regex; use serde_json::Value; use walkdir; use zip::write::SimpleFileOptions; use crate::commands::ReleaseArgs; +use crate::error::Result; use crate::utils::file; use crate::{entity, entity::project::ReleaseInfo}; -use crate::error::Result; pub fn execute(args: &ReleaseArgs) { if let Err(e) = run_release(args) { @@ -24,52 +25,104 @@ pub fn execute(args: &ReleaseArgs) { fn run_release(args: &ReleaseArgs) -> Result<()> { let project_dir = file::find_project_dir(&args.path)?; let release_info = entity::get_current_release_info(&project_dir)?; - + println!("🔖 当前行为包版本: {:?}", release_info.behavior_version); println!("🔖 当前资源包版本: {:?}", release_info.resource_version); - - release(&args.ver, &project_dir, &release_info)?; - + + release(args, &project_dir, &release_info)?; + Ok(()) } fn release( - version: &Option, + args: &ReleaseArgs, project_dir: &PathBuf, release_info: &ReleaseInfo, ) -> Result<()> { - let new_version = calculate_version(version, &release_info.behavior_version)?; + let new_version = calculate_version(&args.ver, args.pin, &release_info.behavior_version)?; let version_value = Value::Array(new_version.iter().map(|v| Value::from(*v)).collect()); - + println!("📦 开始打包, 版本号: {:?}", &new_version); - - update_versions(&project_dir, &release_info, &version_value)?; - + + let pack_dirs = preflight_pack_dirs(project_dir, release_info)?; + + update_versions(&project_dir, &version_value, &pack_dirs)?; + let version_str = format!("{}.{}.{}", new_version[0], new_version[1], new_version[2]); - let output_path = package_project(&project_dir, &release_info, &version_str)?; - + let output_path = package_project(&project_dir, &pack_dirs, &version_str)?; + println!("📦 打包完成: {}", output_path.replace("\\", "/")); Ok(()) } -fn calculate_version(version: &Option, current: &[u32]) -> Result> { +fn calculate_version( + version: &Option, + pin: bool, + current: &[u32], +) -> Result> { if let Some(ver_str) = version { ver_str .split(".") .map(|s| s.parse::().map_err(|e| e.into())) .collect() + } else if pin { + Ok(current.to_vec()) } else { Ok(vec![current[0], current[1], current[2] + 1]) } } +#[derive(Debug)] +struct PackDirs { + behavior: PathBuf, + resource: PathBuf, +} + +fn preflight_pack_dirs(project_dir: &Path, release_info: &ReleaseInfo) -> Result { + let behavior = project_dir.join(format!( + "behavior_pack_{}", + release_info.behavior_identifier + )); + let resource = project_dir.join(format!( + "resource_pack_{}", + release_info.resource_identifier + )); + + ensure_pack_ready("行为包", &behavior, &release_info.behavior_identifier)?; + ensure_pack_ready("资源包", &resource, &release_info.resource_identifier)?; + + Ok(PackDirs { behavior, resource }) +} + +fn ensure_pack_ready(label: &str, pack_dir: &Path, identifier: &str) -> Result<()> { + if !pack_dir.is_dir() { + return Err(crate::error::CliError::NotFound(format!( + "未找到{}目录 '{}',请确认目录是否按 pack_id 前 8 位 '{}' 命名", + label, + pack_dir.display(), + identifier + ))); + } + + let manifest_path = pack_dir.join("pack_manifest.json"); + if !manifest_path.is_file() { + return Err(crate::error::CliError::NotFound(format!( + "{}缺少 pack_manifest.json: '{}'", + label, + manifest_path.display() + ))); + } + + Ok(()) +} + fn update_versions( project_dir: &PathBuf, - release_info: &ReleaseInfo, version: &Value, + pack_dirs: &PackDirs, ) -> Result<()> { update_pack_json(&project_dir, &version)?; - update_manifest_json(&project_dir, &release_info, &version)?; + update_manifest_json(&version, pack_dirs)?; Ok(()) } @@ -78,32 +131,19 @@ fn update_pack_json(project_dir: &PathBuf, version: &Value) -> Result<()> { project_dir.join("world_behavior_packs.json"), project_dir.join("world_resource_packs.json"), ]; - + for path in paths { file::update_json_file(&path, |json| { json[0]["version"] = version.clone(); Ok(()) })?; } - + Ok(()) } -fn update_manifest_json( - project_dir: &PathBuf, - release_info: &ReleaseInfo, - version: &Value, -) -> Result<()> { - let behavior_dir = project_dir.join(format!( - "behavior_pack_{}", - release_info.behavior_identifier - )); - let resource_dir = project_dir.join(format!( - "resource_pack_{}", - release_info.resource_identifier - )); - - for pack_dir in [behavior_dir, resource_dir] { +fn update_manifest_json(version: &Value, pack_dirs: &PackDirs) -> Result<()> { + for pack_dir in [&pack_dirs.behavior, &pack_dirs.resource] { let manifest_path = pack_dir.join("pack_manifest.json"); file::update_json_file(&manifest_path, |json| { json["header"]["version"] = version.clone(); @@ -111,31 +151,32 @@ fn update_manifest_json( Ok(()) })?; } - + Ok(()) } fn package_project( project_dir: &PathBuf, - release_info: &ReleaseInfo, + pack_dirs: &PackDirs, version: &str, ) -> Result { let output_path = format!("{}/release_{}.zip", project_dir.display(), version); - let file = fs::File::create(&output_path)?; + let output_pathbuf = PathBuf::from(&output_path); + let file = fs::File::create(&output_path) + .map_err(|e| file::io_error("创建打包文件", &output_pathbuf, e))?; let mut zip = zip::ZipWriter::new(file); - - let behavior_dir = project_dir.join(format!( - "behavior_pack_{}", - release_info.behavior_identifier - )); - let resource_dir = project_dir.join(format!( - "resource_pack_{}", - release_info.resource_identifier - )); - - add_directory_to_zip(&mut zip, &project_dir, &behavior_dir)?; - add_directory_to_zip(&mut zip, &project_dir, &resource_dir)?; - + + let ignore = EmodIgnore::load(project_dir)?; + let package_path = project_dir.join(".emod-package"); + + if package_path.is_file() { + let package = EmodPackage::load(&package_path)?; + add_with_package(&mut zip, project_dir, &package, &ignore)?; + } else { + add_directory_to_zip(&mut zip, &project_dir, &pack_dirs.behavior, &ignore)?; + add_directory_to_zip(&mut zip, &project_dir, &pack_dirs.resource, &ignore)?; + add_world_pack_files(&mut zip, project_dir, &ignore)?; + } zip.finish()?; Ok(output_path) } @@ -144,57 +185,767 @@ fn add_directory_to_zip( zip: &mut zip::ZipWriter, project_dir: &PathBuf, src_dir: &PathBuf, + ignore: &EmodIgnore, ) -> Result<()> { if !src_dir.is_dir() { - return Err(crate::error::CliError::InvalidData( - format!("{} 不是目录", src_dir.display()) - )); + return Err(crate::error::CliError::InvalidData(format!( + "{} 不是目录", + src_dir.display() + ))); } - - if count_files(src_dir)? == 0 { + + if count_files(src_dir, project_dir, ignore)? == 0 { return Ok(()); } - + let options = SimpleFileOptions::default(); let mut buffer = Vec::new(); - + for entry in walkdir::WalkDir::new(src_dir) { let entry = entry?; let path = entry.path(); - let relative_path = path.strip_prefix(project_dir) + let relative_path = path + .strip_prefix(project_dir) .map_err(|e| crate::error::CliError::InvalidData(e.to_string()))?; - - let path_str = relative_path - .to_str() - .ok_or_else(|| crate::error::CliError::InvalidData( - format!("{:?} 不是有效的 UTF-8 路径", relative_path) - ))?; - + + let path_str = zip_entry_path(relative_path)?; + + if ignore.is_ignored(relative_path, path.is_dir()) { + continue; + } + if path.is_file() { if path_str.ends_with(".gitkeep") { continue; } - zip.start_file(path_str, options)?; + zip.start_file(&path_str, options)?; let mut f = File::open(path)?; f.read_to_end(&mut buffer)?; zip.write_all(&buffer)?; buffer.clear(); - } else if !relative_path.as_os_str().is_empty() && count_files(path)? > 0 { - zip.add_directory(path_str, options)?; + } else if !relative_path.as_os_str().is_empty() + && count_files(path, project_dir, ignore)? > 0 + { + zip.add_directory(&path_str, options)?; } } - + Ok(()) } -fn count_files(dir: &Path) -> Result { +fn add_world_pack_files( + zip: &mut zip::ZipWriter, + project_dir: &Path, + ignore: &EmodIgnore, +) -> Result<()> { + let options = SimpleFileOptions::default(); + let mut buffer = Vec::new(); + + for name in ["world_behavior_packs.json", "world_resource_packs.json"] { + let path = project_dir.join(name); + if !path.is_file() { + continue; + } + + let relative_path = Path::new(name); + if ignore.is_ignored(relative_path, false) { + continue; + } + + zip.start_file(name, options)?; + let mut f = + File::open(&path).map_err(|e| file::io_error("打开打包文件", &path, e))?; + f.read_to_end(&mut buffer) + .map_err(|e| file::io_error("读取打包文件", &path, e))?; + zip.write_all(&buffer)?; + buffer.clear(); + } + + Ok(()) +} + +fn add_with_package( + zip: &mut zip::ZipWriter, + project_dir: &Path, + package: &EmodPackage, + ignore: &EmodIgnore, +) -> Result<()> { + let options = SimpleFileOptions::default(); + let mut buffer = Vec::new(); + + let walker = walkdir::WalkDir::new(project_dir) + .into_iter() + .filter_entry(|entry| { + let path = entry.path(); + if path == project_dir { + return true; + } + let relative_path = match path.strip_prefix(project_dir) { + Ok(p) => p, + Err(_) => return false, + }; + !ignore.is_ignored(relative_path, path.is_dir()) + }); + + for entry in walker { + let entry = entry?; + let path = entry.path(); + if path == project_dir { + continue; + } + + let relative_path = path + .strip_prefix(project_dir) + .map_err(|e| crate::error::CliError::InvalidData(e.to_string()))?; + let path_str = zip_entry_path(relative_path)?; + + let is_dir = path.is_dir(); + + // .emod-ignore 优先:filter_entry 已剪枝,这里再防御一次。 + if ignore.is_ignored(relative_path, is_dir) { + continue; + } + if !package.matches(relative_path, is_dir) { + // 目录本身未命中,但其子孙可能命中,所以继续遍历。 + if !is_dir { + continue; + } + // 当前目录未命中且无被包含的子孙时,无需写出目录条目。 + continue; + } + + if path.is_file() { + if path_str.ends_with(".gitkeep") { + continue; + } + zip.start_file(&path_str, options)?; + let mut f = + File::open(path).map_err(|e| file::io_error("打开打包文件", path, e))?; + f.read_to_end(&mut buffer) + .map_err(|e| file::io_error("读取打包文件", path, e))?; + zip.write_all(&buffer)?; + buffer.clear(); + } else if !relative_path.as_os_str().is_empty() + && dir_has_included_files(path, project_dir, package, ignore)? + { + zip.add_directory(&path_str, options)?; + } + } + + Ok(()) +} + +fn dir_has_included_files( + dir: &Path, + project_dir: &Path, + package: &EmodPackage, + ignore: &EmodIgnore, +) -> Result { + for entry in walkdir::WalkDir::new(dir) { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + if path.display().to_string().ends_with(".gitkeep") { + continue; + } + let relative_path = path + .strip_prefix(project_dir) + .map_err(|e| crate::error::CliError::InvalidData(e.to_string()))?; + if ignore.is_ignored(relative_path, false) { + continue; + } + if !package.matches(relative_path, false) { + continue; + } + return Ok(true); + } + Ok(false) +} + +fn count_files(dir: &Path, project_dir: &Path, ignore: &EmodIgnore) -> Result { let mut count = 0; for entry in walkdir::WalkDir::new(dir) { let entry = entry?; - if entry.path().is_file() - && !entry.path().display().to_string().ends_with(".gitkeep") { + let path = entry.path(); + if !path.is_file() { + continue; + } + + let relative_path = path + .strip_prefix(project_dir) + .map_err(|e| crate::error::CliError::InvalidData(e.to_string()))?; + + if !path.display().to_string().ends_with(".gitkeep") + && !ignore.is_ignored(relative_path, false) + { count += 1; } } Ok(count) } + +struct EmodIgnore { + rules: Vec, +} + +impl EmodIgnore { + fn load(project_dir: &Path) -> Result { + let ignore_path = project_dir.join(".emod-ignore"); + if !ignore_path.exists() { + return Ok(Self { rules: Vec::new() }); + } + + let content = fs::read_to_string(ignore_path)?; + let mut rules = Vec::new(); + + for line in content.lines() { + if let Some(rule) = IgnoreRule::parse(line)? { + rules.push(rule); + } + } + + Ok(Self { rules }) + } + + fn is_ignored(&self, relative_path: &Path, is_dir: bool) -> bool { + let path = normalize_path(relative_path); + let candidates = path_candidates(&path, is_dir); + let mut ignored = false; + + for rule in &self.rules { + if candidates.iter().any(|candidate| rule.matches(candidate)) { + ignored = !rule.negated; + } + } + + ignored + } +} + +struct EmodPackage { + rules: Vec, +} + +impl EmodPackage { + fn load(path: &Path) -> Result { + let content = + fs::read_to_string(path).map_err(|e| file::io_error("读取 .emod-package", path, e))?; + let mut rules = Vec::new(); + + for line in content.lines() { + if let Some(rule) = IgnoreRule::parse(line)? { + rules.push(rule); + } + } + + Ok(Self { rules }) + } + + fn matches(&self, relative_path: &Path, is_dir: bool) -> bool { + let path = normalize_path(relative_path); + let candidates = path_candidates(&path, is_dir); + let mut included = false; + + for rule in &self.rules { + if candidates.iter().any(|candidate| rule.matches(candidate)) { + included = !rule.negated; + } + } + + included + } +} + +struct IgnoreRule { + regex: Regex, + basename_only: bool, + directory_only: bool, + negated: bool, +} + +impl IgnoreRule { + fn parse(line: &str) -> Result> { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return Ok(None); + } + + let (negated, raw_pattern) = line + .strip_prefix('!') + .map_or((false, line), |pattern| (true, pattern.trim())); + if raw_pattern.is_empty() { + return Ok(None); + } + + let normalized = raw_pattern.replace('\\', "/"); + let pattern = normalized.trim_start_matches('/'); + let directory_only = pattern.ends_with('/'); + let pattern = pattern.trim_end_matches('/'); + if pattern.is_empty() { + return Ok(None); + } + + let basename_only = !pattern.contains('/'); + let regex = Regex::new(&format!("^{}$", glob_to_regex(pattern))).map_err(|err| { + crate::error::CliError::InvalidData(format!(".emod-ignore 规则无效: {}", err)) + })?; + + Ok(Some(Self { + regex, + basename_only, + directory_only, + negated, + })) + } + + fn matches(&self, candidate: &PathCandidate) -> bool { + if self.directory_only && !candidate.is_dir { + return false; + } + + let target = if self.basename_only { + candidate.path.rsplit('/').next().unwrap_or(candidate.path) + } else { + candidate.path + }; + + self.regex.is_match(target) + } +} + +struct PathCandidate<'a> { + path: &'a str, + is_dir: bool, +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +/// Build a zip entry path from a relative `Path`. +/// +/// ZIP spec (PKWARE APPNOTE 4.4.17.1) requires forward slashes in entry names. On +/// Windows `Path::to_str()` returns backslash-separated paths, which Netease's +/// review tooling rejects as "中文或特殊符号". Always normalize to '/'. +fn zip_entry_path(relative_path: &Path) -> Result { + let s = relative_path.to_str().ok_or_else(|| { + crate::error::CliError::InvalidData(format!( + "{:?} 不是有效的 UTF-8 路径", + relative_path + )) + })?; + Ok(s.replace('\\', "/")) +} + +fn path_candidates(path: &str, is_dir: bool) -> Vec> { + let mut candidates = Vec::new(); + let mut offset = 0; + + while let Some(index) = path[offset..].find('/') { + let end = offset + index; + candidates.push(PathCandidate { + path: &path[..end], + is_dir: true, + }); + offset = end + 1; + } + + candidates.push(PathCandidate { path, is_dir }); + candidates +} + +fn glob_to_regex(pattern: &str) -> String { + let mut regex = String::new(); + let mut chars = pattern.chars().peekable(); + let mut at_start = true; + + while let Some(ch) = chars.next() { + match ch { + '*' => { + if chars.peek() == Some(&'*') { + chars.next(); + // gitignore: a leading `**/` matches at any depth, including 0. + if at_start && chars.peek() == Some(&'/') { + chars.next(); + regex.push_str("(?:.*/)?"); + } else { + regex.push_str(".*"); + } + } else { + regex.push_str("[^/]*"); + } + } + '?' => regex.push_str("[^/]"), + other => regex.push_str(®ex::escape(&other.to_string())), + } + at_start = false; + } + + regex +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + #[test] + fn preflight_pack_dirs_reports_missing_behavior_pack() { + let project_dir = temp_project_dir(); + fs::create_dir_all(&project_dir).unwrap(); + let release_info = release_info(); + + let err = preflight_pack_dirs(&project_dir, &release_info) + .expect_err("missing pack dir should error"); + let msg = err.to_string(); + + assert!( + msg.contains("behavior_pack_abcdefgh"), + "error must name the missing dir, got: {msg}" + ); + assert!(msg.contains("行为包"), "error must label the pack: {msg}"); + + fs::remove_dir_all(project_dir).unwrap(); + } + + #[test] + fn preflight_pack_dirs_reports_missing_pack_manifest() { + let project_dir = temp_project_dir(); + let release_info = release_info(); + + // pack dir exists, manifest does not + fs::create_dir_all(project_dir.join("behavior_pack_abcdefgh")).unwrap(); + fs::create_dir_all(project_dir.join("resource_pack_ijklmnop")).unwrap(); + + let err = preflight_pack_dirs(&project_dir, &release_info) + .expect_err("missing manifest should error"); + let msg = err.to_string(); + + assert!( + msg.contains("pack_manifest.json"), + "error must name the missing file, got: {msg}" + ); + + fs::remove_dir_all(project_dir).unwrap(); + } + + #[test] + fn calculate_version_pin_keeps_current() { + let v = calculate_version(&None, true, &[0, 0, 4]).unwrap(); + assert_eq!(v, vec![0, 0, 4]); + } + + #[test] + fn calculate_version_default_bumps_patch() { + let v = calculate_version(&None, false, &[0, 0, 4]).unwrap(); + assert_eq!(v, vec![0, 0, 5]); + } + + #[test] + fn calculate_version_explicit_overrides() { + let v = calculate_version(&Some("1.2.3".to_string()), false, &[0, 0, 4]).unwrap(); + assert_eq!(v, vec![1, 2, 3]); + } + + #[test] + fn package_project_skips_files_listed_in_emod_ignore() { + let project_dir = temp_project_dir(); + let release_info = release_info(); + + write_file( + &project_dir.join(".emod-ignore"), + "behavior_pack_abcdefgh/scripts/dev.py\n*.psd\n", + ); + write_file( + &project_dir.join("behavior_pack_abcdefgh/scripts/main.py"), + "print('main')", + ); + write_file( + &project_dir.join("behavior_pack_abcdefgh/scripts/dev.py"), + "print('dev')", + ); + write_file( + &project_dir.join("resource_pack_ijklmnop/textures/icon.png"), + "icon", + ); + write_file( + &project_dir.join("resource_pack_ijklmnop/textures/raw.psd"), + "raw", + ); + + let pack_dirs = pack_dirs(&project_dir, &release_info); + let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let entries = zip_entries(&zip_path); + + assert!(entries.contains(&"behavior_pack_abcdefgh/scripts/main.py".to_string())); + assert!(entries.contains(&"resource_pack_ijklmnop/textures/icon.png".to_string())); + assert!(!entries.contains(&"behavior_pack_abcdefgh/scripts/dev.py".to_string())); + assert!(!entries.contains(&"resource_pack_ijklmnop/textures/raw.psd".to_string())); + + fs::remove_dir_all(project_dir).unwrap(); + } + + #[test] + fn package_project_skips_directories_listed_in_emod_ignore() { + let project_dir = temp_project_dir(); + let release_info = release_info(); + + write_file( + &project_dir.join(".emod-ignore"), + "behavior_pack_abcdefgh/dev/\n", + ); + write_file( + &project_dir.join("behavior_pack_abcdefgh/dev/debug.py"), + "print('debug')", + ); + write_file( + &project_dir.join("behavior_pack_abcdefgh/live/main.py"), + "print('live')", + ); + write_file( + &project_dir.join("resource_pack_ijklmnop/textures/icon.png"), + "icon", + ); + + let pack_dirs = pack_dirs(&project_dir, &release_info); + let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let entries = zip_entries(&zip_path); + + assert!(entries.contains(&"behavior_pack_abcdefgh/live/main.py".to_string())); + assert!(!entries.contains(&"behavior_pack_abcdefgh/dev/".to_string())); + assert!(!entries.contains(&"behavior_pack_abcdefgh/dev/debug.py".to_string())); + + fs::remove_dir_all(project_dir).unwrap(); + } + + #[test] + fn package_project_includes_world_pack_json_in_default_mode() { + let project_dir = temp_project_dir(); + let release_info = release_info(); + + write_file( + &project_dir.join("behavior_pack_abcdefgh/scripts/main.py"), + "main", + ); + write_file( + &project_dir.join("resource_pack_ijklmnop/textures/icon.png"), + "icon", + ); + write_file(&project_dir.join("world_behavior_packs.json"), "[]"); + write_file(&project_dir.join("world_resource_packs.json"), "[]"); + + let pack_dirs = pack_dirs(&project_dir, &release_info); + let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let entries = zip_entries(&zip_path); + + assert!(entries.contains(&"world_behavior_packs.json".to_string())); + assert!(entries.contains(&"world_resource_packs.json".to_string())); + assert!(entries.contains(&"behavior_pack_abcdefgh/scripts/main.py".to_string())); + assert!(entries.contains(&"resource_pack_ijklmnop/textures/icon.png".to_string())); + + fs::remove_dir_all(project_dir).unwrap(); + } + + #[test] + fn package_project_uses_emod_package_when_present() { + let project_dir = temp_project_dir(); + let release_info = release_info(); + + write_file( + &project_dir.join(".emod-package"), + "behavior_pack_abcdefgh/\nworld_behavior_packs.json\n", + ); + write_file( + &project_dir.join("behavior_pack_abcdefgh/scripts/main.py"), + "main", + ); + write_file( + &project_dir.join("resource_pack_ijklmnop/textures/icon.png"), + "icon", + ); + write_file(&project_dir.join("world_behavior_packs.json"), "[]"); + write_file(&project_dir.join("world_resource_packs.json"), "[]"); + write_file(&project_dir.join("misc/notes.md"), "notes"); + + let pack_dirs = pack_dirs(&project_dir, &release_info); + let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let entries = zip_entries(&zip_path); + + assert!(entries.contains(&"behavior_pack_abcdefgh/scripts/main.py".to_string())); + assert!(entries.contains(&"world_behavior_packs.json".to_string())); + assert!(!entries.contains(&"resource_pack_ijklmnop/textures/icon.png".to_string())); + assert!(!entries.contains(&"world_resource_packs.json".to_string())); + assert!(!entries.contains(&"misc/notes.md".to_string())); + + fs::remove_dir_all(project_dir).unwrap(); + } + + #[test] + fn emod_ignore_overrides_emod_package() { + let project_dir = temp_project_dir(); + let release_info = release_info(); + + write_file( + &project_dir.join(".emod-package"), + "behavior_pack_abcdefgh/\n", + ); + write_file( + &project_dir.join(".emod-ignore"), + "behavior_pack_abcdefgh/scripts/dev.py\n", + ); + write_file( + &project_dir.join("behavior_pack_abcdefgh/scripts/main.py"), + "main", + ); + write_file( + &project_dir.join("behavior_pack_abcdefgh/scripts/dev.py"), + "dev", + ); + + let pack_dirs = pack_dirs(&project_dir, &release_info); + let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let entries = zip_entries(&zip_path); + + assert!(entries.contains(&"behavior_pack_abcdefgh/scripts/main.py".to_string())); + assert!(!entries.contains(&"behavior_pack_abcdefgh/scripts/dev.py".to_string())); + + fs::remove_dir_all(project_dir).unwrap(); + } + + #[test] + fn emod_package_supports_glob_patterns() { + let project_dir = temp_project_dir(); + let release_info = release_info(); + + write_file(&project_dir.join(".emod-package"), "**/*.json\n"); + write_file(&project_dir.join("behavior_pack_abcdefgh/manifest.json"), "{}"); + write_file( + &project_dir.join("behavior_pack_abcdefgh/scripts/main.py"), + "main", + ); + write_file(&project_dir.join("world_behavior_packs.json"), "[]"); + + let pack_dirs = pack_dirs(&project_dir, &release_info); + let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let entries = zip_entries(&zip_path); + + assert!(entries.contains(&"behavior_pack_abcdefgh/manifest.json".to_string())); + assert!(entries.contains(&"world_behavior_packs.json".to_string())); + assert!(!entries.contains(&"behavior_pack_abcdefgh/scripts/main.py".to_string())); + + fs::remove_dir_all(project_dir).unwrap(); + } + + #[test] + fn zip_entries_use_forward_slashes_only() { + let project_dir = temp_project_dir(); + let release_info = release_info(); + + write_file( + &project_dir.join("behavior_pack_abcdefgh/scripts/main.py"), + "main", + ); + write_file( + &project_dir.join("behavior_pack_abcdefgh/BoxData/data.json"), + "{}", + ); + write_file( + &project_dir.join("resource_pack_ijklmnop/textures/icon.png"), + "icon", + ); + write_file(&project_dir.join("world_behavior_packs.json"), "[]"); + write_file(&project_dir.join("world_resource_packs.json"), "[]"); + + let pack_dirs = pack_dirs(&project_dir, &release_info); + let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let entries = zip_entries(&zip_path); + + for entry in &entries { + assert!( + !entry.contains('\\'), + "zip entry must not contain backslash, got: {entry}" + ); + } + assert!(entries.contains(&"behavior_pack_abcdefgh/BoxData/data.json".to_string())); + + fs::remove_dir_all(project_dir).unwrap(); + } + + #[test] + fn zip_entries_use_forward_slashes_with_emod_package() { + let project_dir = temp_project_dir(); + let release_info = release_info(); + + write_file(&project_dir.join(".emod-package"), "behavior_pack_abcdefgh/\n"); + write_file( + &project_dir.join("behavior_pack_abcdefgh/BoxData/data.json"), + "{}", + ); + write_file( + &project_dir.join("behavior_pack_abcdefgh/scripts/main.py"), + "main", + ); + + let pack_dirs = pack_dirs(&project_dir, &release_info); + let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let entries = zip_entries(&zip_path); + + for entry in &entries { + assert!( + !entry.contains('\\'), + "zip entry must not contain backslash, got: {entry}" + ); + } + assert!(entries.contains(&"behavior_pack_abcdefgh/BoxData/data.json".to_string())); + + fs::remove_dir_all(project_dir).unwrap(); + } + + fn release_info() -> ReleaseInfo { + ReleaseInfo { + behavior_version: vec![1, 0, 0], + resource_version: vec![1, 0, 0], + behavior_identifier: "abcdefgh".to_string(), + resource_identifier: "ijklmnop".to_string(), + } + } + + fn pack_dirs(project_dir: &Path, release_info: &ReleaseInfo) -> PackDirs { + PackDirs { + behavior: project_dir.join(format!( + "behavior_pack_{}", + release_info.behavior_identifier + )), + resource: project_dir.join(format!( + "resource_pack_{}", + release_info.resource_identifier + )), + } + } + + fn temp_project_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("emod-cli-release-test-{}", nanos)) + } + + fn write_file(path: &Path, content: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + fn zip_entries(path: &str) -> Vec { + let file = File::open(path).unwrap(); + let mut archive = zip::ZipArchive::new(file).unwrap(); + (0..archive.len()) + .map(|index| archive.by_index(index).unwrap().name().to_string()) + .collect() + } +}