Files
Unlucky-Launcher/crates/cli/src/error.rs
T
AskaEth 04ac90911e fix(m159): r1 复审返工 —— parse.rs 拆出子模块 + 五条 P3 真问题
按独立复审 `reviewer-updategui-r1` 的 p1-1 / fix-then-merge 返工。

P1-1(挡门)
- `crates/cli/src/cli/parse.rs` 406 → **382** 行:`fn parse_update` 搬到
  `crates/cli/src/cli/parse/update.rs`(与 `parse/{accounts,agent,instances}.rs` 同形状)。
  全仓 `crates/**/*.rs` 最大仍是 400(`design/tests/card_neutral.rs` 等三处,未越线)。
  上一轮报告里「所有改动文件都在 400 行内」那句是错的,已在 r2 报告里更正。

P3-6 `Job::apply` 不查包形态
- 新增 `self_replaceable(format)`(只有 `PortableExe` 允许)+ `ensure_self_replaceable`,
  **挡在取包之前**;错误分类 `InvalidManifest`(发布侧挂错,换源/重试无用)。
  比复审建议的 `needs_installer()` 更严:`portable-zip`/`appimage` 同样不能原地当 exe。
- 用例:`crates/core/src/update/tests/job_cases.rs`(msi/zip 被拒 + 目标零改动 + 暂存目录
  没被建 + 便携 exe 整环成功 + Nothing 不碰目标 + 闸门的源码锚点)。

P3-7 `ULL_UPDATE_DIR` 生产可用
- `default_source` 改走 `local_dir_override()`:`if !cfg!(debug_assertions) { return None }`,
  发布构建里整段被常量折叠掉(这条路径在 release 二进制里不存在)。判据与三档取舍写在
  `job.rs` 的文档表里(为什么不用 CLI 参数、为什么不用运行期开关)。

P3-1「忽略这个版本」语义
- `UpdateView.dismissed: bool` → `skipped: Option<String>`(记**版本号**):忽略 0.2.3 之后
  同一次运行里冒出 0.2.4 **照常提示**;手动检查清空(`begin_check(manual)`),静默检查不清。
  文案保持「忽略这个版本」。presenter / app 侧视图模型 / 两处用例同步。

P3-3 CLI `--json` 失败信封缺结构化分类
- `CliError` 加 `detail`(`Option<Box<Value>>`,装箱避免 `result_large_err`)+ `with_detail`,
  `to_json` 输出 `error.detail`;`commands/update.rs` 的 failure 填
  `{"kind": core 分类 id, "retryable": 可重试位}`。
- 上一轮报告里引的 `{"kind":"digest_mismatch"}` 形状确实不存在(那是 CLI 的 user/internal),
  r2 报告换成了实机 dump。

P3-4 update 命令没有解析测试
- `crates/cli/tests/parse_test.rs`:`update_subcommands`(check / apply / `--dest` 两种写法)
  + `update_usage_errors`(无子命令 / 未知子命令 / 多余位置参数 / 缺少取值 / 未知选项);
  `help_text_covers_every_command` 的 topic 列表补 `"update"`。

P3-5 `install.rs` 模块文档与实测矛盾
- 改成「可以改名、不能覆盖/删除」,写明 M159 在 Windows 真机验过运行中的 exe 自替换,
  并提醒别再照旧文档去设计「退出自身 → 另一个进程搬运」的双进程方案。

顺手(复审 P3-2/P3-8 里低成本的两条)
- 设置页补「下载中」态(`SettingsUpdateApplying` 不再是死 key,下载期间不画「下载并安装」);
  渠道显示改读 `update.channel`(`stable` 走 key,其它原样);删掉从未使用的
  `UpdateBannerRestartLater`(catalog + 金表同步,29 条 key 全部有引用)。
- `adopt` 只让**检查**刷新 `checked_at`(应用回报不再冒充「上次检查」);
  `UpdateView::default()` 的 `current_version` 取本进程版本号(mock 下不再空白)。

