b2477c8ca9
- 新增 Tauri 命令 rename_local_project_asset(输入 projectPath / assetId / newFileName),并在 main.rs 的 generate_handler! 中注册 - 新增 project/asset_rename.rs:重命名语义为“磁盘文件改名 + manifest localPath 更新”,资产 id / kind / mediaType / source / category / tags 全部不变,imageSequenceFrames 里指向该文件的本地帧一并对齐 - 校验判据:新名非空、不含路径分隔符(同时保证不跨目录)、不含 ..、通过可移植路径组件校验、扩展名与原文件一致、同目录不得已存在同名文件、assetId 与磁盘文件必须存在 - 事务与回滚:持既有项目写锁,manifest 写入走既有 write_manifest 边界(版本数组校验 + 安装后回读一致性);写失败把文件改回原名,回滚也失败时两个错误都报出并标记 reconciliation-required;同名重命名按空操作处理 - 新增 tests/asset_rename.rs 9 条用例:成功改名与身份不变、同名空操作、空名 / 分隔符 / .. / 跨目录 / 扩展名不一致 / 目标同名冲突 / 资产与文件缺失拒绝、manifest 写失败回滚、同目录帧对齐 - decision-log 记录素材重命名语义、命令边界,以及“前端调用方落地前 ai-game-creator-shell:typecheck 必然失败”的已知中间状态
257 lines
11 KiB
Rust
257 lines
11 KiB
Rust
use super::*;
|
||
|
||
use super::filesystem::validate_portable_project_path_component;
|
||
|
||
/// 素材重命名的入参。
|
||
///
|
||
/// 只收"新文件名"而不是新旧两个全路径:改名被限制在资产当前所在目录内,目录由 manifest
|
||
/// 里的 `localPath` 决定,调用方无法指定目标目录。
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
pub(crate) struct RenameLocalProjectAssetInput {
|
||
pub(crate) project_path: String,
|
||
pub(crate) asset_id: String,
|
||
pub(crate) new_file_name: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct RenameLocalProjectAssetResult {
|
||
pub(crate) asset: GameCreationAppAssetManifestEntry,
|
||
pub(crate) previous_local_path: String,
|
||
pub(crate) committed_project_revision: u64,
|
||
}
|
||
|
||
/// 故障注入点:让测试在不依赖只读路径的前提下验证"manifest 写失败必须回滚文件改名"。
|
||
/// 生产路径始终传 `None`。
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub(crate) enum RenameLocalProjectAssetFaultStage {
|
||
/// 文件已改名、manifest 尚未写入时失败。
|
||
ManifestWrite,
|
||
}
|
||
|
||
/// 校验调用方给出的新文件名,返回 trim 后的名字。
|
||
///
|
||
/// 判据(按顺序):
|
||
/// - 非空;
|
||
/// - 不含路径分隔符(`/`、`\`)——这同时保证"不跨目录",改名只落在资产当前所在目录内;
|
||
/// - 不含 `..`——分隔符已被拒绝后,`..` 仍可能以"整个名字就是 `..`"的形式改变层级,一律拒绝;
|
||
/// - 通过项目路径组件的可移植性校验(控制字符、Windows 保留字符、结尾点或空格、保留设备名),
|
||
/// 与项目内其它写入路径保持同一套路径安全口径。
|
||
fn normalize_asset_file_name(value: &str) -> Result<String, String> {
|
||
let name = value.trim();
|
||
if name.is_empty() {
|
||
return Err("新文件名不能为空".to_string());
|
||
}
|
||
if name.contains('/') || name.contains('\\') {
|
||
return Err("新文件名不能包含路径分隔符".to_string());
|
||
}
|
||
if name.contains("..") {
|
||
return Err("新文件名不能包含 ..".to_string());
|
||
}
|
||
validate_portable_project_path_component(name)
|
||
.map_err(|error| format!("新文件名非法:{error}"))?;
|
||
Ok(name.to_string())
|
||
}
|
||
|
||
/// 取小写扩展名。`.gitignore` 这类隐藏文件按"无扩展名"处理。
|
||
fn asset_file_extension(file_name: &str) -> Option<String> {
|
||
Path::new(file_name)
|
||
.extension()
|
||
.and_then(std::ffi::OsStr::to_str)
|
||
.map(str::to_ascii_lowercase)
|
||
}
|
||
|
||
/// 一个帧的 `imageSrc` 是否就是被改名的那份本地文件。
|
||
///
|
||
/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后文件名与旧名
|
||
/// 一致(大小写不敏感,和扩展名比较同口径)。只认真正指向该文件的帧,同目录但指向其它文件的帧
|
||
/// 保持原样,避免改名连带改坏别的引用。
|
||
fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_name: &str) -> bool {
|
||
let image_src = image_src.trim();
|
||
if image_src.is_empty()
|
||
|| image_src.starts_with('/')
|
||
|| image_src.contains('\\')
|
||
|| image_src.contains("://")
|
||
|| Path::new(image_src).is_absolute()
|
||
{
|
||
return false;
|
||
}
|
||
let (frame_directory, frame_file_name) = match image_src.rsplit_once('/') {
|
||
Some((directory, file_name)) => (directory, file_name),
|
||
None => ("", image_src),
|
||
};
|
||
frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name)
|
||
}
|
||
|
||
/// 把 manifest 条目里指向被改名文件的序列帧对齐到新 `localPath`。
|
||
fn align_image_sequence_frames(
|
||
asset: &mut GameCreationAppAssetManifestEntry,
|
||
directory: &str,
|
||
previous_file_name: &str,
|
||
next_local_path: &str,
|
||
) {
|
||
let Some(frames) = asset.image_sequence_frames.as_mut() else {
|
||
return;
|
||
};
|
||
for frame in frames.iter_mut() {
|
||
if !asset_local_frame_matches(&frame.image_src, directory, previous_file_name) {
|
||
continue;
|
||
}
|
||
frame.image_src = next_local_path.to_string();
|
||
}
|
||
}
|
||
|
||
/// manifest 写失败后的回滚:把文件改回原名,并把回滚失败单独报出来。
|
||
fn rollback_asset_file_rename(
|
||
current_absolute: &Path,
|
||
next_absolute: &Path,
|
||
error: String,
|
||
) -> String {
|
||
match fs::rename(next_absolute, current_absolute) {
|
||
Ok(()) => error,
|
||
Err(rollback_error) => format!(
|
||
"{error};reconciliation-required: 素材文件未能改回原名:{} -> {}: {rollback_error}",
|
||
next_absolute.display(),
|
||
current_absolute.display()
|
||
),
|
||
}
|
||
}
|
||
|
||
/// 重命名一个已登记素材:**文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变**。
|
||
///
|
||
/// manifest 资产条目没有 `name` 字段,资源显示名来自 `fileName(localPath)`,因此重命名的语义
|
||
/// 只能是"改文件名 + 改 `localPath`"。
|
||
///
|
||
/// 事务顺序(全程持既有项目写锁,期间不会出现第二个项目写者;manifest 写入仍走既有边界:
|
||
/// 版本数组不可变校验 + 安装后回读一致性校验):
|
||
/// 1. 读 manifest 定位资产,取旧 `localPath`;
|
||
/// 2. 校验新文件名、扩展名一致、目标不冲突,然后 `rename` 磁盘文件;
|
||
/// 3. 更新 manifest 的 `localPath`(`id` / `kind` / `mediaType` / `source` / `category` / `tags`
|
||
/// 全部不变),并把 `imageSequenceFrames` 里指向该文件的帧对齐到新路径;
|
||
/// 4. 写 manifest;
|
||
/// 5. 第 4 步失败时把文件改回原名,不留"文件已改名但 manifest 还是旧路径"的半成品;
|
||
/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required。
|
||
pub(crate) fn rename_local_project_asset_at(
|
||
root: &Path,
|
||
asset_id: &str,
|
||
new_file_name: &str,
|
||
fault: Option<RenameLocalProjectAssetFaultStage>,
|
||
) -> Result<RenameLocalProjectAssetResult, String> {
|
||
let asset_id = asset_id.trim();
|
||
if asset_id.is_empty() {
|
||
return Err("素材重命名 assetId 不能为空".to_string());
|
||
}
|
||
let new_file_name = normalize_asset_file_name(new_file_name)?;
|
||
|
||
let _lock = acquire_project_write_lock(root, "asset.register")?;
|
||
let manifest_path = root.join(".agent/manifest.json");
|
||
let mut manifest = read_existing_manifest_for_project(root)?;
|
||
let index = manifest
|
||
.assets
|
||
.iter()
|
||
.position(|asset| asset.id == asset_id)
|
||
.ok_or_else(|| format!("项目资源不存在:{asset_id}"))?;
|
||
let previous_local_path = manifest.assets[index].local_path.clone();
|
||
let (directory, current_file_name) = match previous_local_path.rsplit_once('/') {
|
||
Some((directory, file_name)) => (directory.to_string(), file_name.to_string()),
|
||
None => (String::new(), previous_local_path.clone()),
|
||
};
|
||
|
||
// 同名重命名是空操作:磁盘与 manifest 都不动,直接回报当前状态。
|
||
if current_file_name == new_file_name {
|
||
return Ok(RenameLocalProjectAssetResult {
|
||
asset: manifest.assets[index].clone(),
|
||
previous_local_path,
|
||
committed_project_revision: read_game_creator_agent_runtime_project_revision(root)?
|
||
.revision,
|
||
});
|
||
}
|
||
|
||
let current_extension = asset_file_extension(¤t_file_name);
|
||
let next_extension = asset_file_extension(&new_file_name);
|
||
if current_extension != next_extension {
|
||
return Err(format!(
|
||
"新文件名扩展名必须与原文件一致,原扩展名:{}",
|
||
current_extension
|
||
.map(|extension| format!(".{extension}"))
|
||
.unwrap_or_else(|| "无".to_string())
|
||
));
|
||
}
|
||
|
||
let next_local_path = normalize_relative_path(&if directory.is_empty() {
|
||
new_file_name.clone()
|
||
} else {
|
||
format!("{directory}/{new_file_name}")
|
||
})?;
|
||
let current_absolute = resolve_local_project_path(root, &previous_local_path)?;
|
||
let next_absolute = resolve_local_project_path(root, &next_local_path)?;
|
||
|
||
// 被改名的必须是真的普通文件:登记与磁盘不一致时先报错,不写出指向"新名字"的悬空登记。
|
||
match fs::symlink_metadata(¤t_absolute) {
|
||
Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {}
|
||
Ok(_) => return Err(format!("素材路径必须是普通文件:{previous_local_path}")),
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||
return Err(format!("素材文件不存在:{previous_local_path}"));
|
||
}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取素材元数据失败:{}: {error}",
|
||
current_absolute.display()
|
||
));
|
||
}
|
||
}
|
||
// 同目录内不得已存在同名文件:先检查再改名,避免 `rename` 覆盖既有文件。
|
||
// 只差文件名大小写的改名在大小写不敏感的文件系统上会命中同一条检查,按"同名冲突"拒绝。
|
||
match fs::symlink_metadata(&next_absolute) {
|
||
Ok(_) => return Err(format!("同目录已存在同名文件:{new_file_name}")),
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取目标素材元数据失败:{}: {error}",
|
||
next_absolute.display()
|
||
));
|
||
}
|
||
}
|
||
|
||
fs::rename(¤t_absolute, &next_absolute).map_err(|error| {
|
||
format!(
|
||
"素材文件改名失败:{} -> {}: {error}",
|
||
current_absolute.display(),
|
||
next_absolute.display()
|
||
)
|
||
})?;
|
||
|
||
manifest.assets[index].local_path = next_local_path.clone();
|
||
align_image_sequence_frames(
|
||
&mut manifest.assets[index],
|
||
&directory,
|
||
¤t_file_name,
|
||
&next_local_path,
|
||
);
|
||
let asset = manifest.assets[index].clone();
|
||
|
||
// 文件已改名:从这里开始的任何失败都必须把文件改回原名。
|
||
let write_error = match fault {
|
||
Some(RenameLocalProjectAssetFaultStage::ManifestWrite) => {
|
||
Some("fault-injected:rename-asset-manifest-write".to_string())
|
||
}
|
||
None => write_manifest(&manifest_path, &manifest).err(),
|
||
};
|
||
if let Some(error) = write_error {
|
||
return Err(rollback_asset_file_rename(
|
||
¤t_absolute,
|
||
&next_absolute,
|
||
error,
|
||
));
|
||
}
|
||
|
||
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
|
||
.map_err(|error| format!("素材已改名,但项目 revision 未能推进:{error}"))?;
|
||
Ok(RenameLocalProjectAssetResult {
|
||
asset,
|
||
previous_local_path,
|
||
committed_project_revision,
|
||
})
|
||
}
|