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:
@@ -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)]
|
||||
|
||||
@@ -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(®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<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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user