门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-159`):fmt 绿、clippy -D warnings 绿、
`cargo test --workspace --all-targets --no-fail-fast -j 4` **exit 0**(51 个测试二进制全 ok,
`core::addon::pack` 未被 skip)。该族用例复现了复审记的泄漏:一次全量在 `/tmp` 留 72 个
`ull-pack-*`(`/tmp` 149M → 8853M),跑完已 `rm -rf` 清回 150M。
2026-11-07 13:03:18 +08:00

199 lines
6.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 错误形状与退出码。
//!
//! 约定(10-cli-design.md §3):**0 成功 / 1 用户错误 / 2 内部错误**。
//! 用户错误 = 用法错、目标不存在、环境不支持、依赖模块未接线;
//! 内部错误 = 不该发生的事(序列化失配等),脚本见到 2 应该当 bug 报。
use std::fmt;
use serde_json::{json, Value};
/// 退出码:成功。
pub const EXIT_OK: i32 = 0;
/// 退出码:用户错误。
pub const EXIT_USER: i32 = 1;
/// 退出码:内部错误。
pub const EXIT_INTERNAL: i32 = 2;
/// 错误大类(决定退出码)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
/// 用户侧问题:改参数/改环境可解决。
User,
/// 内部问题:不该发生,应报 bug。
Internal,
}
impl ErrorKind {
/// 稳定名(`--json` 输出用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Internal => "internal",
}
}
/// 对应的进程退出码。
#[must_use]
pub const fn exit_code(self) -> i32 {
match self {
Self::User => EXIT_USER,
Self::Internal => EXIT_INTERNAL,
}
}
}
/// 一条 CLI 错误。
///
/// `code` 是给脚本的稳定机读标识(`usage` / `not_found` / `unsupported_os` …),
/// `message` 是给人看的中文说明,`hint` 是「下一步该做什么」。
#[derive(Debug, Clone)]
pub struct CliError {
kind: ErrorKind,
code: &'static str,
message: String,
hint: Option<String>,
/// `--json` 信封里 `error.detail` 的结构化载荷(可选)。
///
/// 为什么要有它:`code` 只分到「哪条命令的哪一类失败」(`update_apply_failed`),
/// 而脚本真正要分流的是**底层原因**(`digest_mismatch` / `network` / `invalid_manifest`)。
/// 那些原因以前只出现在人读 `message` 里(`应用更新失败(digest_mismatch):…`),
/// 脚本不解析散文就分不出来。`detail` 把它们挪进机器可读字段。
///
/// **装箱**:`serde_json::Value` 有 32 字节,直接塞进 `CliError` 会把它顶到 144 字节,
/// 于是所有 `Result<_, CliError>` 都触发 `clippy::result_large_err`(阈值 128)。
/// `Option<Box<_>>` 只占 8 字节,错误对象保持轻量。
detail: Option<Box<Value>>,
}
/// 把模板里的程序名占位符渲染掉(M82 r3)。
///
/// 渲染放在**构造时**,而不是打印时:错误有两条出口(人类可读 stderr、`--json` 信封),
/// 只在打印处渲染会漏掉 JSON 那条(r2 就是这么漏的,`%prog%` 直接出现在了 JSON 里)。
fn render(text: impl Into<String>) -> String {
crate::cli::program::fill(&text.into())
}
impl CliError {
/// 用法错误(参数不对)。
#[must_use]
pub fn usage(message: impl Into<String>) -> Self {
Self {
kind: ErrorKind::User,
code: "usage",
message: render(message),
hint: None,
detail: None,
}
}
/// 用户错误,带稳定 code。
#[must_use]
pub fn user(code: &'static str, message: impl Into<String>) -> Self {
Self {
kind: ErrorKind::User,
code,
message: render(message),
hint: None,
detail: None,
}
}
/// 内部错误,带稳定 code。
#[must_use]
pub fn internal(code: &'static str, message: impl Into<String>) -> Self {
Self {
kind: ErrorKind::Internal,
code,
message: render(message),
hint: None,
detail: None,
}
}
/// IO 失败(读配置/读写会话文件):按用户错误处理,路径进消息。
#[must_use]
pub fn io(op: &str, path: &std::path::Path, error: &std::io::Error) -> Self {
Self::user("io", format!("{op}失败:{}({error})", path.display()))
}
/// 补一句提示。
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(render(hint));
self
}
/// 补一段**机器可读**的细节(`--json` 里落在 `error.detail`)。
///
/// 约定:`detail` 里只放脚本要分流的结构化字段(底层分类 id、可重试位、目标路径…),
/// 人话仍在 `message` / `hint` 里——两条出口各管一件事,别把散文塞进 `detail`。
#[must_use]
pub fn with_detail(mut self, detail: Value) -> Self {
self.detail = Some(Box::new(detail));
self
}
/// 机器可读细节(没有则为 `None`)。
#[must_use]
pub fn detail(&self) -> Option<&Value> {
self.detail.as_deref()
}
/// 错误大类。
#[must_use]
pub const fn kind(&self) -> ErrorKind {
self.kind
}
/// 稳定错误码。
#[must_use]
pub const fn code(&self) -> &'static str {
self.code
}
/// 人类可读说明。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// 下一步提示。
#[must_use]
pub fn hint(&self) -> Option<&str> {
self.hint.as_deref()
}
/// 进程退出码。
#[must_use]
pub const fn exit_code(&self) -> i32 {
self.kind.exit_code()
}
/// `--json` 里的错误对象。
#[must_use]
pub fn to_json(&self) -> Value {
let mut error = json!({
"code": self.code,
"kind": self.kind.as_str(),
"message": self.message,
});
if let Some(hint) = &self.hint {
error["hint"] = Value::String(hint.clone());
}
if let Some(detail) = &self.detail {
error["detail"] = (**detail).clone();
}
json!({ "ok": false, "error": error })
}
}
impl fmt::Display for CliError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.message)
}
}
impl std::error::Error for CliError {}