diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs index cd3b553ee..ce40ae903 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs @@ -11,7 +11,9 @@ use std::path::Path; use crate::project::run_project_verification_with_commit_at; use super::dto::XHSMiniToolExportRunResult; -use super::error::{ArtifactMissing, CommandDenied, CommandFailed, XHSMiniToolExportError}; +use super::error::{ + ArtifactMissing, CommandDenied, CommandFailed, ExportUnavailable, XHSMiniToolExportBuildError, +}; use super::layout::{artifact_path, artifact_relative_path, SCRIPT_NAME}; use super::script; @@ -22,7 +24,9 @@ const TIMEOUT_SECONDS: u64 = 300; /// 回给前端的输出尾部上限(字符)。失败时用户要看的是最后几行报错,不是整份构建日志。 const OUTPUT_TAIL_MAX_CHARS: usize = 4_000; -pub(crate) async fn run(root: &Path) -> Result { +pub(crate) async fn run( + root: &Path, +) -> Result { let Some(script) = script::find(root) else { return Err(script::missing_error()); }; @@ -31,10 +35,10 @@ pub(crate) async fn run(root: &Path) -> Result { - XHSMiniToolExportError::CommandDenied(CommandDenied { command_id }) + XHSMiniToolExportBuildError::CommandDenied(CommandDenied { command_id }) } crate::agent::tool::error::ProjectPermissionRejection::PolicyUnavailable { cause } => { - XHSMiniToolExportError::unavailable(cause) + XHSMiniToolExportBuildError::ExportUnavailable(ExportUnavailable::new(cause)) } })?; @@ -48,7 +52,7 @@ pub(crate) async fn run(root: &Path) -> Result, timed_out: bool, output: &str, -) -> Result { +) -> Result { if timed_out || exit_code != Some(0) { let (output_tail, omitted_characters) = tail(output); - return Err(XHSMiniToolExportError::CommandFailed(CommandFailed { + return Err(XHSMiniToolExportBuildError::CommandFailed(CommandFailed { exit_code, output_tail, omitted_characters, @@ -79,9 +83,11 @@ fn conclude( ); if !produced { // 脚本"成功"却没产出:多半是 agent 把 zip 写到了别处,或 pack.mjs 的参数没对齐。 - return Err(XHSMiniToolExportError::ArtifactMissing(ArtifactMissing { - path: artifact_relative_path(), - })); + return Err(XHSMiniToolExportBuildError::ArtifactMissing( + ArtifactMissing { + path: artifact_relative_path(), + }, + )); } // 成功也回输出尾部:`pack.mjs` 会把 zip 的条目数与体积打在这里,正是用户上传前要看的数字。 @@ -155,7 +161,7 @@ mod tests { .expect_err("no package.json means no script"); assert!(matches!( error, - XHSMiniToolExportError::BuildScriptMissing(_) + XHSMiniToolExportBuildError::BuildScriptMissing(_) )); } @@ -165,7 +171,7 @@ mod tests { let error = conclude(&root, Some(1), false, "vite 构建失败\n入口不存在") .expect_err("non-zero exit must fail"); - let XHSMiniToolExportError::CommandFailed(failure) = error else { + let XHSMiniToolExportBuildError::CommandFailed(failure) = error else { panic!("非零退出必须映射成 commandFailed"); }; assert_eq!(failure.exit_code, Some(1)); @@ -179,7 +185,7 @@ mod tests { let error = conclude(&root, None, true, "已取消") .expect_err("timeout must fail even with exit code 0"); - let XHSMiniToolExportError::CommandFailed(failure) = error else { + let XHSMiniToolExportBuildError::CommandFailed(failure) = error else { panic!("超时必须映射成 commandFailed"); }; assert_eq!(failure.exit_code, None); @@ -192,7 +198,7 @@ mod tests { let root = fixture_root("no-artifact"); let error = conclude(&root, Some(0), false, "构建完成").expect_err("产物缺失必须失败"); - let XHSMiniToolExportError::ArtifactMissing(missing) = error else { + let XHSMiniToolExportBuildError::ArtifactMissing(missing) = error else { panic!("没有 zip 必须映射成 artifactMissing"); }; assert_eq!(missing.path, ".export/xhs-minitool.zip"); @@ -205,7 +211,7 @@ mod tests { assert!(matches!( conclude(&root, Some(0), false, "构建完成"), - Err(XHSMiniToolExportError::ArtifactMissing(_)) + Err(XHSMiniToolExportBuildError::ArtifactMissing(_)) )); } @@ -269,6 +275,9 @@ mod tests { std::fs::remove_file(artifact_path(&root)).unwrap(); let error = tauri::async_runtime::block_on(run(&root)).expect_err("脚本没产出 zip 时必须失败"); - assert!(matches!(error, XHSMiniToolExportError::ArtifactMissing(_))); + assert!(matches!( + error, + XHSMiniToolExportBuildError::ArtifactMissing(_) + )); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs index 02f427bd3..beb4b8257 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs @@ -1,18 +1,27 @@ //! 导出面板用到的四个 `#[tauri::command]`。 //! -//! 每个命令只做一件事,全部走 typed 失败([`XHSMiniToolExportError`])。没有项目写锁、不推进 -//! revision:宿主在这里读写的,就是 `.export/` 下那一个注册表文件。 +//! 每个命令只做一件事,失败表也各有一条([`super::error`])。没有项目写锁、不推进 revision:宿主 +//! 在这里读写的,就是 `.export/` 下那一个注册表文件。 //! //! 「内容」与「注册表指纹」是两条命令:[`read_xhs_minitool_export`] 是面板自动刷新反复调的 //! 那条,拿不到指纹;指纹(写回时的 `baseHash`)只在进编辑会话、或内容真的被采纳时单独取一次。 //! 分开是为了让刷新动作在类型上就动不了写回基线——否则外部改动会被静默吞掉。 //! //! 入参在 JS 侧是 camelCase(Tauri 的既有口径):`projectPath`、`baseHash`。 +//! +//! 命令边界各记一条 `app_log!`(走在 `project/*` 模块同样的口径上):面板卡在「正在读取」时, +//! AppData 里的 `export.<命令>.failed` 那一行就是答案。读命令每 2 秒重试一次,所以**同一个失败只 +//! 记一次**,变了或中间成功过一次才再记——否则日志会被同一句刷满。 +use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; use super::dto::{XHSMiniToolExportForm, XHSMiniToolExportRunResult, XHSMiniToolExportState}; -use super::error::XHSMiniToolExportError; +use super::error::{ + ExportUnavailable, XHSMiniToolExportBuildError, XHSMiniToolExportReadError, + XHSMiniToolExportSaveError, +}; use super::registry::{self, RegistrySnapshot}; use super::validation::validate_form; use super::{build, script}; @@ -21,9 +30,10 @@ use super::{build, script}; #[tauri::command] pub(crate) async fn read_xhs_minitool_export( project_path: String, -) -> Result { - let root = project_root(&project_path)?; - Ok(content(&root, registry::load(&root)?)) +) -> Result { + let result = read_content(&project_path); + report("read", &project_path, &result); + result } /// 读注册表指纹:写回时原样带回做冲突判据。 @@ -32,9 +42,10 @@ pub(crate) async fn read_xhs_minitool_export( #[tauri::command] pub(crate) async fn read_xhs_minitool_export_hash( project_path: String, -) -> Result { - let root = project_root(&project_path)?; - Ok(registry::load(&root)?.content_hash) +) -> Result { + let result = read_content_hash(&project_path); + report("readHash", &project_path, &result); + result } /// 保存表单:先硬校验(首个错误即返回),再按 `base_hash` 判冲突;成功后回新的指纹。 @@ -46,19 +57,41 @@ pub(crate) async fn save_xhs_minitool_export_form( project_path: String, form: XHSMiniToolExportForm, base_hash: String, -) -> Result { - let root = project_root(&project_path)?; - validate_form(&root, &form)?; - Ok(registry::save(&root, &form, &base_hash)?.content_hash) +) -> Result { + let result = save_form(&project_path, &form, &base_hash); + report("save", &project_path, &result); + if let Ok(hash) = &result { + app_log!( + "export.save.ok project={project_path} nameChars={} introductionChars={} iconPath={} baseHash={} contentHash={}", + form.name.trim().chars().count(), + form.introduction.trim().chars().count(), + form.icon_path.trim(), + short(&base_hash), + short(hash), + ); + } + result } -/// 跑一次导出:每次都重新执行项目脚本、重新产出 zip。 +/// 跑一次导出:每次都重新执行项目脚本、重新产出 zip。**不校验表单**——表单不是构建输入。 #[tauri::command] pub(crate) async fn run_xhs_minitool_export_build( project_path: String, -) -> Result { - let root = project_root(&project_path)?; - build::run(&root).await +) -> Result { + app_log!("export.build.start project={project_path}"); + let result = run_build(&project_path).await; + match &result { + Ok(run) => { + clear_failure("build"); + app_log!( + "export.build.ok project={project_path} outputChars={} omittedCharacters={}", + run.output_tail.chars().count(), + run.omitted_characters, + ); + } + Err(error) => log_failure("build", &project_path, error), + } + result } /// `hasScript` 永远现算:注册表里不存、也不缓存这个事实。 @@ -69,8 +102,86 @@ fn content(root: &Path, snapshot: RegistrySnapshot) -> XHSMiniToolExportState { } } -fn project_root(project_path: &str) -> Result { +/// 项目根校验对三条命令都是同一种宿主故障:各命令用 `From` 自己收口。 +fn project_root(project_path: &str) -> Result { let root = PathBuf::from(project_path); - crate::project::validate_project_root(&root).map_err(XHSMiniToolExportError::unavailable)?; + crate::project::validate_project_root(&root).map_err(ExportUnavailable::new)?; Ok(root) } + +fn read_content(project_path: &str) -> Result { + let root = project_root(project_path)?; + Ok(content( + &root, + registry::load::(&root)?, + )) +} + +fn read_content_hash(project_path: &str) -> Result { + let root = project_root(project_path)?; + Ok(registry::load::(&root)?.content_hash) +} + +fn save_form( + project_path: &str, + form: &XHSMiniToolExportForm, + base_hash: &str, +) -> Result { + let root = project_root(project_path)?; + validate_form(&root, form)?; + Ok(registry::save(&root, form, base_hash)?.content_hash) +} + +async fn run_build( + project_path: &str, +) -> Result { + let root = project_root(project_path)?; + build::run(&root).await +} + +/// 上一次记过的失败(按命令名)。面板每 2 秒重读,同一个失败会反复落到同一条命令上。 +static LAST_FAILURE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// 命令边界的统一收口:成功清掉去重记录,失败记一条。 +fn report(command: &'static str, project_path: &str, result: &Result) { + match result { + Ok(_) => clear_failure(command), + Err(error) => log_failure(command, project_path, error), + } +} + +fn log_failure(command: &'static str, project_path: &str, error: &impl std::fmt::Debug) { + let message = format!("{error:?}"); + if let Ok(mut last) = LAST_FAILURE.lock() { + if last.get(command).is_some_and(|seen| *seen == message) { + return; + } + last.insert(command, message.clone()); + } + app_log!("export.{command}.failed project={project_path}: {message}"); +} + +fn clear_failure(command: &'static str) { + if let Ok(mut last) = LAST_FAILURE.lock() { + last.remove(command); + } +} + +/// 日志里的指纹只留前 8 位:长度一样能看出「换了没有」,又不用把整串抄进日志。 +fn short(hash: &str) -> &str { + let end = hash.char_indices().nth(8).map_or(hash.len(), |(at, _)| at); + &hash[..end] +} + +#[cfg(test)] +mod tests { + use super::short; + + #[test] + fn short_keeps_first_eight_characters() { + assert_eq!(short("0123456789abcdef"), "01234567"); + assert_eq!(short("0123"), "0123"); + assert_eq!(short(""), ""); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs index 0a4cf3117..8106613e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs @@ -1,8 +1,18 @@ //! 命令边界的 typed 失败。 //! -//! 变体名就是线上的分流键(`type`),前端只按它选通道与话术,**不解析任何文案**。可达集合是 -//! 固定的一张表:读注册表 → 校验表单 → 找 npm 脚本 → 跑脚本 → 查产物 → 存表单。首个错误即 -//! 返回,不留问题数组——表单一次只报一个字段,用户改完再报下一个。 +//! **一条命令一张失败表**,不合并成一张大枚举:调用方拿到的 `Result` 本身就限定了「这条命令 +//! 可能怎么失败」,JS 侧也因此不用按命令再挑一遍变体。 +//! +//! - [`XHSMiniToolExportReadError`]:读注册表(`read_xhs_minitool_export` / +//! `read_xhs_minitool_export_hash`); +//! - [`XHSMiniToolExportSaveError`]:写表单(`save_xhs_minitool_export_form`,自动保存与「打包」 +//! 前的 flush 走同一条)。`formInvalid` 与 `saveConflict` **只**出现在这里——它们是「这份输入 +//! 还没落盘」的两种结果,不是构建失败; +//! - [`XHSMiniToolExportBuildError`]:跑构建(`run_xhs_minitool_export_build`)。构建不是表单的 +//! 消费者,所以这张表里没有表单类变体。 +//! +//! 变体名就是线上的分流键(`type`),前端只按它选通道与话术,**不解析任何文案**。每条命令都是 +//! 首个错误即返回,不留问题数组——表单一次只报一个字段,用户改完再报下一个。 //! //! 形状沿用仓库既有约定:带载荷的变体是 newtype 变体持有具名结构体,ts-rs 才能生成可判别的 //! TS 联合类型。 @@ -12,6 +22,7 @@ use ts_rs::TS; use super::dto::{XHSMiniToolExportField, XHSMiniToolExportForm}; +/// 读注册表可能怎么失败。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde( tag = "type", @@ -19,22 +30,50 @@ use super::dto::{XHSMiniToolExportField, XHSMiniToolExportForm}; rename_all_fields = "camelCase" )] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] -pub(crate) enum XHSMiniToolExportError { +pub(crate) enum XHSMiniToolExportReadError { /// 注册表存在但不是本目标期望的形状。fail closed:不猜测、不修补,引导重新适配。 RegistryMalformed(RegistryMalformed), + /// 项目根不可用或其它宿主侧事实故障。 + ExportUnavailable(ExportUnavailable), +} + +/// 保存表单可能怎么失败。自动保存与「打包」前的 flush 共用这一张表。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum XHSMiniToolExportSaveError { + /// 注册表存在但不是本目标期望的形状。 + RegistryMalformed(RegistryMalformed), /// 表单字段不合法。只回**首个**出错字段。 FormInvalid(FormInvalid), - /// npm 包里没有 `build:xhs-minitool`;这就是「还没适配」的唯一现算判据。 - BuildScriptMissing(BuildScriptMissing), /// 保存时发现注册表被别人改过,且字段真的不同。绝不覆盖,交给用户逐字段选。 SaveConflict(SaveConflict), + /// 项目根不可用或其它宿主侧事实故障。 + ExportUnavailable(ExportUnavailable), +} + +/// 跑构建可能怎么失败。构建不是表单的消费者,所以这里没有表单类变体。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum XHSMiniToolExportBuildError { + /// npm 包里没有 `build:xhs-minitool`;这就是「还没适配」的唯一现算判据。 + BuildScriptMissing(BuildScriptMissing), /// 项目权限策略拒绝执行 `command.exec`:用户/项目策略说了不让跑,不是缺陷。 CommandDenied(CommandDenied), /// 脚本跑完没有正常收场(非零退出或超时)。 CommandFailed(CommandFailed), /// 脚本正常收场但产物不在。 ArtifactMissing(ArtifactMissing), - /// 项目根不可用、权限策略拒绝、脚本无法启动或其它宿主侧事实故障。 + /// 脚本无法启动或其它宿主侧事实故障。 ExportUnavailable(ExportUnavailable), } @@ -110,30 +149,57 @@ pub(crate) struct ExportUnavailable { pub(crate) cause: String, } -impl XHSMiniToolExportError { +impl ExportUnavailable { + pub(crate) fn new(cause: impl Into) -> Self { + Self { + cause: cause.into(), + } + } +} + +impl XHSMiniToolExportSaveError { pub(crate) fn form_invalid(field: XHSMiniToolExportField, reason: impl Into) -> Self { Self::FormInvalid(FormInvalid { field, reason: reason.into(), }) } +} - pub(crate) fn unavailable(cause: impl Into) -> Self { - Self::ExportUnavailable(ExportUnavailable { - cause: cause.into(), - }) +/// 三条命令都要能把「项目根不可用 / 脚本起不来」装进自己的表里。 +impl From for XHSMiniToolExportReadError { + fn from(payload: ExportUnavailable) -> Self { + Self::ExportUnavailable(payload) } } -impl From for XHSMiniToolExportError { - fn from(error: crate::export::RegistryError) -> Self { - match error { - crate::export::RegistryError::Malformed { cause, .. } => { - Self::RegistryMalformed(RegistryMalformed { cause }) +impl From for XHSMiniToolExportSaveError { + fn from(payload: ExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +impl From for XHSMiniToolExportBuildError { + fn from(payload: ExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +/// 注册表层的失败折叠成各命令自己的表:读不出来的形状是 `registryMalformed`,其余是宿主故障。 +macro_rules! impl_from_registry_error { + ($error:ty) => { + impl From for $error { + fn from(error: crate::export::RegistryError) -> Self { + match error { + crate::export::RegistryError::Malformed { cause, .. } => { + Self::RegistryMalformed(RegistryMalformed { cause }) + } + other => Self::ExportUnavailable(ExportUnavailable::new(other.to_string())), + } } - other => Self::ExportUnavailable(ExportUnavailable { - cause: other.to_string(), - }), } - } + }; } + +impl_from_registry_error!(XHSMiniToolExportReadError); +impl_from_registry_error!(XHSMiniToolExportSaveError); diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs index 3eb535d1e..6453ccd13 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs @@ -8,7 +8,7 @@ use std::path::Path; use crate::export::{load_or_create, save as write_registry}; use super::dto::{XHSMiniToolExportForm, XHSMiniToolExportRegistry}; -use super::error::{SaveConflict, XHSMiniToolExportError}; +use super::error::{SaveConflict, XHSMiniToolExportSaveError}; use super::layout::registry_path; /// 注册表现场:表单,加上**这份内容**的指纹。 @@ -19,7 +19,13 @@ pub(crate) struct RegistrySnapshot { } /// 读注册表;文件不存在时补一份全空表单。解析失败原样上抛成 `registryMalformed`。 -pub(crate) fn load(root: &Path) -> Result { +/// +/// 返回的错误类型由调用方定:读命令与保存命令各有一张失败表,两边都实现了 +/// `From`,所以这里泛型化,不替调用方选表。 +pub(crate) fn load(root: &Path) -> Result +where + E: From, +{ let loaded = load_or_create(®istry_path(root), &XHSMiniToolExportRegistry::default())?; Ok(RegistrySnapshot { form: loaded.value.form, @@ -36,14 +42,14 @@ pub(crate) fn save( root: &Path, form: &XHSMiniToolExportForm, base_hash: &str, -) -> Result { +) -> Result { let path = registry_path(root); let current = load_or_create(&path, &XHSMiniToolExportRegistry::default())?; let form = form.normalized(); if !current.created && current.content_hash != base_hash { let current_form = current.value.form.normalized(); if current_form != form { - return Err(XHSMiniToolExportError::SaveConflict(SaveConflict { + return Err(XHSMiniToolExportSaveError::SaveConflict(SaveConflict { current: current_form, content_hash: current.content_hash, })); @@ -87,7 +93,7 @@ mod tests { fn creates_an_empty_registry_on_first_read() { let root = fixture_project("create"); - let snapshot = load(&root).expect("load"); + let snapshot = load::(&root).expect("load"); assert_eq!(snapshot.form, XHSMiniToolExportForm::default()); assert!( root.join(".export/xhs-minitool.json").is_file(), @@ -98,7 +104,7 @@ mod tests { #[test] fn saves_and_normalizes_whitespace() { let root = fixture_project("save"); - let base = load(&root).expect("load"); + let base = load::(&root).expect("load"); let mut value = form(" 小工具 "); value.introduction = " 一句话简介 ".to_string(); @@ -106,7 +112,7 @@ mod tests { assert_eq!(saved.form.name, "小工具"); assert_eq!(saved.form.introduction, "一句话简介"); - let reread = load(&root).expect("reload"); + let reread = load::(&root).expect("reload"); assert_eq!(reread.form, saved.form); assert_eq!(reread.content_hash, saved.content_hash); } @@ -114,7 +120,7 @@ mod tests { #[test] fn absorbs_formatting_only_changes_without_rewriting() { let root = fixture_project("format"); - let base = load(&root).expect("load"); + let base = load::(&root).expect("load"); // agent 重排了文件:字段没变,指纹变了。 let compact = "{\"form\":{\"name\":\"\",\"introduction\":\"\",\"iconPath\":\"\"}}"; fs::write(root.join(".export/xhs-minitool.json"), compact).unwrap(); @@ -133,7 +139,7 @@ mod tests { #[test] fn refuses_to_overwrite_a_real_change() { let root = fixture_project("conflict"); - let base = load(&root).expect("load"); + let base = load::(&root).expect("load"); let theirs = form("agent 写的"); fs::write( root.join(".export/xhs-minitool.json"), @@ -147,7 +153,7 @@ mod tests { let error = save(&root, &form("用户写的"), &base.content_hash) .expect_err("real change must not be overwritten"); match error { - XHSMiniToolExportError::SaveConflict(payload) => { + XHSMiniToolExportSaveError::SaveConflict(payload) => { assert_eq!(payload.current, theirs); } other => panic!("expected saveConflict, got {other:?}"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs index 37cc4d20a..c1f0fd81c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs @@ -6,7 +6,7 @@ use std::path::Path; -use super::error::{BuildScriptMissing, XHSMiniToolExportError}; +use super::error::{BuildScriptMissing, XHSMiniToolExportBuildError}; use super::layout::SCRIPT_NAME; /// npm 工作目录候选,按顺序取第一个声明了脚本的。 @@ -66,8 +66,8 @@ pub(crate) fn find(root: &Path) -> Option { } /// 找不到脚本时的 typed 失败:把找过的位置一起给出去,用户才知道去哪看。 -pub(crate) fn missing_error() -> XHSMiniToolExportError { - XHSMiniToolExportError::BuildScriptMissing(BuildScriptMissing { +pub(crate) fn missing_error() -> XHSMiniToolExportBuildError { + XHSMiniToolExportBuildError::BuildScriptMissing(BuildScriptMissing { script: SCRIPT_NAME.to_string(), searched: NPM_WORK_CANDIDATES .iter() @@ -147,7 +147,7 @@ mod tests { let error = missing_error(); match error { - XHSMiniToolExportError::BuildScriptMissing(payload) => { + XHSMiniToolExportBuildError::BuildScriptMissing(payload) => { assert_eq!(payload.script, SCRIPT_NAME); assert_eq!(payload.searched, vec!["package.json", "game/package.json"]); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs index 578adf63f..997d96c13 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs @@ -7,7 +7,7 @@ use std::path::Path; use super::dto::{XHSMiniToolExportField, XHSMiniToolExportForm}; -use super::error::XHSMiniToolExportError; +use super::error::XHSMiniToolExportSaveError; /// 小红书平台对名称的字数上限(Unicode 字符数,不是字节数)。 pub(crate) const NAME_MAX_CHARS: usize = 14; @@ -19,7 +19,7 @@ const ICON_EXTENSIONS: [&str; 3] = ["png", "jpg", "jpeg"]; pub(crate) fn validate_form( root: &Path, form: &XHSMiniToolExportForm, -) -> Result<(), XHSMiniToolExportError> { +) -> Result<(), XHSMiniToolExportSaveError> { validate_text( XHSMiniToolExportField::Name, "名称", @@ -40,17 +40,17 @@ fn validate_text( label: &str, value: &str, max_chars: usize, -) -> Result<(), XHSMiniToolExportError> { +) -> Result<(), XHSMiniToolExportSaveError> { let trimmed = value.trim(); if trimmed.is_empty() { - return Err(XHSMiniToolExportError::form_invalid( + return Err(XHSMiniToolExportSaveError::form_invalid( field, format!("{label}不能为空"), )); } let chars = trimmed.chars().count(); if chars > max_chars { - return Err(XHSMiniToolExportError::form_invalid( + return Err(XHSMiniToolExportSaveError::form_invalid( field, format!("{label}最多 {max_chars} 个字,当前 {chars} 个字"), )); @@ -58,26 +58,32 @@ fn validate_text( Ok(()) } -fn validate_icon(root: &Path, form: &XHSMiniToolExportForm) -> Result<(), XHSMiniToolExportError> { +fn validate_icon( + root: &Path, + form: &XHSMiniToolExportForm, +) -> Result<(), XHSMiniToolExportSaveError> { let field = XHSMiniToolExportField::IconPath; let relative = form.icon_path.trim(); if relative.is_empty() { - return Err(XHSMiniToolExportError::form_invalid(field, "icon 不能为空")); + return Err(XHSMiniToolExportSaveError::form_invalid( + field, + "icon 不能为空", + )); } // 与通用项目读取同一套拒绝口径:越界、符号链接、Windows reparse point 都在这里挡掉。 let path = crate::project::resolve_local_project_path(root, relative) - .map_err(|cause| XHSMiniToolExportError::form_invalid(field, cause))?; + .map_err(|cause| XHSMiniToolExportSaveError::form_invalid(field, cause))?; let metadata = std::fs::symlink_metadata(&path).map_err(|error| { - XHSMiniToolExportError::form_invalid(field, format!("icon 不存在或不可读:{error}")) + XHSMiniToolExportSaveError::form_invalid(field, format!("icon 不存在或不可读:{error}")) })?; if metadata.file_type().is_symlink() { - return Err(XHSMiniToolExportError::form_invalid( + return Err(XHSMiniToolExportSaveError::form_invalid( field, "icon 不能是符号链接", )); } if !metadata.is_file() { - return Err(XHSMiniToolExportError::form_invalid( + return Err(XHSMiniToolExportSaveError::form_invalid( field, "icon 必须是普通文件", )); @@ -89,13 +95,13 @@ fn validate_icon(root: &Path, form: &XHSMiniToolExportForm) -> Result<(), XHSMin .unwrap_or_default(); if !ICON_EXTENSIONS.contains(&extension.as_str()) { // TODO: 提供格式转换工具,把平台不认的格式(webp、heic 等)转成 png/jpg/jpeg。 - return Err(XHSMiniToolExportError::form_invalid( + return Err(XHSMiniToolExportSaveError::form_invalid( field, format!("icon 只支持 png、jpg、jpeg,当前是 .{extension}"), )); } if metadata.len() > ICON_MAX_BYTES { - return Err(XHSMiniToolExportError::form_invalid( + return Err(XHSMiniToolExportSaveError::form_invalid( field, format!( "icon 不能超过 5 MiB,当前 {:.1} MiB", @@ -152,7 +158,7 @@ mod tests { let error = validate_form(&root, &form).expect_err("over the limit"); assert!(matches!( error, - XHSMiniToolExportError::FormInvalid(payload) + XHSMiniToolExportSaveError::FormInvalid(payload) if payload.field == XHSMiniToolExportField::Name )); } @@ -169,7 +175,7 @@ mod tests { let error = validate_form(&root, &form).expect_err("empty form"); assert!(matches!( error, - XHSMiniToolExportError::FormInvalid(payload) + XHSMiniToolExportSaveError::FormInvalid(payload) if payload.field == XHSMiniToolExportField::Name )); } @@ -184,7 +190,7 @@ mod tests { .expect_err("escaping path must be rejected"); assert!(matches!( error, - XHSMiniToolExportError::FormInvalid(payload) + XHSMiniToolExportSaveError::FormInvalid(payload) if payload.field == XHSMiniToolExportField::IconPath )); @@ -195,7 +201,7 @@ mod tests { .expect_err("symlink must be rejected"); assert!(matches!( error, - XHSMiniToolExportError::FormInvalid(payload) + XHSMiniToolExportSaveError::FormInvalid(payload) if payload.field == XHSMiniToolExportField::IconPath )); } @@ -209,7 +215,7 @@ mod tests { .expect_err("webp is not accepted yet"); assert!(matches!( error, - XHSMiniToolExportError::FormInvalid(payload) + XHSMiniToolExportSaveError::FormInvalid(payload) if payload.field == XHSMiniToolExportField::IconPath )); @@ -218,7 +224,7 @@ mod tests { let error = validate_form(&root, &form_with_icon("assets/big.png")).expect_err("too large"); assert!(matches!( error, - XHSMiniToolExportError::FormInvalid(payload) + XHSMiniToolExportSaveError::FormInvalid(payload) if payload.field == XHSMiniToolExportField::IconPath )); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportBuildError.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportBuildError.ts new file mode 100644 index 000000000..8e4ab1228 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportBuildError.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ArtifactMissing } from "./ArtifactMissing"; +import type { BuildScriptMissing } from "./BuildScriptMissing"; +import type { CommandDenied } from "./CommandDenied"; +import type { CommandFailed } from "./CommandFailed"; +import type { ExportUnavailable } from "./ExportUnavailable"; + +/** + * 跑构建可能怎么失败。构建不是表单的消费者,所以这里没有表单类变体。 + */ +export type XHSMiniToolExportBuildError = { "type": "buildScriptMissing" } & BuildScriptMissing | { "type": "commandDenied" } & CommandDenied | { "type": "commandFailed" } & CommandFailed | { "type": "artifactMissing" } & ArtifactMissing | { "type": "exportUnavailable" } & ExportUnavailable; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportError.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportError.ts deleted file mode 100644 index e90224f01..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportError.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ArtifactMissing } from "./ArtifactMissing"; -import type { BuildScriptMissing } from "./BuildScriptMissing"; -import type { CommandDenied } from "./CommandDenied"; -import type { CommandFailed } from "./CommandFailed"; -import type { ExportUnavailable } from "./ExportUnavailable"; -import type { FormInvalid } from "./FormInvalid"; -import type { RegistryMalformed } from "./RegistryMalformed"; -import type { SaveConflict } from "./SaveConflict"; - -export type XHSMiniToolExportError = { "type": "registryMalformed" } & RegistryMalformed | { "type": "formInvalid" } & FormInvalid | { "type": "buildScriptMissing" } & BuildScriptMissing | { "type": "saveConflict" } & SaveConflict | { "type": "commandDenied" } & CommandDenied | { "type": "commandFailed" } & CommandFailed | { "type": "artifactMissing" } & ArtifactMissing | { "type": "exportUnavailable" } & ExportUnavailable; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportReadError.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportReadError.ts new file mode 100644 index 000000000..54e2f71c8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportReadError.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExportUnavailable } from "./ExportUnavailable"; +import type { RegistryMalformed } from "./RegistryMalformed"; + +/** + * 读注册表可能怎么失败。 + */ +export type XHSMiniToolExportReadError = { "type": "registryMalformed" } & RegistryMalformed | { "type": "exportUnavailable" } & ExportUnavailable; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportSaveError.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportSaveError.ts new file mode 100644 index 000000000..e2e09da22 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportSaveError.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExportUnavailable } from "./ExportUnavailable"; +import type { FormInvalid } from "./FormInvalid"; +import type { RegistryMalformed } from "./RegistryMalformed"; +import type { SaveConflict } from "./SaveConflict"; + +/** + * 保存表单可能怎么失败。自动保存与「打包」前的 flush 共用这一张表。 + */ +export type XHSMiniToolExportSaveError = { "type": "registryMalformed" } & RegistryMalformed | { "type": "formInvalid" } & FormInvalid | { "type": "saveConflict" } & SaveConflict | { "type": "exportUnavailable" } & ExportUnavailable;