导出命令的错误按命令拆开,宿主补 app_log 现场

- `XHSMiniToolExportError` 一个大枚举拆成读 / 存 / 构建三份,每条命令只承载自己出得了的变体;
- 三条命令的返回类型跟着换成本命令那份,策略拒绝(`commandDenied`)与宿主故障(`exportUnavailable`)在各自主语里分开;
- 关键分支补 `app_log!`:命令入口、策略拒绝、脚本非零退出、产物缺失、注册表坏都留一行现场,出错时不必只靠前端转述;
- 重新生成 ts-rs 绑定:删掉 `XHSMiniToolExportError.ts`,新增 `XHSMiniToolExport{Read,Save,Build}Error.ts`。
This commit is contained in:
2026-10-06 14:31:23 +08:00
parent 25315299a5
commit a0b791083f
10 changed files with 316 additions and 100 deletions
@@ -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(&registry_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
));
}
@@ -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;
@@ -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;
@@ -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;
@@ -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;