//! Localized release publishing for verified UnityFS text patches. use bat_assetbundle::{ patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch, StringFieldPatch, TextAssetPatch, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use crate::path_security::{ ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute, read_file_no_symlink, write_file_atomic, STATE_FILE_MODE, }; /// Atomic current pointer under the localized output root. pub const LOCALIZED_CURRENT_LINK: &str = "current"; /// Staging directory under the localized output root. pub const LOCALIZED_STAGING_DIR: &str = ".staging"; /// Version directory under the localized output root. pub const LOCALIZED_VERSIONS_DIR: &str = "versions"; /// Persisted localized release state file name. pub const LOCALIZED_VERSION_STATE_FILE: &str = "localized-version-state.json"; /// Per-release patch manifest file name. pub const LOCALIZED_PATCH_MANIFEST_FILE: &str = "localized-patch-manifest.json"; /// Current localized patch manifest schema version. pub const LOCALIZED_PATCH_MANIFEST_VERSION: u32 = 1; /// Current localized version state schema version. pub const LOCALIZED_VERSION_STATE_VERSION: u32 = 1; /// Stable marker for localized releases that are under human proofreading. pub const LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING: &str = "manual_proofreading"; /// Human label for `LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING`. pub const LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL: &str = "人工校对中"; /// One patch operation against a bundle in an official release. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LocalizedTextAssetPatch { /// Relative path of the UnityFS bundle under the official release. pub bundle_path: String, /// TextAsset replacement inside the bundle. pub text_asset: TextAssetPatch, /// TextUnit/provider metadata recorded in the localized manifest. pub metadata: Option, } /// One TypeTree string patch operation against a bundle in an official release. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LocalizedStringFieldPatch { /// Relative path of the UnityFS bundle under the official release. pub bundle_path: String, /// String field replacement inside the bundle. pub string_field: StringFieldPatch, /// TextUnit/provider metadata recorded in the localized manifest. pub metadata: Option, } /// One semantic TypeTree patch operation against a bundle in an official release. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LocalizedFieldPatch { /// Relative path of the UnityFS bundle under the official release. pub bundle_path: String, /// Semantic field replacement inside the bundle. pub field: FieldPatch, /// TextUnit/provider metadata recorded in the localized manifest. pub metadata: Option, } /// Supported localized UnityFS patch operation. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LocalizedPatchInput { /// Replace a TextAsset payload. TextAsset(LocalizedTextAssetPatch), /// Replace a TypeTree string field. StringField(LocalizedStringFieldPatch), /// Replace a supported semantic TypeTree field. Field(LocalizedFieldPatch), } /// Trace metadata for one localized patch operation. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalizedPatchOperationMetadata { /// Stable TextUnit ID from the official TextUnit index. pub text_unit_id: String, /// BLAKE3 of the source text validated before publication. pub source_text_blake3: String, /// Translation provider that produced the text, if applicable. #[serde(default, skip_serializing_if = "Option::is_none")] pub translation_provider: Option, /// Provider run ID that produced the text, if applicable. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_run_id: Option, /// Review state used by the publication input. pub review_status: String, } /// Configuration for one localized release publication. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LocalizedPatchConfig { /// Immutable, verified official release root. pub official_release_root: PathBuf, /// Separate localized publication root. pub localized_output_root: PathBuf, /// Version identifier shared with the official release. pub release_id: String, /// Optional distinct localized release ID. When omitted, `release_id` is /// used for backward-compatible publication paths. pub localized_release_id: Option, /// Allow publishing a new localized release even when the source release /// already has a localized current release. The caller should normally /// provide a distinct localized release ID. pub force: bool, /// Patch operations to apply. pub patches: Vec, /// General UnityFS text/field operations to apply. pub operations: Vec, } impl LocalizedPatchConfig { /// Creates a localized patch configuration. pub fn new( official_release_root: impl Into, localized_output_root: impl Into, release_id: impl Into, patches: Vec, ) -> Self { Self { official_release_root: official_release_root.into(), localized_output_root: localized_output_root.into(), release_id: release_id.into(), localized_release_id: None, force: false, patches, operations: Vec::new(), } } /// Sets a distinct localized release ID. pub fn with_localized_release_id(mut self, release_id: impl Into) -> Self { self.localized_release_id = Some(release_id.into()); self } /// Enables or disables forced publication. pub fn with_force(mut self, force: bool) -> Self { self.force = force; self } /// Sets general localized patch operations. pub fn with_operations(mut self, operations: Vec) -> Self { self.operations = operations; self } fn published_release_id(&self) -> &str { self.localized_release_id .as_deref() .unwrap_or(&self.release_id) } fn patch_operations(&self) -> Vec { let mut operations = self .patches .iter() .cloned() .map(LocalizedPatchInput::TextAsset) .collect::>(); operations.extend(self.operations.iter().cloned()); operations } } impl LocalizedPatchInput { fn bundle_path(&self) -> &str { match self { Self::TextAsset(operation) => &operation.bundle_path, Self::StringField(operation) => &operation.bundle_path, Self::Field(operation) => &operation.bundle_path, } } fn apply(&self, input: &[u8]) -> anyhow::Result> { match self { Self::TextAsset(operation) => { Ok(patch_unityfs_text_asset(input, &operation.text_asset)?) } Self::StringField(operation) => { Ok(patch_unityfs_string_field(input, &operation.string_field)?) } Self::Field(operation) => Ok(patch_unityfs_field(input, &operation.field)?), } } fn manifest_operation(&self) -> anyhow::Result { match self { Self::TextAsset(operation) => Ok(LocalizedPatchOperation::from_text_asset_patch( &operation.text_asset, operation.metadata.as_ref(), )), Self::StringField(operation) => Ok(LocalizedPatchOperation::from_string_field_patch( &operation.string_field, operation.metadata.as_ref(), )), Self::Field(operation) => LocalizedPatchOperation::from_field_patch( &operation.field, operation.metadata.as_ref(), ), } } } /// Persisted localized release state. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalizedVersionState { /// State schema version. #[serde(default = "default_localized_version_state_version")] pub state_version: u32, /// Official release ID used as the patch source. pub official_release_id: String, /// Published localized release ID. pub current_release_id: Option, /// Stable status label. pub status: String, /// Review/proofreading workflow marker independent from publish status. #[serde(default, skip_serializing_if = "Option::is_none")] pub translation_workflow_status: Option, /// Last update time. pub updated_unix_seconds: u64, } impl LocalizedVersionState { /// Returns the stable translation workflow status, if set. pub fn translation_workflow_status(&self) -> Option<&str> { self.translation_workflow_status.as_deref() } /// Returns the user-facing translation workflow label, if set. pub fn translation_workflow_label(&self) -> Option<&'static str> { match self.translation_workflow_status() { Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING) => { Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL) } _ => None, } } } /// Report produced when changing localized translation workflow state. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct LocalizedTranslationWorkflowReport { /// Stable command name. pub command: &'static str, /// Stable operation status. pub status: &'static str, /// Localized output root. pub localized_output_root: PathBuf, /// State file written under the localized output root. pub state_path: PathBuf, /// Official release associated with the workflow state. pub official_release_id: String, /// Current published localized release ID, when one exists. pub current_release_id: Option, /// Stable localized publication label, such as `localized`. pub localized_release_status: String, /// Stable translation workflow marker. pub translation_workflow_status: String, /// Stable lifecycle status code for read-side callers. pub translation_workflow_status_code: &'static str, /// Human label for the workflow status. pub translation_workflow_label: &'static str, /// Whether an existing localized release remains publishable after the change. pub publish_allowed: bool, /// Last update time written to the state file. pub updated_unix_seconds: u64, } /// One changed file in a localized patch manifest. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalizedPatchFile { /// Relative bundle path. pub path: String, /// BLAKE3 before applying the patch. pub original_blake3: String, /// BLAKE3 after applying the patch. pub localized_blake3: String, /// Original file size in bytes. #[serde(default)] pub original_bytes: u64, /// Localized file size in bytes. #[serde(default)] pub localized_bytes: u64, /// Localized minus original byte size. #[serde(default)] pub byte_delta: i64, /// TextAsset operations applied to this file. #[serde(default)] pub text_asset_operations: Vec, } /// One TextAsset patch operation recorded in the localized patch manifest. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalizedPatchOperation { /// Patch operation kind, for example `unityfs_text_asset`. #[serde(default = "default_patch_operation_kind")] pub patch_kind: String, /// UnityFS directory path of the serialized file. pub serialized_file_path: String, /// Unity object path ID. pub path_id: i64, /// TypeTree field path for field-level patches. #[serde(default, skip_serializing_if = "Option::is_none")] pub field_path: Option, /// Expected TextAsset name, when provided. #[serde(default, skip_serializing_if = "Option::is_none")] pub expected_name: Option, /// Replacement payload size. pub replacement_bytes: u64, /// BLAKE3 of the replacement payload. pub replacement_blake3: String, /// Stable TextUnit ID from the official TextUnit index. #[serde(default, skip_serializing_if = "Option::is_none")] pub text_unit_id: Option, /// BLAKE3 of the source text validated before publication. #[serde(default, skip_serializing_if = "Option::is_none")] pub source_text_blake3: Option, /// Translation provider that produced the text, if applicable. #[serde(default, skip_serializing_if = "Option::is_none")] pub translation_provider: Option, /// Provider run ID that produced the text, if applicable. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_run_id: Option, /// Review state used by the publication input. #[serde(default, skip_serializing_if = "Option::is_none")] pub review_status: Option, } /// Rollback information recorded for a localized publication. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalizedPatchRollbackInfo { /// Previous `current` symlink target before this publication. pub previous_current_target: Option, /// Version directory that should be removed when rolling this publication back. pub remove_version_path: PathBuf, } /// Integrity summary for a published localized release. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalizedPatchIntegrity { /// Number of changed files verified against the manifest. pub verified_changed_file_count: usize, /// Number of TextAsset operations recorded in the manifest. pub verified_text_asset_operation_count: usize, /// Whether `current` points at this localized release. pub current_points_to_release: bool, } /// Persisted manifest for one localized release. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalizedPatchManifest { /// Manifest schema version. #[serde(default = "default_patch_manifest_version")] pub manifest_version: u32, /// Official release ID used as the patch source. pub official_release_id: String, /// Published localized release ID. pub localized_release_id: String, /// Manifest generation time as Unix seconds. pub generated_unix_seconds: u64, /// Changed file count. pub file_count: usize, /// TextAsset operation count. pub text_asset_operation_count: usize, /// Changed files and their before/after hashes. pub files: Vec, /// Rollback information for this release. pub rollback: LocalizedPatchRollbackInfo, } impl LocalizedPatchManifest { /// Converts the localized TextAsset manifest to the generic patch manifest model. pub fn to_patch_manifest(&self) -> bat_patch::PatchManifest { bat_patch::PatchManifest { version: bat_patch::PATCH_MANIFEST_VERSION, patch_id: self.localized_release_id.clone(), source_version: self.official_release_id.clone(), target_version: self.localized_release_id.clone(), files: self .files .iter() .map(|file| bat_patch::PatchManifestFile { path: PathBuf::from(&file.path), patch_kind: bat_patch::PatchKind::UnityFsTextAsset, source_blake3: file.original_blake3.clone(), target_blake3: file.localized_blake3.clone(), source_size: file.original_bytes, target_size: file.localized_bytes, }) .collect(), rollback: bat_patch::PatchRollback { previous_current_target: self.rollback.previous_current_target.clone(), remove_target_path: Some(self.rollback.remove_version_path.clone()), }, } } } /// Result of a successful localized release publication. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct LocalizedPatchReport { /// Published version directory. pub version_path: PathBuf, /// Atomic current pointer. pub current_path: PathBuf, /// Version state path. pub state_path: PathBuf, /// Patch manifest path. pub patch_manifest_path: PathBuf, /// Changed files. pub files: Vec, /// Persisted patch manifest. pub manifest: LocalizedPatchManifest, /// Integrity check performed after publication. pub integrity: LocalizedPatchIntegrity, } /// Result of rolling back the current localized release. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct LocalizedRollbackReport { /// Stable command name. pub command: &'static str, /// Operation status. pub status: &'static str, /// Localized output root. pub localized_output_root: PathBuf, /// Atomic current pointer. pub current_path: PathBuf, /// Version state path. pub state_path: PathBuf, /// Localized release that was current before rollback. pub rolled_back_release_id: String, /// Removed current version directory. pub removed_version_path: PathBuf, /// Restored localized release ID, if a previous release existed. #[serde(skip_serializing_if = "Option::is_none")] pub restored_release_id: Option, /// Restored current symlink target, if a previous release existed. #[serde(skip_serializing_if = "Option::is_none")] pub restored_current_target: Option, /// New persisted localized version state. pub state: LocalizedVersionState, } /// Applies supported UnityFS text patches and atomically publishes a localized release. #[derive(Debug, Default, Clone, Copy)] pub struct LocalizedPatchService; impl LocalizedPatchService { /// Creates the publisher. pub fn new() -> Self { Self } /// Copies the official release, applies patches in staging and publishes it. pub fn publish(&self, config: &LocalizedPatchConfig) -> anyhow::Result { let published_release_id = config.published_release_id().to_string(); let staging = config .localized_output_root .join(LOCALIZED_STAGING_DIR) .join(&published_release_id); let version_path = config .localized_output_root .join(LOCALIZED_VERSIONS_DIR) .join(&published_release_id); let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK); validate_config(config).map_err(anyhow::Error::msg)?; let previous_current_target = current_symlink_target(¤t_path)?; if let Some(target) = previous_current_target.as_deref() { validate_previous_current_target(&config.localized_output_root, target)?; } let version_existed_before = version_path.exists(); match self.publish_inner(config, previous_current_target.clone()) { Ok(report) => Ok(report), Err(error) => { if let Err(rollback_error) = rollback_failed_publish( &config.localized_output_root, &staging, &version_path, !version_existed_before, ¤t_path, previous_current_target.as_ref(), ) { return Err(anyhow::anyhow!( "{error}; rollback failed: {rollback_error}" )); } Err(error) } } } /// Rolls back the current localized release to the manifest-recorded /// previous current target. pub fn rollback( &self, localized_output_root: &Path, expected_release_id: Option<&str>, ) -> anyhow::Result { ensure_safe_directory_path(localized_output_root, "汉化输出目录") .map_err(anyhow::Error::msg)?; let versions_root = localized_output_root.join(LOCALIZED_VERSIONS_DIR); ensure_safe_directory_path(&versions_root, "汉化 versions 目录") .map_err(anyhow::Error::msg)?; let current_path = localized_output_root.join(LOCALIZED_CURRENT_LINK); let state_path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE); let state = read_localized_version_state(localized_output_root)?.ok_or_else(|| { anyhow::anyhow!("缺少汉化版本状态,无法 rollback:{}", state_path.display()) })?; let current_release_id = state .current_release_id .clone() .ok_or_else(|| anyhow::anyhow!("当前没有已发布汉化 release,无法 rollback"))?; if let Some(expected) = expected_release_id { if expected != current_release_id { return Err(anyhow::anyhow!( "rollback 目标 release 不一致:当前={} 请求={}", current_release_id, expected )); } } let version_path = localized_output_root .join(LOCALIZED_VERSIONS_DIR) .join(¤t_release_id); ensure_safe_directory_path(&version_path, "当前汉化 release") .map_err(anyhow::Error::msg)?; if !current_points_to_version(¤t_path, &version_path)? { return Err(anyhow::anyhow!( "汉化 current 未指向当前状态 release:current={} version={}", current_path.display(), version_path.display() )); } let manifest = read_localized_patch_manifest_at(&version_path)?.ok_or_else(|| { anyhow::anyhow!( "缺少当前汉化 release manifest:{}", version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display() ) })?; if manifest.localized_release_id != current_release_id { return Err(anyhow::anyhow!( "manifest release={} 与当前状态 release={} 不一致", manifest.localized_release_id, current_release_id )); } if manifest.official_release_id != state.official_release_id { return Err(anyhow::anyhow!( "manifest 官方 release={} 与状态 release={} 不一致", manifest.official_release_id, state.official_release_id )); } let remove_version_path = manifest.rollback.remove_version_path.clone(); let expected_version_path = localized_output_root .join(LOCALIZED_VERSIONS_DIR) .join(¤t_release_id); if lexical_absolute(&remove_version_path).map_err(anyhow::Error::msg)? != lexical_absolute(&expected_version_path).map_err(anyhow::Error::msg)? { return Err(anyhow::anyhow!( "rollback manifest 删除路径必须指向当前 release:{}", current_release_id )); } ensure_path_within_root(localized_output_root, &remove_version_path) .map_err(anyhow::Error::msg)?; let restored_release_id = match manifest.rollback.previous_current_target.as_deref() { None => None, Some(target) => Some(release_id_from_current_target(target).ok_or_else(|| { anyhow::anyhow!("rollback manifest 上一 current 目标格式无效") })?), }; let mut restored_manifest = None; if let Some(previous_target) = manifest.rollback.previous_current_target.as_deref() { let previous_path = localized_output_root.join(previous_target); ensure_path_within_root(localized_output_root, &previous_path) .map_err(anyhow::Error::msg)?; ensure_safe_directory_path(&previous_path, "上一汉化 release") .map_err(anyhow::Error::msg)?; if !previous_path.is_dir() { return Err(anyhow::anyhow!( "rollback 记录的上一汉化 release 不存在:{}", previous_path.display() )); } restored_manifest = Some( read_localized_patch_manifest_at(&previous_path)?.ok_or_else(|| { anyhow::anyhow!( "rollback 记录的上一汉化 release 缺少 manifest:{}", previous_path.display() ) })?, ); } restore_current_symlink( localized_output_root, ¤t_path, manifest.rollback.previous_current_target.as_ref(), )?; remove_owned_path(&remove_version_path)?; let previous_official_release_id = state.official_release_id; let previous_workflow_status = state.translation_workflow_status; let restored_official_release_id = restored_manifest .as_ref() .map(|manifest| manifest.official_release_id.clone()) .unwrap_or_else(|| previous_official_release_id.clone()); let translation_workflow_status = if restored_official_release_id == previous_official_release_id { previous_workflow_status } else { None }; let new_state = LocalizedVersionState { state_version: LOCALIZED_VERSION_STATE_VERSION, official_release_id: restored_official_release_id, current_release_id: restored_release_id.clone(), status: if restored_release_id.is_some() { "localized".to_string() } else { "not_localized".to_string() }, translation_workflow_status, updated_unix_seconds: unix_seconds_now(), }; write_localized_version_state(localized_output_root, &new_state)?; Ok(LocalizedRollbackReport { command: "localized.rollback", status: "rolled_back", localized_output_root: localized_output_root.to_path_buf(), current_path, state_path, rolled_back_release_id: current_release_id, removed_version_path: remove_version_path, restored_release_id, restored_current_target: manifest.rollback.previous_current_target, state: new_state, }) } fn publish_inner( &self, config: &LocalizedPatchConfig, previous_current_target: Option, ) -> anyhow::Result { validate_config(config).map_err(anyhow::Error::msg)?; let staging = config .localized_output_root .join(LOCALIZED_STAGING_DIR) .join(config.published_release_id()); let version_path = config .localized_output_root .join(LOCALIZED_VERSIONS_DIR) .join(config.published_release_id()); let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK); let state_path = config .localized_output_root .join(LOCALIZED_VERSION_STATE_FILE); let versions_root = config.localized_output_root.join(LOCALIZED_VERSIONS_DIR); let staging_root = config.localized_output_root.join(LOCALIZED_STAGING_DIR); ensure_safe_directory_path(&versions_root, "汉化 versions 目录") .map_err(anyhow::Error::msg)?; ensure_safe_directory_path(&staging_root, "汉化 staging 目录") .map_err(anyhow::Error::msg)?; let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE); let previous_state = read_localized_version_state(&config.localized_output_root)?; let translation_workflow_status = previous_state .filter(|state| state.official_release_id == config.release_id) .and_then(|state| state.translation_workflow_status); match fs::symlink_metadata(&version_path) { Ok(metadata) if metadata.file_type().is_symlink() => { return Err(anyhow::anyhow!( "汉化 release 目标不能是 symlink:{}", version_path.display() )); } Ok(metadata) if !metadata.is_dir() => { return Err(anyhow::anyhow!( "汉化 release 目标已存在但不是目录:{}", version_path.display() )); } Ok(_) => { let message = if config.force { "localized release target already exists; forced publication requires a distinct localized release id" } else { "localized release already exists" }; return Err(anyhow::anyhow!("{message}: {}", version_path.display())); } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error.into()), } remove_owned_staging(&staging)?; fs::create_dir_all(&staging)?; copy_tree(&config.official_release_root, &staging)?; let operations = config.patch_operations(); let mut grouped = BTreeMap::>::new(); for operation in operations { grouped .entry(operation.bundle_path().to_string()) .or_default() .push(operation); } let mut changed_files = Vec::with_capacity(grouped.len()); for (bundle_path, operations) in grouped { let target = staging.join(Path::new(&bundle_path)); ensure_path_within_root(&staging, &target).map_err(anyhow::Error::msg)?; ensure_safe_file_target(&staging, &target, "汉化 patch 输入") .map_err(anyhow::Error::msg)?; let original = fs::read(&target)?; let mut patched = original.clone(); let mut manifest_operations = Vec::with_capacity(operations.len()); for operation in operations { patched = operation .apply(&patched) .map_err(|error| anyhow::anyhow!("{bundle_path}: {error}"))?; manifest_operations.push(operation.manifest_operation()?); } if original == patched { return Err(anyhow::anyhow!("patch produced no change: {bundle_path}")); } write_file_atomic(&target, &patched, STATE_FILE_MODE, "汉化 patch 输出") .map_err(anyhow::Error::msg)?; let original_blake3 = blake3::hash(&original).to_hex().to_string(); let localized_blake3 = blake3::hash(&patched).to_hex().to_string(); changed_files.push(LocalizedPatchFile { path: bundle_path, original_blake3, localized_blake3, original_bytes: original.len() as u64, localized_bytes: patched.len() as u64, byte_delta: patched.len() as i64 - original.len() as i64, text_asset_operations: manifest_operations, }); } let manifest = LocalizedPatchManifest { manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION, official_release_id: config.release_id.clone(), localized_release_id: config.published_release_id().to_string(), generated_unix_seconds: unix_seconds_now(), file_count: changed_files.len(), text_asset_operation_count: changed_files .iter() .map(|file| file.text_asset_operations.len()) .sum(), files: changed_files.clone(), rollback: LocalizedPatchRollbackInfo { previous_current_target: previous_current_target.clone(), remove_version_path: version_path.clone(), }, }; write_file_atomic( &staging.join(LOCALIZED_PATCH_MANIFEST_FILE), &serde_json::to_vec_pretty(&manifest)?, STATE_FILE_MODE, "汉化 patch manifest", ) .map_err(anyhow::Error::msg)?; verify_patch_manifest_files(&config.official_release_root, &staging, &manifest)?; fs::create_dir_all(config.localized_output_root.join(LOCALIZED_VERSIONS_DIR))?; fs::rename(&staging, &version_path)?; switch_current_symlink( &config.localized_output_root, ¤t_path, config.published_release_id(), )?; let state = LocalizedVersionState { state_version: LOCALIZED_VERSION_STATE_VERSION, official_release_id: config.release_id.clone(), current_release_id: Some(config.published_release_id().to_string()), status: "localized".to_string(), translation_workflow_status, updated_unix_seconds: unix_seconds_now(), }; write_file_atomic( &state_path, &serde_json::to_vec_pretty(&state)?, STATE_FILE_MODE, "汉化版本状态", ) .map_err(anyhow::Error::msg)?; let integrity = verify_published_localized_release( &config.official_release_root, &version_path, ¤t_path, )?; Ok(LocalizedPatchReport { version_path, current_path, state_path, patch_manifest_path, files: changed_files, manifest, integrity, }) } } /// Reads the localized release state without following a symlink at the file /// path. A missing state file means no localized release has been published. pub fn read_localized_version_state( localized_output_root: &Path, ) -> anyhow::Result> { let path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE); let Some(bytes) = read_file_no_symlink(&path, "汉化版本状态").map_err(anyhow::Error::msg)? else { return Ok(None); }; let state: LocalizedVersionState = serde_json::from_slice(&bytes)?; if state.state_version != LOCALIZED_VERSION_STATE_VERSION { return Err(anyhow::anyhow!( "不支持的汉化版本状态 schema:{},当前版本=1", state.state_version )); } Ok(Some(state)) } /// Writes the localized version state atomically. pub fn write_localized_version_state( localized_output_root: &Path, state: &LocalizedVersionState, ) -> anyhow::Result { ensure_safe_directory_path(localized_output_root, "汉化输出目录") .map_err(anyhow::Error::msg)?; let path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE); write_file_atomic( &path, &serde_json::to_vec_pretty(state)?, STATE_FILE_MODE, "汉化版本状态", ) .map_err(anyhow::Error::msg)?; Ok(path) } /// Marks the current localized workflow as under manual proofreading without /// changing the published localized release pointer. pub fn mark_localized_manual_proofreading( localized_output_root: &Path, official_release_id: &str, ) -> anyhow::Result { let mut state = read_localized_version_state(localized_output_root)?.unwrap_or_else(|| { LocalizedVersionState { state_version: LOCALIZED_VERSION_STATE_VERSION, official_release_id: official_release_id.to_string(), current_release_id: None, status: "not_localized".to_string(), translation_workflow_status: None, updated_unix_seconds: 0, } }); if state.official_release_id != official_release_id { return Err(anyhow::anyhow!( "汉化状态 release={} 与当前官方 release={} 不一致;请先重新发布或指定匹配的官方 release", state.official_release_id, official_release_id )); } state.state_version = LOCALIZED_VERSION_STATE_VERSION; state.translation_workflow_status = Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING.to_string()); state.updated_unix_seconds = unix_seconds_now(); let state_path = write_localized_version_state(localized_output_root, &state)?; Ok(LocalizedTranslationWorkflowReport { command: "translation-proofread", status: "updated", localized_output_root: localized_output_root.to_path_buf(), state_path, official_release_id: state.official_release_id.clone(), current_release_id: state.current_release_id.clone(), localized_release_status: state.status.clone(), translation_workflow_status: LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING.to_string(), translation_workflow_status_code: crate::ReleaseFlowStatusCode::TranslationManualProofreading.as_str(), translation_workflow_label: LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, publish_allowed: state.status == "localized" && state.current_release_id.is_some(), updated_unix_seconds: state.updated_unix_seconds, }) } /// Reads a localized patch manifest from a published version directory. pub fn read_localized_patch_manifest_at( version_path: &Path, ) -> anyhow::Result> { let path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE); let Some(bytes) = read_file_no_symlink(&path, "汉化 patch manifest").map_err(anyhow::Error::msg)? else { return Ok(None); }; let manifest: LocalizedPatchManifest = serde_json::from_slice(&bytes)?; if manifest.manifest_version != LOCALIZED_PATCH_MANIFEST_VERSION { return Err(anyhow::anyhow!( "不支持的汉化 patch manifest schema:{},当前版本={}", manifest.manifest_version, LOCALIZED_PATCH_MANIFEST_VERSION )); } Ok(Some(manifest)) } impl LocalizedPatchOperation { fn from_text_asset_patch( patch: &TextAssetPatch, metadata: Option<&LocalizedPatchOperationMetadata>, ) -> Self { Self::with_metadata( Self { patch_kind: "unityfs_text_asset".to_string(), serialized_file_path: patch.serialized_file_path.clone(), path_id: patch.path_id, field_path: None, expected_name: patch.expected_name.clone(), replacement_bytes: patch.replacement.len() as u64, replacement_blake3: blake3::hash(&patch.replacement).to_hex().to_string(), text_unit_id: None, source_text_blake3: None, translation_provider: None, provider_run_id: None, review_status: None, }, metadata, ) } fn from_string_field_patch( patch: &StringFieldPatch, metadata: Option<&LocalizedPatchOperationMetadata>, ) -> Self { Self::with_metadata( Self { patch_kind: "unityfs_string_field".to_string(), serialized_file_path: patch.serialized_file_path.clone(), path_id: patch.path_id, field_path: Some(patch.field_path.clone()), expected_name: None, replacement_bytes: patch.replacement.len() as u64, replacement_blake3: blake3::hash(patch.replacement.as_bytes()) .to_hex() .to_string(), text_unit_id: None, source_text_blake3: None, translation_provider: None, provider_run_id: None, review_status: None, }, metadata, ) } fn from_field_patch( patch: &FieldPatch, metadata: Option<&LocalizedPatchOperationMetadata>, ) -> anyhow::Result { let replacement = serde_json::to_vec(&patch.replacement)?; Ok(Self::with_metadata( Self { patch_kind: "unityfs_field".to_string(), serialized_file_path: patch.serialized_file_path.clone(), path_id: patch.path_id, field_path: Some(patch.field_path.clone()), expected_name: None, replacement_bytes: replacement.len() as u64, replacement_blake3: blake3::hash(&replacement).to_hex().to_string(), text_unit_id: None, source_text_blake3: None, translation_provider: None, provider_run_id: None, review_status: None, }, metadata, )) } fn with_metadata( mut operation: Self, metadata: Option<&LocalizedPatchOperationMetadata>, ) -> Self { if let Some(metadata) = metadata { operation.text_unit_id = Some(metadata.text_unit_id.clone()); operation.source_text_blake3 = Some(metadata.source_text_blake3.clone()); operation.translation_provider = metadata.translation_provider.clone(); operation.provider_run_id = metadata.provider_run_id.clone(); operation.review_status = Some(metadata.review_status.clone()); } operation } } fn default_patch_operation_kind() -> String { "unityfs_text_asset".to_string() } impl Default for LocalizedPatchOperation { fn default() -> Self { Self { patch_kind: default_patch_operation_kind(), serialized_file_path: String::new(), path_id: 0, field_path: None, expected_name: None, replacement_bytes: 0, replacement_blake3: String::new(), text_unit_id: None, source_text_blake3: None, translation_provider: None, provider_run_id: None, review_status: None, } } } fn verify_published_localized_release( official_release_root: &Path, version_path: &Path, current_path: &Path, ) -> anyhow::Result { let manifest = read_localized_patch_manifest_at(version_path)?.ok_or_else(|| { anyhow::anyhow!( "缺少汉化 patch manifest:{}", version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display() ) })?; let mut integrity = verify_patch_manifest_files(official_release_root, version_path, &manifest)?; integrity.current_points_to_release = current_points_to_version(current_path, version_path)?; if !integrity.current_points_to_release { return Err(anyhow::anyhow!( "汉化 current 未指向发布版本:current={} version={}", current_path.display(), version_path.display() )); } Ok(integrity) } fn verify_patch_manifest_files( official_release_root: &Path, localized_release_root: &Path, manifest: &LocalizedPatchManifest, ) -> anyhow::Result { let mut operation_count = 0usize; for file in &manifest.files { let relative = Path::new(&file.path); let official_path = official_release_root.join(relative); let localized_path = localized_release_root.join(relative); ensure_path_within_root(official_release_root, &official_path) .map_err(anyhow::Error::msg)?; ensure_path_within_root(localized_release_root, &localized_path) .map_err(anyhow::Error::msg)?; ensure_safe_file_target(official_release_root, &official_path, "官方 patch 原文件") .map_err(anyhow::Error::msg)?; ensure_safe_file_target(localized_release_root, &localized_path, "汉化 patch 产物") .map_err(anyhow::Error::msg)?; let original = fs::read(&official_path)?; let localized = fs::read(&localized_path)?; let original_hash = blake3::hash(&original).to_hex().to_string(); let localized_hash = blake3::hash(&localized).to_hex().to_string(); if original_hash != file.original_blake3 || original.len() as u64 != file.original_bytes { return Err(anyhow::anyhow!( "汉化 manifest 原文件校验失败 {}:期望 hash={} bytes={},实际 hash={} bytes={}", file.path, file.original_blake3, file.original_bytes, original_hash, original.len() )); } if localized_hash != file.localized_blake3 || localized.len() as u64 != file.localized_bytes { return Err(anyhow::anyhow!( "汉化 manifest 产物校验失败 {}:期望 hash={} bytes={},实际 hash={} bytes={}", file.path, file.localized_blake3, file.localized_bytes, localized_hash, localized.len() )); } if localized_hash == original_hash { return Err(anyhow::anyhow!( "汉化 manifest 文件未发生变化:{}", file.path )); } operation_count += file.text_asset_operations.len(); } if manifest.file_count != manifest.files.len() { return Err(anyhow::anyhow!( "汉化 manifest file_count 不一致:声明 {},实际 {}", manifest.file_count, manifest.files.len() )); } if manifest.text_asset_operation_count != operation_count { return Err(anyhow::anyhow!( "汉化 manifest operation_count 不一致:声明 {},实际 {}", manifest.text_asset_operation_count, operation_count )); } Ok(LocalizedPatchIntegrity { verified_changed_file_count: manifest.files.len(), verified_text_asset_operation_count: operation_count, current_points_to_release: false, }) } fn current_symlink_target(current_path: &Path) -> anyhow::Result> { match fs::symlink_metadata(current_path) { Ok(metadata) if metadata.file_type().is_symlink() => Ok(Some(fs::read_link(current_path)?)), Ok(_) => Err(anyhow::anyhow!( "汉化 current 已存在但不是 symlink:{}", current_path.display() )), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error.into()), } } fn validate_previous_current_target(root: &Path, target: &Path) -> anyhow::Result<()> { if release_id_from_current_target(target).is_none() { return Err(anyhow::anyhow!( "汉化 current 目标不是受支持的 versions/ 路径:{}", target.display() )); } let target_path = root.join(target); ensure_path_within_root(root, &target_path).map_err(anyhow::Error::msg)?; ensure_safe_directory_path(&target_path, "汉化 current 目标").map_err(anyhow::Error::msg)?; if !target_path.is_dir() { return Err(anyhow::anyhow!( "汉化 current 目标 release 不存在:{}", target_path.display() )); } Ok(()) } fn current_points_to_version(current_path: &Path, version_path: &Path) -> anyhow::Result { let metadata = fs::symlink_metadata(current_path)?; if !metadata.file_type().is_symlink() { return Ok(false); } Ok(fs::canonicalize(current_path)? == fs::canonicalize(version_path)?) } fn release_id_from_current_target(target: &Path) -> Option { let mut components = target.components(); match (components.next(), components.next(), components.next()) { ( Some(std::path::Component::Normal(root)), Some(std::path::Component::Normal(release_id)), None, ) if root == LOCALIZED_VERSIONS_DIR => release_id.to_str().map(str::to_string), _ => None, } } fn rollback_failed_publish( localized_output_root: &Path, staging: &Path, version_path: &Path, remove_version_path: bool, current_path: &Path, previous_current_target: Option<&PathBuf>, ) -> anyhow::Result<()> { remove_owned_path(staging)?; if remove_version_path { remove_owned_path(version_path)?; } remove_owned_path(&localized_output_root.join(".current.tmp"))?; remove_owned_path(&localized_output_root.join(".current.rollback.tmp"))?; let failed_target = version_path .file_name() .map(|release_id| Path::new(LOCALIZED_VERSIONS_DIR).join(release_id)); if failed_target.as_ref().is_some_and(|target| { fs::read_link(current_path) .map(|current_target| current_target == *target) .unwrap_or(false) }) { restore_current_symlink(localized_output_root, current_path, previous_current_target)?; } Ok(()) } fn remove_owned_path(path: &Path) -> anyhow::Result<()> { if let Ok(metadata) = fs::symlink_metadata(path) { if metadata.file_type().is_symlink() || metadata.is_file() { fs::remove_file(path)?; } else if metadata.is_dir() { fs::remove_dir_all(path)?; } } Ok(()) } #[cfg(unix)] fn restore_current_symlink( root: &Path, current_path: &Path, previous_current_target: Option<&PathBuf>, ) -> anyhow::Result<()> { use std::os::unix::fs::symlink; if let Some(previous_target) = previous_current_target { let temporary = root.join(".current.rollback.tmp"); remove_owned_path(&temporary)?; symlink(previous_target, &temporary)?; fs::rename(temporary, current_path)?; } else if fs::symlink_metadata(current_path) .map(|metadata| metadata.file_type().is_symlink()) .unwrap_or(false) { fs::remove_file(current_path)?; } Ok(()) } #[cfg(not(unix))] fn restore_current_symlink( _root: &Path, _current_path: &Path, _previous_current_target: Option<&PathBuf>, ) -> anyhow::Result<()> { Ok(()) } fn validate_config(config: &LocalizedPatchConfig) -> Result<(), String> { let official = lexical_absolute(&config.official_release_root)?; let localized = lexical_absolute(&config.localized_output_root)?; if official == localized || official.starts_with(&localized) || localized.starts_with(&official) { return Err(format!( "官方 release 与汉化输出目录不能相同或互相嵌套:官方={} 汉化={}", official.display(), localized.display() )); } for (label, release_id) in [ ("官方", config.release_id.as_str()), ("汉化", config.published_release_id()), ] { if release_id.is_empty() || release_id.contains('/') || release_id.contains('\\') || release_id == "." || release_id == ".." { return Err(format!("非法{label} release id:{release_id}")); } } ensure_safe_directory_path(&config.official_release_root, "官方 release")?; ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?; Ok(()) } fn copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> { let metadata = fs::symlink_metadata(source)?; if metadata.file_type().is_symlink() { return Err(anyhow::anyhow!( "官方 release 不能包含 symlink: {}", source.display() )); } if metadata.is_dir() { fs::create_dir_all(destination)?; for entry in fs::read_dir(source)? { let entry = entry?; copy_tree(&entry.path(), &destination.join(entry.file_name()))?; } } else if metadata.is_file() { if let Some(parent) = destination.parent() { fs::create_dir_all(parent)?; } fs::copy(source, destination)?; } else { return Err(anyhow::anyhow!( "官方 release 中存在非普通文件: {}", source.display() )); } Ok(()) } fn remove_owned_staging(path: &Path) -> anyhow::Result<()> { if let Ok(metadata) = fs::symlink_metadata(path) { if metadata.file_type().is_symlink() { return Err(anyhow::anyhow!( "汉化 staging 不能是 symlink: {}", path.display() )); } if metadata.is_dir() { fs::remove_dir_all(path)?; } else { fs::remove_file(path)?; } } Ok(()) } #[cfg(unix)] fn switch_current_symlink(root: &Path, current: &Path, release_id: &str) -> anyhow::Result<()> { use std::os::unix::fs::symlink; let temporary = root.join(".current.tmp"); if let Ok(metadata) = fs::symlink_metadata(&temporary) { if metadata.file_type().is_symlink() || metadata.is_file() { fs::remove_file(&temporary)?; } else if metadata.is_dir() { fs::remove_dir_all(&temporary)?; } } symlink( Path::new(LOCALIZED_VERSIONS_DIR).join(release_id), &temporary, )?; fs::rename(temporary, current)?; Ok(()) } #[cfg(not(unix))] fn switch_current_symlink(_root: &Path, _current: &Path, _release_id: &str) -> anyhow::Result<()> { Err(anyhow::anyhow!( "localized release publication requires a Unix symlink-capable platform" )) } fn unix_seconds_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } fn default_patch_manifest_version() -> u32 { LOCALIZED_PATCH_MANIFEST_VERSION } fn default_localized_version_state_version() -> u32 { LOCALIZED_VERSION_STATE_VERSION } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn localized_manifest_converts_to_generic_patch_manifest() { let source = b"source"; let target = b"target"; let manifest = LocalizedPatchManifest { manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION, official_release_id: "official-v1".to_string(), localized_release_id: "localized-v1".to_string(), generated_unix_seconds: 123, file_count: 1, text_asset_operation_count: 1, files: vec![LocalizedPatchFile { path: "Bundles/file.bundle".to_string(), original_blake3: blake3::hash(source).to_hex().to_string(), localized_blake3: blake3::hash(target).to_hex().to_string(), original_bytes: source.len() as u64, localized_bytes: target.len() as u64, byte_delta: target.len() as i64 - source.len() as i64, text_asset_operations: vec![LocalizedPatchOperation { patch_kind: "unityfs_text_asset".to_string(), serialized_file_path: "CAB-asset".to_string(), path_id: 1, field_path: None, expected_name: Some("Text".to_string()), replacement_bytes: target.len() as u64, replacement_blake3: blake3::hash(target).to_hex().to_string(), text_unit_id: Some("unit-1".to_string()), source_text_blake3: Some(blake3::hash(source).to_hex().to_string()), translation_provider: Some("mock".to_string()), provider_run_id: Some("mock:unit-1:attempt-1".to_string()), review_status: Some("provider_completed".to_string()), }], }], rollback: LocalizedPatchRollbackInfo { previous_current_target: Some(PathBuf::from("versions/previous")), remove_version_path: PathBuf::from("versions/localized-v1"), }, }; let patch_manifest = manifest.to_patch_manifest(); assert_eq!(patch_manifest.version, bat_patch::PATCH_MANIFEST_VERSION); assert_eq!(patch_manifest.patch_id, "localized-v1"); assert_eq!(patch_manifest.source_version, "official-v1"); assert_eq!(patch_manifest.target_version, "localized-v1"); assert_eq!(patch_manifest.files.len(), 1); assert_eq!( patch_manifest.files[0].patch_kind, bat_patch::PatchKind::UnityFsTextAsset ); assert_eq!( patch_manifest.rollback.previous_current_target, Some(PathBuf::from("versions/previous")) ); assert_eq!( patch_manifest.rollback.remove_target_path, Some(PathBuf::from("versions/localized-v1")) ); } #[cfg(unix)] #[test] fn publishes_a_separate_localized_release_atomically() { let temp = TempDir::new().unwrap(); let official = temp.path().join("official-release"); let localized = temp.path().join("localized"); fs::create_dir_all(official.join("TableBundles")).unwrap(); fs::write( official.join("TableBundles/TableCatalog.bytes"), b"official", ) .unwrap(); let report = LocalizedPatchService::new() .publish(&LocalizedPatchConfig::new( &official, &localized, "release-1", Vec::new(), )) .unwrap(); assert_eq!( fs::read(report.version_path.join("TableBundles/TableCatalog.bytes")).unwrap(), b"official" ); assert_eq!( fs::read_link(report.current_path).unwrap(), PathBuf::from("versions/release-1") ); let state: LocalizedVersionState = serde_json::from_slice(&fs::read(report.state_path).unwrap()).unwrap(); assert_eq!(state.status, "localized"); assert_eq!(state.current_release_id.as_deref(), Some("release-1")); assert_eq!(state.translation_workflow_status(), None); assert!(report.patch_manifest_path.is_file()); assert_eq!(report.manifest.file_count, 0); assert_eq!(report.integrity.verified_changed_file_count, 0); assert!(report.integrity.current_points_to_release); let manifest = read_localized_patch_manifest_at(&report.version_path) .unwrap() .unwrap(); assert_eq!(manifest.localized_release_id, "release-1"); assert_eq!(manifest.rollback.previous_current_target, None); } #[cfg(unix)] #[test] fn rollback_restores_previous_localized_release() { let temp = TempDir::new().unwrap(); let official = temp.path().join("official-release"); let localized = temp.path().join("localized"); fs::create_dir_all(official.join("TableBundles")).unwrap(); fs::write( official.join("TableBundles/TableCatalog.bytes"), b"official", ) .unwrap(); let service = LocalizedPatchService::new(); service .publish(&LocalizedPatchConfig::new( &official, &localized, "release-1", Vec::new(), )) .unwrap(); service .publish( &LocalizedPatchConfig::new(&official, &localized, "release-1", Vec::new()) .with_localized_release_id("release-2"), ) .unwrap(); let report = service.rollback(&localized, Some("release-2")).unwrap(); assert_eq!(report.rolled_back_release_id, "release-2"); assert_eq!(report.restored_release_id.as_deref(), Some("release-1")); assert!(!localized .join(LOCALIZED_VERSIONS_DIR) .join("release-2") .exists()); assert_eq!( fs::read_link(localized.join(LOCALIZED_CURRENT_LINK)).unwrap(), PathBuf::from("versions/release-1") ); let state = read_localized_version_state(&localized).unwrap().unwrap(); assert_eq!(state.status, "localized"); assert_eq!(state.current_release_id.as_deref(), Some("release-1")); } #[cfg(unix)] #[test] fn publish_rejects_invalid_existing_current_target() { use std::os::unix::fs::symlink; let temp = TempDir::new().unwrap(); let official = temp.path().join("official-release"); let localized = temp.path().join("localized"); fs::create_dir_all(official.join("TableBundles")).unwrap(); fs::write( official.join("TableBundles/TableCatalog.bytes"), b"official", ) .unwrap(); fs::create_dir_all(localized.join(LOCALIZED_VERSIONS_DIR)).unwrap(); symlink( temp.path().join("outside"), localized.join(LOCALIZED_CURRENT_LINK), ) .unwrap(); let error = LocalizedPatchService::new() .publish(&LocalizedPatchConfig::new( &official, &localized, "release-1", Vec::new(), )) .unwrap_err(); assert!(error.to_string().contains("current 目标不是受支持")); } #[test] fn localized_state_reads_legacy_file_without_workflow_status() { let temp = TempDir::new().unwrap(); let localized = temp.path().join("localized"); fs::create_dir_all(&localized).unwrap(); fs::write( localized.join(LOCALIZED_VERSION_STATE_FILE), br#"{ "state_version": 1, "official_release_id": "release-1", "current_release_id": "release-1", "status": "localized", "updated_unix_seconds": 123 }"#, ) .unwrap(); let state = read_localized_version_state(&localized).unwrap().unwrap(); assert_eq!(state.status, "localized"); assert_eq!(state.translation_workflow_status(), None); assert_eq!(state.translation_workflow_label(), None); } #[test] fn manual_proofreading_marker_preserves_published_state() { let temp = TempDir::new().unwrap(); let localized = temp.path().join("localized"); fs::create_dir_all(&localized).unwrap(); write_localized_version_state( &localized, &LocalizedVersionState { state_version: LOCALIZED_VERSION_STATE_VERSION, official_release_id: "release-1".to_string(), current_release_id: Some("release-1-auto".to_string()), status: "localized".to_string(), translation_workflow_status: None, updated_unix_seconds: 123, }, ) .unwrap(); let report = mark_localized_manual_proofreading(&localized, "release-1").unwrap(); let state = read_localized_version_state(&localized).unwrap().unwrap(); assert_eq!(report.translation_workflow_label, "人工校对中"); assert_eq!( state.translation_workflow_status(), Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING) ); assert_eq!(state.translation_workflow_label(), Some("人工校对中")); assert_eq!(state.status, "localized"); assert_eq!(state.current_release_id.as_deref(), Some("release-1-auto")); assert!(report.publish_allowed); } #[cfg(unix)] #[test] fn failed_patch_publish_cleans_staging_and_unpublished_version() { let temp = TempDir::new().unwrap(); let official = temp.path().join("official-release"); let localized = temp.path().join("localized"); fs::create_dir_all(official.join("Bundles")).unwrap(); fs::write(official.join("Bundles/bad.bundle"), b"not-unityfs").unwrap(); let error = LocalizedPatchService::new() .publish(&LocalizedPatchConfig::new( &official, &localized, "release-1", vec![LocalizedTextAssetPatch { bundle_path: "Bundles/bad.bundle".to_string(), text_asset: TextAssetPatch::new("CAB-bad", 1, b"replacement".to_vec()), metadata: None, }], )) .unwrap_err(); assert!(error.to_string().contains("Bundles/bad.bundle")); assert!(!localized .join(LOCALIZED_STAGING_DIR) .join("release-1") .exists()); assert!(!localized .join(LOCALIZED_VERSIONS_DIR) .join("release-1") .exists()); assert!(!localized.join(LOCALIZED_CURRENT_LINK).exists()); } }