导出注册表文件级读写收口到 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:
@@ -0,0 +1 @@
|
||||
pub mod xhs_minitool;
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user