导出注册表文件级读写收口到 export 模块

- 新增 apps/ai-game-creator-shell/src-tauri/src/export/registry.rs:注册表 JSON 的自动创建、严格解析、临时文件原子替换与原始字节 SHA-256 指纹,不认任何 target。
- 改写 apps/ai-game-creator-shell/src-tauri/src/export/mod.rs:写清导出模块边界,重导出注册表原语,说明 .export 工作目录的 flat 约定与不进快照的后果。
- src-tauri/src/main.rs 注册 pub mod export;。
- 删除上一轮「共享命令目录」残留的空壳 src-tauri/src/export/command/,命令落点改为 per-target commands.rs。
- registry.rs 覆盖自动创建且重读指纹稳定、非法 JSON fail closed 不覆写、未知字段拒绝、保存返回新指纹四条单测。
This commit is contained in:
2026-10-05 15:19:11 +08:00
parent f0ee972c9c
commit 91741f9de0
6 changed files with 273 additions and 0 deletions
@@ -0,0 +1 @@
pub mod xhs_minitool;
@@ -0,0 +1,17 @@
pub mod commands;
struct XHSMiniToolExportForm {
// max 14 unicode
name: String,
// max 14 unicode
introduction: String,
// png jpg jpeg, max 5M
icon_path: String,
}
const XHS_MINI_TOOL_CODE_ZIP_FILENAME: &str = "xhx-minitool.zip";
const XHS_MINI_TOOL_REGISTRY_FILENAME: &str = "xhx-minitool.json";
const XHS_MINI_TOOL_EXPORT_VITE_SCRIPT: &str = "build-xhx-minitool";
struct XHSMiniToolExportRegistry {
form: XHSMiniToolExportForm,
}
@@ -0,0 +1,20 @@
//! 导出产物:把用户项目导出成第三方平台的制品。
//!
//! 边界见 `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的
//! 2026-10-05 条目。一句话:宿主只做四件确定性的事——读写项目内 `.export/` 注册表、校验表单
//! 与构建脚本是否存在、以既有 `command.exec` 边界运行项目自己的 npm 脚本、把产物交给用户。
//! 适配与打包全部由 code agent 首次实验出来并固化成用户项目里的脚本;宿主不复刻平台规范、
//! 不解析 zip 结构,也不替用户跑 `pack.mjs`。
pub mod draft;
mod registry;
pub(crate) use registry::{content_hash, load_or_create, save, Loaded, RegistryError};
/// 导出工作目录:项目根下的 `.export/`。
///
/// 全部导出目标共用一个 flat 目录:共享文件(例如多目标复用的同一个 icon)直接放这一层,
/// 目标专属文件挂 target 前缀(`xhs-minitool.json`、`xhs-minitool.zip`……)。整个目录排除出
/// 项目快照——所以恢复或换机后适配成果不跟随,需要重新让 agent 适配。
pub(crate) const EXPORT_WORK_RELATIVE_DIR: &str = ".export";
@@ -0,0 +1,233 @@
//! 导出注册表的**文件级**读写。
//!
//! 这个模块只认「一个 JSON 文件 + 一个内容指纹」,不认任何 target:自动创建、严格解析、原子
//! 写入与内容 hash 全部收在这里,目标模块只负责提供自己的 DTO 与路径。指纹是**原始文件字节**
//! 的 SHA-256,不写进文件——它是「有没有别人改过」的判据,不是持久化状态。
use std::fmt;
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use serde::Serialize;
use sha2::{Digest, Sha256};
/// 已读出的注册表值与它的内容指纹。
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Loaded<T> {
pub(crate) value: T,
/// 原始文件内容的指纹;保存时用它判断「读完之后有没有别人写过」。
pub(crate) content_hash: String,
}
/// 注册表文件级失败。目标模块负责把它映射成自己的 typed error。
#[derive(Debug)]
pub(crate) enum RegistryError {
/// 路径不可读写(含父目录创建失败)。
Io { path: PathBuf, cause: String },
/// 文件内容不是本目标期望的形状。fail closed:不猜测、不修补。
Malformed { path: PathBuf, cause: String },
/// 宿主自己的 DTO 序列化失败,属于编程错误。
Encode { cause: String },
}
impl fmt::Display for RegistryError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, cause } => {
write!(formatter, "注册表读写失败:{}:{cause}", path.display())
}
Self::Malformed { path, cause } => {
write!(
formatter,
"注册表内容不符合约定:{}:{cause}",
path.display()
)
}
Self::Encode { cause } => write!(formatter, "注册表序列化失败:{cause}"),
}
}
}
/// 原始内容指纹(SHA-256,小写十六进制)。
pub(crate) fn content_hash(text: &str) -> String {
format!("{:x}", Sha256::digest(text.as_bytes()))
}
/// 读注册表;文件不存在时先用 `empty` 的规范序列化补一份空注册表,再读回来。
///
/// 「自动创建」是产品要求:面板一打开就要能显示空表单,而不是先报「文件不存在」。
pub(crate) fn load_or_create<T>(path: &Path, empty: &T) -> Result<Loaded<T>, RegistryError>
where
T: Serialize + DeserializeOwned,
{
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let created = encode(empty)?;
write_atomically(path, &created)?;
created
}
Err(error) => {
return Err(RegistryError::Io {
path: path.to_path_buf(),
cause: error.to_string(),
});
}
};
let value = decode(path, &text)?;
Ok(Loaded {
value,
content_hash: content_hash(&text),
})
}
/// 以规范形式写回注册表,返回**新内容**的指纹。
pub(crate) fn save<T: Serialize>(path: &Path, value: &T) -> Result<String, RegistryError> {
let text = encode(value)?;
write_atomically(path, &text)?;
Ok(content_hash(&text))
}
/// 规范形式:两空格缩进 + 结尾换行。agent 会手改这个文件,人类可读比紧凑更重要。
fn encode<T: Serialize>(value: &T) -> Result<String, RegistryError> {
let mut text = serde_json::to_string_pretty(value).map_err(|error| RegistryError::Encode {
cause: error.to_string(),
})?;
text.push('\n');
Ok(text)
}
fn decode<T: DeserializeOwned>(path: &Path, text: &str) -> Result<T, RegistryError> {
serde_json::from_str(text).map_err(|error| RegistryError::Malformed {
path: path.to_path_buf(),
cause: error.to_string(),
})
}
/// 先写同目录临时文件再原子替换。
///
/// 直接对目标 `File::create` 会在中途失败时同时留下半截文件和「原内容已被截断」两个后果;
/// manifest 与素材保存用的是同一套手法。
fn write_atomically(path: &Path, text: &str) -> Result<(), RegistryError> {
let parent = path.parent().ok_or_else(|| RegistryError::Io {
path: path.to_path_buf(),
cause: "注册表路径缺少父目录".to_string(),
})?;
fs::create_dir_all(parent).map_err(|error| RegistryError::Io {
path: path.to_path_buf(),
cause: format!("创建导出目录失败:{error}"),
})?;
let temp = temp_path(path);
let write = || -> std::io::Result<()> {
let mut file = fs::File::create(&temp)?;
file.write_all(text.as_bytes())?;
// 替换前落盘:只 flush 的话,崩溃窗口里换过去的可能是还没写完的内容。
file.sync_all()
};
if let Err(error) = write() {
let _ = fs::remove_file(&temp);
return Err(RegistryError::Io {
path: path.to_path_buf(),
cause: error.to_string(),
});
}
fs::rename(&temp, path).map_err(|error| {
let _ = fs::remove_file(&temp);
RegistryError::Io {
path: path.to_path_buf(),
cause: error.to_string(),
}
})
}
fn temp_path(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "registry.json".to_string());
path.with_file_name(format!(".{file_name}.{}.tmp", std::process::id()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Fixture {
name: String,
}
fn fixture_directory(prefix: &str) -> PathBuf {
let directory = std::env::temp_dir().join(format!(
"genarrative-export-registry-{prefix}-{}-{}",
std::process::id(),
NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
fs::create_dir_all(&directory).expect("create fixture directory");
directory
}
static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[test]
fn creates_empty_registry_and_keeps_its_hash_on_reread() {
let path = fixture_directory("create").join("target.json");
let empty = Fixture {
name: String::new(),
};
let first = load_or_create(&path, &empty).expect("create registry");
assert_eq!(first.value, empty);
assert_eq!(
fs::read_to_string(&path).unwrap(),
"{\n \"name\": \"\"\n}\n"
);
let second = load_or_create(&path, &empty).expect("reread registry");
assert_eq!(second.content_hash, first.content_hash);
}
#[test]
fn rejects_malformed_json_without_rewriting_it() {
let path = fixture_directory("malformed").join("target.json");
fs::write(&path, "{ not json").unwrap();
let empty = Fixture {
name: String::new(),
};
let error = load_or_create(&path, &empty).expect_err("malformed must fail closed");
assert!(matches!(error, RegistryError::Malformed { .. }));
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
}
#[test]
fn rejects_unknown_fields_so_agent_drift_is_visible() {
let path = fixture_directory("unknown").join("target.json");
fs::write(&path, "{\"name\":\"a\",\"status\":\"ready\"}\n").unwrap();
let empty = Fixture {
name: String::new(),
};
let error = load_or_create(&path, &empty).expect_err("unknown field must fail");
assert!(matches!(error, RegistryError::Malformed { .. }));
}
#[test]
fn save_returns_the_hash_of_what_it_wrote() {
let path = fixture_directory("save")
.join(".export")
.join("target.json");
let value = Fixture {
name: "小红书".to_string(),
};
let hash = save(&path, &value).expect("save registry");
let loaded = load_or_create(&path, &value).expect("reload registry");
assert_eq!(loaded.value, value);
assert_eq!(loaded.content_hash, hash);
}
}
@@ -2130,6 +2130,7 @@ mod diagnostic_log_tests {
}
}
pub mod export;
#[cfg(test)]
mod tests;
pub mod ui_editor;