导出命令的错误按命令拆开,宿主补 app_log 现场
- `XHSMiniToolExportError` 一个大枚举拆成读 / 存 / 构建三份,每条命令只承载自己出得了的变体;
- 三条命令的返回类型跟着换成本命令那份,策略拒绝(`commandDenied`)与宿主故障(`exportUnavailable`)在各自主语里分开;
- 关键分支补 `app_log!`:命令入口、策略拒绝、脚本非零退出、产物缺失、注册表坏都留一行现场,出错时不必只靠前端转述;
- 重新生成 ts-rs 绑定:删掉 `XHSMiniToolExportError.ts`,新增 `XHSMiniToolExport{Read,Save,Build}Error.ts`。
This commit is contained in:
@@ -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<XHSMiniToolExportRunResult, XHSMiniToolExportError> {
|
||||
pub(crate) async fn run(
|
||||
root: &Path,
|
||||
) -> Result<XHSMiniToolExportRunResult, XHSMiniToolExportBuildError> {
|
||||
let Some(script) = script::find(root) else {
|
||||
return Err(script::missing_error());
|
||||
};
|
||||
@@ -31,10 +35,10 @@ pub(crate) async fn run(root: &Path) -> Result<XHSMiniToolExportRunResult, XHSMi
|
||||
crate::project::enforce_project_permission_policy_rejection(root, PERMISSION_COMMAND_ID)
|
||||
.map_err(|rejection| match rejection {
|
||||
crate::agent::tool::error::ProjectPermissionRejection::Denied { command_id } => {
|
||||
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<XHSMiniToolExportRunResult, XHSMi
|
||||
|| Ok(()),
|
||||
)
|
||||
.await
|
||||
.map_err(XHSMiniToolExportError::unavailable)?;
|
||||
.map_err(ExportUnavailable::new)?;
|
||||
|
||||
conclude(root, result.exit_code, result.timed_out, &result.output)
|
||||
}
|
||||
@@ -62,10 +66,10 @@ fn conclude(
|
||||
exit_code: Option<i32>,
|
||||
timed_out: bool,
|
||||
output: &str,
|
||||
) -> Result<XHSMiniToolExportRunResult, XHSMiniToolExportError> {
|
||||
) -> Result<XHSMiniToolExportRunResult, XHSMiniToolExportBuildError> {
|
||||
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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<XHSMiniToolExportState, XHSMiniToolExportError> {
|
||||
let root = project_root(&project_path)?;
|
||||
Ok(content(&root, registry::load(&root)?))
|
||||
) -> Result<XHSMiniToolExportState, XHSMiniToolExportReadError> {
|
||||
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<String, XHSMiniToolExportError> {
|
||||
let root = project_root(&project_path)?;
|
||||
Ok(registry::load(&root)?.content_hash)
|
||||
) -> Result<String, XHSMiniToolExportReadError> {
|
||||
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<String, XHSMiniToolExportError> {
|
||||
let root = project_root(&project_path)?;
|
||||
validate_form(&root, &form)?;
|
||||
Ok(registry::save(&root, &form, &base_hash)?.content_hash)
|
||||
) -> Result<String, XHSMiniToolExportSaveError> {
|
||||
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<XHSMiniToolExportRunResult, XHSMiniToolExportError> {
|
||||
let root = project_root(&project_path)?;
|
||||
build::run(&root).await
|
||||
) -> Result<XHSMiniToolExportRunResult, XHSMiniToolExportBuildError> {
|
||||
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<PathBuf, XHSMiniToolExportError> {
|
||||
/// 项目根校验对三条命令都是同一种宿主故障:各命令用 `From<ExportUnavailable>` 自己收口。
|
||||
fn project_root(project_path: &str) -> Result<PathBuf, ExportUnavailable> {
|
||||
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<XHSMiniToolExportState, XHSMiniToolExportReadError> {
|
||||
let root = project_root(project_path)?;
|
||||
Ok(content(
|
||||
&root,
|
||||
registry::load::<XHSMiniToolExportReadError>(&root)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn read_content_hash(project_path: &str) -> Result<String, XHSMiniToolExportReadError> {
|
||||
let root = project_root(project_path)?;
|
||||
Ok(registry::load::<XHSMiniToolExportReadError>(&root)?.content_hash)
|
||||
}
|
||||
|
||||
fn save_form(
|
||||
project_path: &str,
|
||||
form: &XHSMiniToolExportForm,
|
||||
base_hash: &str,
|
||||
) -> Result<String, XHSMiniToolExportSaveError> {
|
||||
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<XHSMiniToolExportRunResult, XHSMiniToolExportBuildError> {
|
||||
let root = project_root(project_path)?;
|
||||
build::run(&root).await
|
||||
}
|
||||
|
||||
/// 上一次记过的失败(按命令名)。面板每 2 秒重读,同一个失败会反复落到同一条命令上。
|
||||
static LAST_FAILURE: LazyLock<Mutex<HashMap<&'static str, String>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// 命令边界的统一收口:成功清掉去重记录,失败记一条。
|
||||
fn report<T, E: std::fmt::Debug>(command: &'static str, project_path: &str, result: &Result<T, E>) {
|
||||
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(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>) -> Self {
|
||||
Self {
|
||||
cause: cause.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl XHSMiniToolExportSaveError {
|
||||
pub(crate) fn form_invalid(field: XHSMiniToolExportField, reason: impl Into<String>) -> Self {
|
||||
Self::FormInvalid(FormInvalid {
|
||||
field,
|
||||
reason: reason.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unavailable(cause: impl Into<String>) -> Self {
|
||||
Self::ExportUnavailable(ExportUnavailable {
|
||||
cause: cause.into(),
|
||||
})
|
||||
/// 三条命令都要能把「项目根不可用 / 脚本起不来」装进自己的表里。
|
||||
impl From<ExportUnavailable> for XHSMiniToolExportReadError {
|
||||
fn from(payload: ExportUnavailable) -> Self {
|
||||
Self::ExportUnavailable(payload)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::export::RegistryError> for XHSMiniToolExportError {
|
||||
fn from(error: crate::export::RegistryError) -> Self {
|
||||
match error {
|
||||
crate::export::RegistryError::Malformed { cause, .. } => {
|
||||
Self::RegistryMalformed(RegistryMalformed { cause })
|
||||
impl From<ExportUnavailable> for XHSMiniToolExportSaveError {
|
||||
fn from(payload: ExportUnavailable) -> Self {
|
||||
Self::ExportUnavailable(payload)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExportUnavailable> for XHSMiniToolExportBuildError {
|
||||
fn from(payload: ExportUnavailable) -> Self {
|
||||
Self::ExportUnavailable(payload)
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册表层的失败折叠成各命令自己的表:读不出来的形状是 `registryMalformed`,其余是宿主故障。
|
||||
macro_rules! impl_from_registry_error {
|
||||
($error:ty) => {
|
||||
impl From<crate::export::RegistryError> 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);
|
||||
|
||||
@@ -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<RegistrySnapshot, XHSMiniToolExportError> {
|
||||
///
|
||||
/// 返回的错误类型由调用方定:读命令与保存命令各有一张失败表,两边都实现了
|
||||
/// `From<RegistryError>`,所以这里泛型化,不替调用方选表。
|
||||
pub(crate) fn load<E>(root: &Path) -> Result<RegistrySnapshot, E>
|
||||
where
|
||||
E: From<crate::export::RegistryError>,
|
||||
{
|
||||
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<RegistrySnapshot, XHSMiniToolExportError> {
|
||||
) -> Result<RegistrySnapshot, XHSMiniToolExportSaveError> {
|
||||
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::<XHSMiniToolExportSaveError>(&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::<XHSMiniToolExportSaveError>(&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::<XHSMiniToolExportSaveError>(&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::<XHSMiniToolExportSaveError>(&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::<XHSMiniToolExportSaveError>(&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:?}"),
|
||||
|
||||
@@ -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<NpmScript> {
|
||||
}
|
||||
|
||||
/// 找不到脚本时的 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"]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
+11
@@ -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;
|
||||
-11
@@ -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;
|
||||
+8
@@ -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;
|
||||
+10
@@ -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;
|
||||
Reference in New Issue
Block a user