按独立复审 `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。
385 lines
12 KiB
Rust
385 lines
12 KiB
Rust
//! 子命令解析:手写解析器的语法契约。
|
||
|
||
#[path = "support/mod.rs"]
|
||
mod support;
|
||
|
||
use unlucky_cli::cli::{parse, AccountsAddKind, Command, Globals};
|
||
use unlucky_cli::error::ErrorKind;
|
||
|
||
fn invoke(args: &[&str]) -> unlucky_cli::cli::Invocation {
|
||
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
|
||
parse(&owned).unwrap_or_else(|error| panic!("解析失败:{error}"))
|
||
}
|
||
|
||
#[test]
|
||
fn no_args_is_welcome() {
|
||
let invocation = invoke(&[]);
|
||
assert_eq!(invocation.command, Command::Welcome);
|
||
assert_eq!(invocation.globals, Globals::default());
|
||
}
|
||
|
||
#[test]
|
||
fn version_flags() {
|
||
assert_eq!(invoke(&["--version"]).command, Command::Version);
|
||
assert_eq!(invoke(&["-V"]).command, Command::Version);
|
||
assert_eq!(invoke(&["version"]).command, Command::Version);
|
||
}
|
||
|
||
#[test]
|
||
fn help_takes_topic_from_positional() {
|
||
assert_eq!(invoke(&["--help"]).command, Command::Help(None));
|
||
assert_eq!(
|
||
invoke(&["--help", "launch"]).command,
|
||
Command::Help(Some("launch".to_string()))
|
||
);
|
||
assert_eq!(
|
||
invoke(&["help", "config"]).command,
|
||
Command::Help(Some("config".to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn globals_are_position_independent() {
|
||
let invocation = invoke(&["instances", "list", "--json", "--quiet"]);
|
||
assert_eq!(invocation.command, Command::InstancesList);
|
||
assert!(invocation.globals.json);
|
||
assert!(invocation.globals.quiet);
|
||
|
||
let before = invoke(&["--json", "instances", "list"]);
|
||
assert!(before.globals.json);
|
||
assert_eq!(before.command, Command::InstancesList);
|
||
}
|
||
|
||
#[test]
|
||
fn data_dir_accepts_both_forms() {
|
||
assert_eq!(
|
||
invoke(&["--data-dir", "/tmp/ull", "instances", "list"])
|
||
.globals
|
||
.data_dir,
|
||
Some(std::path::PathBuf::from("/tmp/ull"))
|
||
);
|
||
assert_eq!(
|
||
invoke(&["instances", "list", "--data-dir=/tmp/ull"])
|
||
.globals
|
||
.data_dir,
|
||
Some(std::path::PathBuf::from("/tmp/ull"))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn install_flags() {
|
||
let Command::Install(args) =
|
||
invoke(&["install", "1.20.1", "--loader", "vanilla", "--name=x"]).command
|
||
else {
|
||
panic!("应为 install");
|
||
};
|
||
assert_eq!(args.version, "1.20.1");
|
||
assert_eq!(args.loader.as_deref(), Some("vanilla"));
|
||
assert_eq!(args.name.as_deref(), Some("x"));
|
||
assert!(!args.dry_run);
|
||
assert!(args.progress, "人类模式默认打实时进度");
|
||
}
|
||
|
||
#[test]
|
||
fn install_loader_version_flag() {
|
||
let Command::Install(args) = invoke(&[
|
||
"install",
|
||
"1.20.1",
|
||
"--loader",
|
||
"fabric",
|
||
"--loader-version",
|
||
"0.15.11",
|
||
])
|
||
.command
|
||
else {
|
||
panic!("应为 install");
|
||
};
|
||
assert_eq!(args.loader.as_deref(), Some("fabric"));
|
||
assert_eq!(args.loader_version.as_deref(), Some("0.15.11"));
|
||
// 不带该 flag 时为 None(由 core 取官方默认版本)。
|
||
let Command::Install(default) = invoke(&["install", "1.20.1"]).command else {
|
||
panic!("应为 install");
|
||
};
|
||
assert!(default.loader_version.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn install_dry_run_flag_and_progress_suppression() {
|
||
let Command::Install(args) = invoke(&["install", "1.20.1", "--dry-run"]).command else {
|
||
panic!("应为 install");
|
||
};
|
||
assert!(args.dry_run);
|
||
assert!(args.progress);
|
||
assert!(!args.json_progress, "人类模式打文本进度");
|
||
|
||
// `--json`:stderr 换成 JSONL 进度(stdout 仍是单个信封)。
|
||
let Command::Install(args) = invoke(&["--json", "install", "1.20.1"]).command else {
|
||
panic!("应为 install");
|
||
};
|
||
assert!(args.progress);
|
||
assert!(args.json_progress);
|
||
|
||
// `--quiet`:一律不打进度。
|
||
let Command::Install(args) = invoke(&["install", "1.20.1", "-q"]).command else {
|
||
panic!("应为 install");
|
||
};
|
||
assert!(!args.progress);
|
||
assert!(!args.dry_run);
|
||
}
|
||
|
||
#[test]
|
||
fn launch_flags_and_defaults() {
|
||
let Command::Launch(args) = invoke(&["launch", "生存服"]).command else {
|
||
panic!("应为 launch");
|
||
};
|
||
assert_eq!(args.instance, "生存服");
|
||
assert!(!args.dry_run);
|
||
assert_eq!(args.java, None);
|
||
assert_eq!(args.max_memory, None);
|
||
|
||
let Command::Launch(args) = invoke(&[
|
||
"launch",
|
||
"生存服",
|
||
"--dry-run",
|
||
"--java",
|
||
"/opt/jdk/bin/java",
|
||
"--username",
|
||
"Steve",
|
||
"--max-memory",
|
||
"4096",
|
||
])
|
||
.command
|
||
else {
|
||
panic!("应为 launch");
|
||
};
|
||
assert!(args.dry_run);
|
||
assert_eq!(args.username.as_deref(), Some("Steve"));
|
||
assert_eq!(args.max_memory, Some(4096));
|
||
}
|
||
|
||
#[test]
|
||
fn accounts_subcommands() {
|
||
assert_eq!(invoke(&["accounts"]).command, Command::AccountsList);
|
||
assert_eq!(invoke(&["accounts", "list"]).command, Command::AccountsList);
|
||
|
||
let Command::AccountsAdd(args) = invoke(&["accounts", "add", "offline", "Steve"]).command
|
||
else {
|
||
panic!("应为 accounts add offline");
|
||
};
|
||
assert_eq!(args.kind, AccountsAddKind::Offline);
|
||
assert_eq!(args.name.as_deref(), Some("Steve"));
|
||
|
||
let Command::AccountsAdd(args) = invoke(&["accounts", "add", "msa"]).command else {
|
||
panic!("应为 accounts add msa");
|
||
};
|
||
assert_eq!(args.kind, AccountsAddKind::Microsoft);
|
||
assert_eq!(args.name, None);
|
||
|
||
let Command::AccountsRemove { name } = invoke(&["accounts", "remove", "Steve"]).command else {
|
||
panic!("应为 accounts remove");
|
||
};
|
||
assert_eq!(name, "Steve");
|
||
}
|
||
|
||
#[test]
|
||
fn launch_account_flag() {
|
||
let Command::Launch(args) = invoke(&["launch", "生存服", "--account", "Steve"]).command
|
||
else {
|
||
panic!("应为 launch");
|
||
};
|
||
assert_eq!(args.account.as_deref(), Some("Steve"));
|
||
assert_eq!(args.username, None);
|
||
}
|
||
|
||
#[test]
|
||
fn logs_and_diag_default_tails() {
|
||
let Command::Logs(args) = invoke(&["logs", "abc"]).command else {
|
||
panic!("应为 logs");
|
||
};
|
||
assert_eq!(args.tail, 200);
|
||
|
||
let Command::Diag(args) = invoke(&["diag", "abc", "--tail", "10"]).command else {
|
||
panic!("应为 diag");
|
||
};
|
||
assert_eq!(args.tail, 10);
|
||
}
|
||
|
||
#[test]
|
||
fn config_get_set_forms() {
|
||
let Command::ConfigGet(args) = invoke(&["config", "get"]).command else {
|
||
panic!("应为 config get");
|
||
};
|
||
assert_eq!(args.key, None);
|
||
assert!(!args.show_secret);
|
||
|
||
let Command::ConfigGet(args) =
|
||
invoke(&["config", "get", "ui.language", "--show-secret"]).command
|
||
else {
|
||
panic!("应为 config get");
|
||
};
|
||
assert_eq!(args.key.as_deref(), Some("ui.language"));
|
||
assert!(args.show_secret);
|
||
|
||
let Command::ConfigSet(args) = invoke(&["config", "set", "ui.language", "en_us"]).command
|
||
else {
|
||
panic!("应为 config set");
|
||
};
|
||
assert_eq!(args.key, "ui.language");
|
||
assert_eq!(args.value, "en_us");
|
||
}
|
||
|
||
#[test]
|
||
fn java_subcommands() {
|
||
assert_eq!(invoke(&["java"]).command, Command::JavaList);
|
||
assert_eq!(invoke(&["java", "list"]).command, Command::JavaList);
|
||
assert_eq!(
|
||
invoke(&["java", "install", "21"]).command,
|
||
Command::JavaInstall {
|
||
major: 21,
|
||
vendor: None
|
||
}
|
||
);
|
||
// D23:`--vendor` 钉死发行版(别名也认),`java vendors` 列出四家。
|
||
assert_eq!(
|
||
invoke(&["java", "install", "21", "--vendor", "oracle"]).command,
|
||
Command::JavaInstall {
|
||
major: 21,
|
||
vendor: Some(unlucky_core::game::java::JdkVendor::Oracle),
|
||
}
|
||
);
|
||
assert_eq!(
|
||
invoke(&["java", "install", "17", "--vendor", "adoptium"]).command,
|
||
Command::JavaInstall {
|
||
major: 17,
|
||
vendor: Some(unlucky_core::game::java::JdkVendor::Temurin),
|
||
}
|
||
);
|
||
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]] = &[
|
||
&["frobnicate"],
|
||
&["install"],
|
||
&["launch"],
|
||
&["launch", "a", "b"],
|
||
&["instances", "list", "--nope"],
|
||
&["install", "1.20.1", "--dry-run=1"],
|
||
&["config", "set", "only-key"],
|
||
&["java", "install", "abc"],
|
||
&["--data-dir"],
|
||
&["sessions", "watch"],
|
||
&["accounts", "add"],
|
||
&["accounts", "add", "offline"],
|
||
&["accounts", "add", "authlib"],
|
||
&["accounts", "remove"],
|
||
&["accounts", "frobnicate"],
|
||
];
|
||
for args in cases {
|
||
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
|
||
let error = parse(&owned).unwrap_err();
|
||
assert_eq!(error.kind(), ErrorKind::User, "args={args:?}");
|
||
assert_eq!(error.exit_code(), 1, "args={args:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn value_flags_do_not_swallow_the_next_flag() {
|
||
// P2-2:漏了值的写法必须在解析期报用法错误,而不是把 `--dry-run` 当成 Java 路径。
|
||
let cases: &[&[&str]] = &[
|
||
&["launch", "x", "--java", "--dry-run"],
|
||
&["launch", "x", "--username", "--json"],
|
||
&["install", "1.20.1", "--name", "--loader", "fabric"],
|
||
&["install", "1.20.1", "--loader", "--name", "x"],
|
||
&["logs", "abc", "--tail", "--json"],
|
||
&["--data-dir", "--json", "instances", "list"],
|
||
];
|
||
for args in cases {
|
||
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
|
||
let error = parse(&owned).expect_err(&format!("args={args:?} 应当报用法错误"));
|
||
assert_eq!(error.code(), "usage", "args={args:?}");
|
||
assert!(
|
||
error.message().contains("需要一个值"),
|
||
"args={args:?} message={}",
|
||
error.message()
|
||
);
|
||
}
|
||
|
||
// 负数值与正常值不受影响(只拒 `--` 开头的 token)。
|
||
let Command::Launch(args) = invoke(&["launch", "x", "--java", "/opt/jdk/bin/java"]).command
|
||
else {
|
||
panic!("应为 launch");
|
||
};
|
||
assert_eq!(
|
||
args.java.as_deref(),
|
||
Some(std::path::Path::new("/opt/jdk/bin/java"))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn help_text_covers_every_command() {
|
||
for topic in [
|
||
"instances",
|
||
"install",
|
||
"launch",
|
||
"sessions",
|
||
"logs",
|
||
"diag",
|
||
"config",
|
||
"java",
|
||
"update",
|
||
] {
|
||
assert!(
|
||
unlucky_cli::cli::help::topic(topic).is_some(),
|
||
"缺少 {topic} 的帮助"
|
||
);
|
||
}
|
||
assert!(unlucky_cli::cli::help::topic("nope").is_none());
|
||
}
|