use std::{ fs::{self, File}, io::{Read, Write}, 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}; pub fn execute(args: &ReleaseArgs) { if let Err(e) = run_release(args) { eprintln!("❌ 组件打包失败: {}", e); return; } println!("🍀 组件打包完成"); } 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, &project_dir, &release_info)?; Ok(()) } fn release( args: &ReleaseArgs, project_dir: &PathBuf, release_info: &ReleaseInfo, ) -> Result<()> { 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); 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, &pack_dirs, &version_str)?; println!("📦 打包完成: {}", output_path.replace("\\", "/")); Ok(()) } 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, version: &Value, pack_dirs: &PackDirs, ) -> Result<()> { update_pack_json(&project_dir, &version)?; update_manifest_json(&version, pack_dirs)?; Ok(()) } fn update_pack_json(project_dir: &PathBuf, version: &Value) -> Result<()> { let paths = vec![ 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(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(); json["modules"][0]["version"] = version.clone(); Ok(()) })?; } Ok(()) } fn package_project( project_dir: &PathBuf, pack_dirs: &PackDirs, version: &str, ) -> Result { let output_path = format!("{}/release_{}.zip", project_dir.display(), version); 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 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) } 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() ))); } 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) .map_err(|e| crate::error::CliError::InvalidData(e.to_string()))?; 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)?; 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, project_dir, ignore)? > 0 { zip.add_directory(&path_str, options)?; } } Ok(()) } 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?; 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() } }