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。
This commit is contained in:
AskaEth committed 2026-11-07 13:03:18 +08:00
1 parent 1c8f6aa1b3
commit 04ac90911e
19 files changed
+521 -112

No files matched your search

+3 -27
View File
@@ -10,10 +10,11 @@ use crate::error::CliError;
mod accounts;
mod agent;
mod instances;
mod update;
use super::model::{
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
LogsArgs, PackImportArgs, UpdateApplyArgs, UpdateCheckArgs,
LogsArgs, PackImportArgs,
};
/// `logs` 缺省尾巴行数。
@@ -93,7 +94,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
"java" => parse_java(tail),
"accounts" => accounts::parse_accounts(tail),
"agent" => agent::parse_agent(tail),
"update" => parse_update(tail),
"update" => update::parse_update(tail),
other => {
Err(CliError::usage(format!("未知命令 `{other}`"))
.with_hint("%prog% --help 查看命令列表"))
@@ -242,31 +243,6 @@ fn parse_config(tail: &[String]) -> Result<Command, CliError> {
}
}
/// `update check` / `update apply [--dest <path>]`。
fn parse_update(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("update 需要子命令 check|apply")
.with_hint("%prog% update check / %prog% update apply"));
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"check" => {
ensure_no_extra(rest, "update check")?;
Ok(Command::UpdateCheck(UpdateCheckArgs {}))
}
"apply" => {
let parsed = parse_flags(rest, &["dest"], &[])?;
ensure_no_positionals(&parsed, "update apply")?;
Ok(Command::UpdateApply(UpdateApplyArgs {
dest: parsed.value("dest").map(PathBuf::from),
}))
}
other => Err(CliError::usage(format!(
"update 的子命令只有 check|apply,收到 `{other}`"
))),
}
}
fn parse_java(tail: &[String]) -> Result<Command, CliError> {
match tail.first().map(String::as_str) {
None | Some("list") => {
+46
View File
@@ -0,0 +1,46 @@
//! `ull update` 子命令的解析(从 `parse.rs` 拆出,避免解析器主文件顶到 400 行)。
//!
//! 语法:
//! - `ull update check`
//! - `ull update apply [--dest <path>]`
//!
//! 两条命令都只是「造一个 `core::update::Job` → 调 `check` / `apply` → 排版输出」,
//! 判定(版本比较、渠道准入、清单校验)与替换(双检 + 原子改名)全在 core。
//! `--dest` 钉替换目标,缺省是本进程的 exe(取证 / 自定义安装位置时才给)。
//!
//! ## 为什么单独一个文件
//!
//! `parse.rs` 在加进这两条之后是 406 行,撞仓库 400 行硬线(`guards.yml` 的 `file-size`
//! 作业按 `git ls-files '*.rs'` + `wc -l > 400` 判红)。拆法与 `parse/{accounts,agent,
//! instances}.rs` 一致:一个子命令族一个文件,主文件只留分发。
use std::path::PathBuf;
use super::{ensure_no_extra, ensure_no_positionals, parse_flags};
use crate::cli::model::{Command, UpdateApplyArgs, UpdateCheckArgs};
use crate::error::CliError;
/// 解析 `update ...`。
pub(super) fn parse_update(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("update 需要子命令 check|apply")
.with_hint("%prog% update check / %prog% update apply"));
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"check" => {
ensure_no_extra(rest, "update check")?;
Ok(Command::UpdateCheck(UpdateCheckArgs {}))
}
"apply" => {
let parsed = parse_flags(rest, &["dest"], &[])?;
ensure_no_positionals(&parsed, "update apply")?;
Ok(Command::UpdateApply(UpdateApplyArgs {
dest: parsed.value("dest").map(PathBuf::from),
}))
}
other => Err(CliError::usage(format!(
"update 的子命令只有 check|apply,收到 `{other}`"
))),
}
}
+16 -1
View File
@@ -48,7 +48,18 @@ fn job(ctx: &Ctx, dest: Option<PathBuf>) -> Job {
}
}
/// 失败 → CLI 错误(保留 core 的稳定分类 id 在 code 里,脚本可分流)。
/// 失败 → CLI 错误(`code` 说「哪条命令的哪一类失败」,`detail.kind` 说**到底为什么**)。
///
/// `--json` 信封示例(复审 P3-3 之后的样子):
///
/// ```json
/// {"ok":false,"error":{"code":"update_apply_failed","kind":"user","message":"应用更新失败(digest_mismatch):…",
/// "hint":"…","detail":{"kind":"digest_mismatch","retryable":true}}}
/// ```
///
/// 为什么要两层:`code` 是命令级的(脚本据此决定「要不要提示用户重跑」),`detail.kind` 是
/// **底层原因**(`digest_mismatch` / `network` / `invalid_manifest` / `io` …,据此决定「重试有没有意义」)。
/// 以前原因只写在人读 message 里,脚本不解析散文就分不出来。
fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
let hint = if error.is_retryable() {
"网络/占用类失败,过一会儿重试一次通常就好"
@@ -59,6 +70,10 @@ fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
code,
format!("{action}失败({}):{error}", error.kind.as_str()),
)
.with_detail(json!({
"kind": error.kind.as_str(),
"retryable": error.is_retryable(),
}))
.with_hint(hint)
}
+33
View File
@@ -54,6 +54,17 @@ pub struct CliError {
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)。
@@ -73,6 +84,7 @@ impl CliError {
code: "usage",
message: render(message),
hint: None,
detail: None,
}
}
@@ -84,6 +96,7 @@ impl CliError {
code,
message: render(message),
hint: None,
detail: None,
}
}
@@ -95,6 +108,7 @@ impl CliError {
code,
message: render(message),
hint: None,
detail: None,
}
}
@@ -111,6 +125,22 @@ impl CliError {
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 {
@@ -152,6 +182,9 @@ impl CliError {
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 })
}
}
+46
View File
@@ -257,6 +257,51 @@ fn java_subcommands() {
assert_eq!(invoke(&["java", "vendors"]).command, Command::JavaVendors);
}
#[test]
fn update_subcommands() {
use unlucky_cli::cli::{UpdateApplyArgs, UpdateCheckArgs};
assert_eq!(
invoke(&["update", "check"]).command,
Command::UpdateCheck(UpdateCheckArgs {})
);
assert_eq!(
invoke(&["update", "apply"]).command,
Command::UpdateApply(UpdateApplyArgs { dest: None })
);
// `--dest` 两种写法都要认(钉替换目标 = 取证 / 自定义安装位置)
for args in [
vec!["update", "apply", "--dest", "D:/tmp/ull.exe"],
vec!["update", "apply", "--dest=D:/tmp/ull.exe"],
] {
assert_eq!(
invoke(&args).command,
Command::UpdateApply(UpdateApplyArgs {
dest: Some(std::path::PathBuf::from("D:/tmp/ull.exe")),
})
);
}
}
/// `update` 的用法错误:没子命令 / 未知子命令 / 多余位置参数 / 未知选项——四条都是 `usage`
/// (退出码 1),不许静默退化成某个默认动作。
#[test]
fn update_usage_errors() {
for args in [
vec!["update"],
vec!["update", "install"],
vec!["update", "check", "extra"],
vec!["update", "apply", "extra"],
vec!["update", "apply", "--dest"],
vec!["update", "apply", "--nope"],
] {
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
let error = parse(&owned).expect_err(&format!("{args:?} 应当报用法错误"));
assert_eq!(error.kind(), ErrorKind::User, "{args:?}");
assert_eq!(error.code(), "usage", "{args:?}");
}
}
#[test]
fn usage_errors_are_user_errors() {
let cases: &[&[&str]] = &[
@@ -328,6 +373,7 @@ fn help_text_covers_every_command() {
"diag",
"config",
"java",
"update",
] {
assert!(
unlucky_cli::cli::help::topic(topic).is_some(),