diff --git a/src/commands/release.rs b/src/commands/release.rs index 7764cd2..56720e3 100644 --- a/src/commands/release.rs +++ b/src/commands/release.rs @@ -1,4 +1,5 @@ use std::{ + env, fs::{self, File}, io::{Read, Write}, path::{Path, PathBuf}, @@ -14,6 +15,8 @@ use crate::error::{CliError, Result}; use crate::utils::file; use crate::{entity, entity::project::ReleaseInfo}; +const ARTIFACTS_DIR_ENV: &str = "EMOD_ARTIFACTS_DIR"; + pub fn execute(args: &ReleaseArgs) { if let Err(e) = run_release(args) { eprintln!("❌ 组件打包失败: {}", e); @@ -34,11 +37,7 @@ fn run_release(args: &ReleaseArgs) -> Result<()> { Ok(()) } -fn release( - args: &ReleaseArgs, - project_dir: &PathBuf, - release_info: &ReleaseInfo, -) -> Result<()> { +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()); @@ -50,16 +49,12 @@ fn release( 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("\\", "/")); + let display_path = output_path.display().to_string().replace("\\", "/"); + println!("📦 打包完成: {}", display_path); Ok(()) } -fn calculate_version( - version: &Option, - pin: bool, - current: &[u32], -) -> Result> { +fn calculate_version(version: &Option, pin: bool, current: &[u32]) -> Result> { if let Some(ver_str) = version { ver_str .split(".") @@ -116,11 +111,7 @@ fn ensure_pack_ready(label: &str, pack_dir: &Path, identifier: &str) -> Result<( Ok(()) } -fn update_versions( - project_dir: &PathBuf, - version: &Value, - pack_dirs: &PackDirs, -) -> Result<()> { +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(()) @@ -155,11 +146,7 @@ fn update_manifest_json(version: &Value, pack_dirs: &PackDirs) -> Result<()> { Ok(()) } -fn package_project( - project_dir: &PathBuf, - pack_dirs: &PackDirs, - version: &str, -) -> Result { +fn package_project(project_dir: &PathBuf, pack_dirs: &PackDirs, version: &str) -> Result { let canonical_project_dir = project_dir .canonicalize() .map_err(|e| file::io_error("解析项目目录", project_dir, e))?; @@ -178,15 +165,25 @@ fn package_project( canonical_project_dir.display() )) })?; - let output_path = format!( - "{}/{}_release_{}.zip", - project_dir.display(), - project_name, - version - ); - let output_pathbuf = PathBuf::from(&output_path); + + let output_dir = + match env::var_os(ARTIFACTS_DIR_ENV).filter(|value| !value.as_os_str().is_empty()) { + Some(artifacts_dir) => { + let output_dir = PathBuf::from(artifacts_dir).join(project_name); + fs::create_dir_all(&output_dir) + .map_err(|e| file::io_error("创建打包输出目录", &output_dir, e))?; + println!( + "📦 使用 {} 指定的打包输出目录: {}", + ARTIFACTS_DIR_ENV, + output_dir.display().to_string().replace("\\", "/") + ); + output_dir + } + None => project_dir.clone(), + }; + let output_path = output_dir.join(format!("{project_name}_release_{version}.zip")); let file = fs::File::create(&output_path) - .map_err(|e| file::io_error("创建打包文件", &output_pathbuf, e))?; + .map_err(|e| file::io_error("创建打包文件", &output_path, e))?; let mut zip = zip::ZipWriter::new(file); let ignore = EmodIgnore::load(project_dir)?; @@ -276,8 +273,7 @@ fn add_world_pack_files( } zip.start_file(name, options)?; - let mut f = - File::open(&path).map_err(|e| file::io_error("打开打包文件", &path, e))?; + 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)?; @@ -342,8 +338,7 @@ fn add_with_package( continue; } zip.start_file(&path_str, options)?; - let mut f = - File::open(path).map_err(|e| file::io_error("打开打包文件", path, e))?; + 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)?; @@ -554,10 +549,7 @@ fn normalize_path(path: &Path) -> String { /// 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 - )) + crate::error::CliError::InvalidData(format!("{:?} 不是有效的 UTF-8 路径", relative_path)) })?; Ok(s.replace('\\', "/")) } @@ -614,9 +606,12 @@ mod tests { use super::*; use std::{ fs, + sync::Mutex, time::{SystemTime, UNIX_EPOCH}, }; + static ARTIFACTS_ENV_LOCK: Mutex<()> = Mutex::new(()); + #[test] fn preflight_pack_dirs_reports_missing_behavior_pack() { let project_dir = temp_project_dir(); @@ -675,6 +670,66 @@ mod tests { assert_eq!(v, vec![1, 2, 3]); } + #[test] + fn package_writes_to_project_dir_when_env_unset() { + 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", + ); + + let pack_dirs = pack_dirs(&project_dir, &release_info); + let zip_path = + package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.2.3").unwrap(); + let project_name = project_dir.file_name().unwrap().to_string_lossy(); + let expected_path = project_dir.join(format!("{project_name}_release_1.2.3.zip")); + + assert_eq!(zip_path, expected_path); + assert!(zip_path.is_file()); + + fs::remove_dir_all(project_dir).unwrap(); + } + + #[test] + fn package_writes_to_artifacts_subdir_when_env_set() { + let project_dir = temp_project_dir(); + let artifacts_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", + ); + + let pack_dirs = pack_dirs(&project_dir, &release_info); + let zip_path = + package_project_with_artifacts_env(&project_dir, &pack_dirs, "1.0.0", &artifacts_dir) + .unwrap(); + let project_name = project_dir.file_name().unwrap().to_string_lossy(); + let expected_path = artifacts_dir + .join(project_name.as_ref()) + .join(format!("{project_name}_release_1.0.0.zip")); + let project_dir_zip = project_dir.join(format!("{project_name}_release_1.0.0.zip")); + + assert_eq!(zip_path, expected_path); + assert!(zip_path.is_file()); + assert!(expected_path.parent().unwrap().is_dir()); + assert!(!project_dir_zip.exists()); + + fs::remove_dir_all(project_dir).unwrap(); + fs::remove_dir_all(artifacts_dir).unwrap(); + } + #[test] fn package_project_skips_files_listed_in_emod_ignore() { let project_dir = temp_project_dir(); @@ -702,7 +757,8 @@ mod tests { ); let pack_dirs = pack_dirs(&project_dir, &release_info); - let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let zip_path = + package_project_with_artifacts_env_unset(&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())); @@ -736,7 +792,8 @@ mod tests { ); let pack_dirs = pack_dirs(&project_dir, &release_info); - let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let zip_path = + package_project_with_artifacts_env_unset(&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())); @@ -763,7 +820,8 @@ mod tests { 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 zip_path = + package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap(); let entries = zip_entries(&zip_path); assert!(entries.contains(&"world_behavior_packs.json".to_string())); @@ -796,7 +854,8 @@ mod tests { 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 zip_path = + package_project_with_artifacts_env_unset(&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())); @@ -831,7 +890,8 @@ mod tests { ); let pack_dirs = pack_dirs(&project_dir, &release_info); - let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let zip_path = + package_project_with_artifacts_env_unset(&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())); @@ -846,7 +906,10 @@ mod tests { 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/manifest.json"), + "{}", + ); write_file( &project_dir.join("behavior_pack_abcdefgh/scripts/main.py"), "main", @@ -854,7 +917,8 @@ mod tests { 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 zip_path = + package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap(); let entries = zip_entries(&zip_path); assert!(entries.contains(&"behavior_pack_abcdefgh/manifest.json".to_string())); @@ -885,7 +949,8 @@ mod tests { 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 zip_path = + package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap(); let entries = zip_entries(&zip_path); for entry in &entries { @@ -904,7 +969,10 @@ mod tests { 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-package"), + "behavior_pack_abcdefgh/\n", + ); write_file( &project_dir.join("behavior_pack_abcdefgh/BoxData/data.json"), "{}", @@ -915,7 +983,8 @@ mod tests { ); let pack_dirs = pack_dirs(&project_dir, &release_info); - let zip_path = package_project(&project_dir, &pack_dirs, "1.0.0").unwrap(); + let zip_path = + package_project_with_artifacts_env_unset(&project_dir, &pack_dirs, "1.0.0").unwrap(); let entries = zip_entries(&zip_path); for entry in &entries { @@ -964,7 +1033,48 @@ mod tests { fs::write(path, content).unwrap(); } - fn zip_entries(path: &str) -> Vec { + fn package_project_with_artifacts_env_unset( + project_dir: &PathBuf, + pack_dirs: &PackDirs, + version: &str, + ) -> Result { + let _guard = ARTIFACTS_ENV_LOCK.lock().unwrap(); + remove_artifacts_dir_env(); + let result = package_project(project_dir, pack_dirs, version); + remove_artifacts_dir_env(); + result + } + + fn package_project_with_artifacts_env( + project_dir: &PathBuf, + pack_dirs: &PackDirs, + version: &str, + artifacts_dir: &Path, + ) -> Result { + let _guard = ARTIFACTS_ENV_LOCK.lock().unwrap(); + set_artifacts_dir_env(artifacts_dir); + let result = package_project(project_dir, pack_dirs, version); + remove_artifacts_dir_env(); + result + } + + fn set_artifacts_dir_env(path: &Path) { + // SAFETY: tests serialize every EMOD_ARTIFACTS_DIR mutation and every + // package_project call through ARTIFACTS_ENV_LOCK. + unsafe { + env::set_var(ARTIFACTS_DIR_ENV, path.as_os_str()); + } + } + + fn remove_artifacts_dir_env() { + // SAFETY: tests serialize every EMOD_ARTIFACTS_DIR mutation and every + // package_project call through ARTIFACTS_ENV_LOCK. + unsafe { + env::remove_var(ARTIFACTS_DIR_ENV); + } + } + + fn zip_entries(path: &Path) -> Vec { let file = File::open(path).unwrap(); let mut archive = zip::ZipArchive::new(file).unwrap(); (0..archive.len())