feat(release): 增强打包流程, 支持 --pin、.emod-ignore、.emod-package

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

View File

@@ -34,8 +34,12 @@ pub struct ReleaseArgs {
#[arg(short, long)]
pub path: Option<String>,
/// The version of the project
#[arg(short, long)]
#[arg(short, long, conflicts_with = "pin")]
pub ver: Option<String>,
/// 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)]

View File

@@ -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) {
@@ -28,48 +29,100 @@ fn run_release(args: &ReleaseArgs) -> Result<()> {
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<String>,
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<String>, current: &[u32]) -> Result<Vec<u32>> {
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,
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(())
}
@@ -89,21 +142,8 @@ fn update_pack_json(project_dir: &PathBuf, version: &Value) -> Result<()> {
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();
@@ -117,25 +157,26 @@ fn update_manifest_json(
fn package_project(
project_dir: &PathBuf,
release_info: &ReleaseInfo,
pack_dirs: &PackDirs,
version: &str,
) -> Result<String> {
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,14 +185,16 @@ 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())
));
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(());
}
@@ -161,40 +204,748 @@ fn add_directory_to_zip(
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<usize> {
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?;
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<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()
}
}