按独立复审 `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。
199 lines
6.0 KiB
Rust
199 lines
6.0 KiB
Rust
//! 错误形状与退出码。
|
||
//!
|
||
//! 约定(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 {}
|