Files
emod-cli/src/commands/release.rs
Blank038 85aa369793 feat(release): 增强打包流程, 支持 --pin、.emod-ignore、.emod-package
- 新增 --pin/-P 参数: 保留当前版本号不打补丁, 用于失败后重试发布
- 新增 .emod-ignore: gitignore 风格的打包排除规则, 支持通配符和取反
- 新增 .emod-package: 自定义打包包含规则, 支持通配符匹配
- 添加 preflight_pack_dirs 预检: 在打包前验证行为包/资源包目录和清单文件
- 引入 PackDirs 结构体, 消除重复的目录拼接逻辑
- 修复 ZIP 条目路径在 Windows 使用反斜杠导致网易审核工具报错
- 新增 zip_entry_path 统一使用正斜杠
- 添加 10 个单元测试覆盖核心场景
2026-05-04 01:31:44 +08:00

952 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
pin: bool,
current: &[u32],
) -> Result<Vec<u32>> {
if let Some(ver_str) = version {
ver_str
.split(".")
.map(|s| s.parse::<u32>().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<PackDirs> {
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<String> {
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<File>,
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<File>,
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<File>,
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<bool> {
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<usize> {
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<IgnoreRule>,
}
impl EmodIgnore {
fn load(project_dir: &Path) -> Result<Self> {
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<IgnoreRule>,
}
impl EmodPackage {
fn load(path: &Path) -> Result<Self> {
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<Option<Self>> {
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<String> {
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<PathCandidate<'_>> {
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(&regex::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<String> {
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()
}
}