refactor(cli): 判据收敛到 unlucky_cli::cli,banner 按被调用的二进制名自称(M82 复审)

去重:`looks_like_cli(args) -> bool` 落在 `unlucky_cli::cli`,两个 GUI 的 main.rs 只调用它,
判据从两份变一份。cli crate 里**不**出现 GUI 的 flag 表——语义仍是「首个 token 是裸词就接管;
首个 token 是 flag 时只有解析出真命令才接管,解析失败不接管」。原来两个 main.rs 里的 5+5 条
单测合并成 cli crate 的一份(用例集按复审要求原样搬,含 --screenshot/--renderer/--diff/
--mock/--force-state/--scale-factor、--help/-h/--version/-V、10 个已知子命令、--json 前置、
未知子命令),两个 main.rs 的旧测试已删除。

banner:新增 cli::program 模块,从 argv[0] 的 basename 取程序名(剥目录、剥 .exe、空值回落
ull),把帮助/版本/错误提示里独立出现的 `ull` 换成它——`unlucky-launcher --version` 不再自称
`ull`,也不会把人指去敲 `ull --help`。`run()` 签名未改;`ull` 自己跑时输出逐字不变(替换是
恒等),打包脚本的 `ull --version` 冒烟不受影响。
This commit is contained in:
AskaEth committed 2026-11-02 08:08:12 +08:00
1 parent fecff989e7
commit c9e6236a87
9 files changed
+275 -190

No files matched your search

+92
View File
@@ -9,9 +9,101 @@
pub mod help;
pub mod model;
pub mod parse;
pub mod program;
pub use model::{
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs,
};
pub use parse::parse;
/// 这条命令行是 CLI 调用,还是「不该由 CLI 接管」(GUI 启动 / 只有全局选项)?
///
/// GUI 二进制(`unlucky-launcher` / `unlucky-launcher-iced`)用它做分发判据,`ull` 自己的
/// `main` 不用——**判据只有这一处**,两个 GUI 各调一次,改语义不会漂。
///
/// 规则(刻意不列 GUI 的 flag 表:cli crate 不该知道 GUI 有什么参数):
///
/// - 首个 token 是裸词(不以 `-` 开头)→ 一定在子命令位置(GUI 不接受位置参数,`--diff` /
/// `--screenshot` 的取值都排在 flag 之后)→ 接管。未知子命令也在这里,由 CLI 自己报错。
/// - 首个 token 是 flag → 只有当 [`parse`] 解析出一条**真命令**时才算 CLI 调用:`--help` /
/// `--version` / `--json instances list` 接管;`--data-dir X` 解析成 [`Command::Welcome`]
/// (没有子命令)、GUI 专属参数(`--screenshot` / `--renderer` / `--diff` …)根本解析失败,
/// 两种情况都不接管。**「解析失败」必须留在调用方**:`--screenshot out.png` 也会让本解析器
/// 报错,把它判成 CLI 就会让 GUI 的截图通道失效。
#[must_use]
pub fn looks_like_cli(args: &[String]) -> bool {
match args.first() {
None => false,
Some(first) if !first.starts_with('-') => true,
Some(_) => match parse(args) {
Ok(invocation) => !matches!(invocation.command, Command::Welcome),
Err(_) => false,
},
}
}
#[cfg(test)]
mod tests {
use super::looks_like_cli;
fn cli(args: &[&str]) -> bool {
looks_like_cli(&args.iter().map(|arg| (*arg).to_owned()).collect::<Vec<_>>())
}
#[test]
fn no_args_is_not_a_cli_call() {
assert!(!cli(&[]));
}
#[test]
fn gui_flags_are_never_taken_over() {
// CI 与验收脚本在用这些参数,一条都不能被当成子命令吃掉。
assert!(!cli(&["--software-renderer"]));
assert!(!cli(&["--screenshot", "out.png", "--data-dir", "/d/tmp/x"]));
assert!(!cli(&["--screenshot-theme", "dark"]));
assert!(!cli(&["--renderer=gpu", "--theme=dark"]));
assert!(!cli(&["--scale-factor", "1.0"]));
assert!(!cli(&["--force-state", "hover"]));
assert!(!cli(&["--diff", "a.png", "b.png"]));
assert!(!cli(&["--mock"]));
// 只有 --data-dir 时解析成 Welcome(没有子命令),仍是 GUI 启动。
assert!(!cli(&["--data-dir", "/d/tmp/x"]));
}
#[test]
fn known_subcommands_are_taken_over() {
for args in [
vec!["instances", "list"],
vec!["install", "1.20.1", "--dry-run"],
vec!["launch", "1.20.1", "--dry-run"],
vec!["accounts", "list"],
vec!["sessions", "list"],
vec!["logs", "abc", "--tail", "10"],
vec!["diag", "abc"],
vec!["config", "get"],
vec!["java", "list"],
vec!["agent", "config"],
// 全局选项可以排在子命令前面
vec!["--json", "instances", "list"],
] {
assert!(cli(&args), "{args:?} 应该交给 CLI");
}
}
#[test]
fn help_and_version_are_taken_over() {
assert!(cli(&["--version"]));
assert!(cli(&["--help"]));
assert!(cli(&["-h"]));
assert!(cli(&["-V"]));
}
#[test]
fn unknown_subcommands_are_left_to_the_cli_to_reject() {
// 语义定案(README 两版同款):未知子命令 → CLI 报「未知命令 + <名字> --help」并以 1 退出,
// 不静默落到 GUI(否则脚本用户会以为参数生效了)。
assert!(cli(&["lst"]));
assert!(cli(&["intsances", "list"]));
}
}
+131
View File
@@ -0,0 +1,131 @@
//! 程序名:CLI 输出里自称什么,取决于**被调用的那个二进制**叫什么。
//!
//! GUI 二进制(`unlucky-launcher`)与 `ull` 共用同一套命令行(M82),所以 banner / 用法行 /
//! 「`<名字> --help`」这类提示不能写死 `ull` —— 用户敲的是 `unlucky-launcher`,把他指去敲
//! 一个可能不存在的名字只会让人困惑。
//!
//! 取不到(argv[0] 为空,某些 exec 场景)就回落 [`FALLBACK`],输出因此与老版本逐字相同。
/// 取不到 argv[0] 时的回落名(也是 `crates/cli` 产出的那个 bin)。
pub const FALLBACK: &str = "ull";
/// 从 argv[0] 求程序名:剥目录、剥 `.exe`、空值回落。
///
/// Windows 上是 `C:\…\unlucky-launcher.exe`,unix 上是 `./target/release/unlucky-launcher`,
/// 两种都要得到 `unlucky-launcher`。
#[must_use]
pub fn name_in(argv0: Option<&str>) -> String {
let Some(raw) = argv0 else {
return FALLBACK.to_owned();
};
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
let base = strip_exe(base);
if base.is_empty() {
FALLBACK.to_owned()
} else {
base.to_owned()
}
}
fn strip_exe(name: &str) -> &str {
match name
.strip_suffix(".exe")
.or_else(|| name.strip_suffix(".EXE"))
{
Some(stem) => stem,
None => name,
}
}
/// 当前进程的程序名。
#[must_use]
pub fn name() -> String {
let argv0 = std::env::args_os()
.next()
.map(|arg| arg.to_string_lossy().into_owned());
name_in(argv0.as_deref())
}
/// 把文本里**独立出现**的 `ull` 换成程序名;`program` 就是 `ull` 时原样返回。
///
/// 只换两侧都不是 ASCII 字母数字的 `ull`:`ull --help` 里的换,`null` / `pull` 里的不换。
/// 帮助文案因此不用为了改名重写成动态字符串。
#[must_use]
pub fn rename(text: &str, program: &str) -> String {
if program == FALLBACK || !text.contains(FALLBACK) {
return text.to_owned();
}
let mut out = String::with_capacity(text.len() + program.len());
let mut copied = 0;
let mut index = 0;
while let Some(found) = text[index..].find(FALLBACK) {
let start = index + found;
let end = start + FALLBACK.len();
let before_ok = !text[..start]
.chars()
.next_back()
.is_some_and(|ch| ch.is_ascii_alphanumeric());
let after_ok = !text[end..]
.chars()
.next()
.is_some_and(|ch| ch.is_ascii_alphanumeric());
if before_ok && after_ok {
out.push_str(&text[copied..start]);
out.push_str(program);
copied = end;
}
index = end;
}
out.push_str(&text[copied..]);
out
}
#[cfg(test)]
mod tests {
use super::{name_in, rename, FALLBACK};
#[test]
fn argv0_is_reduced_to_a_bare_name() {
assert_eq!(
name_in(Some(r"C:\Program Files\ULL\unlucky-launcher.exe")),
"unlucky-launcher"
);
assert_eq!(
name_in(Some("./target/release/unlucky-launcher")),
"unlucky-launcher"
);
assert_eq!(name_in(Some("ull")), "ull");
assert_eq!(name_in(Some("/usr/local/bin/ull.exe")), "ull");
}
#[test]
fn empty_or_missing_argv0_falls_back() {
assert_eq!(name_in(None), FALLBACK);
assert_eq!(name_in(Some("")), FALLBACK);
assert_eq!(name_in(Some("/usr/bin/")), FALLBACK);
}
#[test]
fn rename_only_touches_standalone_occurrences() {
assert_eq!(
rename("ull --help 查看命令列表", "unlucky-launcher"),
"unlucky-launcher --help 查看命令列表"
);
// 别的词里的 ull 不动(防止把 null/pull 改成 n/p + 名字)
assert_eq!(
rename("value is null, pull it", "unlucky-launcher"),
"value is null, pull it"
);
// 中文紧邻不算字母数字,照换
assert_eq!(
rename("用法:ull [全局选项]", "unlucky-launcher"),
"用法:unlucky-launcher [全局选项]"
);
}
#[test]
fn rename_is_identity_for_ull() {
let text = "ull --help";
assert_eq!(rename(text, FALLBACK), text);
}
}
+17 -6
View File
@@ -2,7 +2,7 @@
use serde_json::json;
use crate::cli::help;
use crate::cli::{help, program};
use crate::os;
use crate::output::Report;
@@ -12,18 +12,20 @@ pub const LAUNCHER_NAME: &str = "UnluckyLauncher";
/// 版本 + 系统摘要(`ull --version`,D19 §5-2 要求附带 os 摘要)。
///
/// 探测走 `core::platform::os_info::current()`(进程内缓存),未达基线时附带升级指引。
/// 自称的名字取 argv[0](M82):GUI 二进制调进来时是 `unlucky-launcher`,`ull` 自己跑时仍是 `ull`。
#[must_use]
pub fn version() -> Report {
let info = os::detect();
let invoked_as = program::name();
let data = json!({
"name": "ull",
"name": invoked_as,
"package": env!("CARGO_PKG_NAME"),
"version": env!("CARGO_PKG_VERSION"),
"os": os::to_json(&info),
});
let mut human = vec![
format!(
"ull {}({})",
"{invoked_as} {}({})",
env!("CARGO_PKG_VERSION"),
env!("CARGO_PKG_NAME")
),
@@ -38,20 +40,29 @@ pub fn version() -> Report {
pub fn welcome() -> Report {
let version_report = version();
let mut human = version_report.human;
let text = general_help();
human.push(String::new());
human.extend(help::general().lines().map(str::to_string));
human.extend(text.lines().map(str::to_string));
let mut data = version_report.data;
data["help"] = json!(help::general());
data["help"] = json!(text);
Report::new(data, human)
}
/// 帮助(`ull help <命令>` 给该命令,未知主题退回总帮助)。
#[must_use]
pub fn help(topic: Option<&str>) -> Report {
let text = topic.and_then(help::topic).unwrap_or_else(help::general);
let text = match topic.and_then(help::topic) {
Some(text) => program::rename(text, &program::name()),
None => general_help(),
};
let data = json!({
"topic": topic,
"text": text,
});
Report::new(data, text.lines().map(str::to_string).collect())
}
/// 总帮助,按被调用的二进制名自述(`ull` 自己跑时逐字不变)。
fn general_help() -> String {
program::rename(help::general(), &program::name())
}
+7 -2
View File
@@ -101,13 +101,18 @@ pub fn print_error(
writeln!(out, "{}", serde_json::to_string_pretty(&error.to_json())?)?;
return Ok(());
}
writeln!(err, "错误: {}", error.message())?;
writeln!(err, "错误: {}", rename_program(error.message()))?;
if let Some(hint) = error.hint() {
writeln!(err, "提示: {hint}")?;
writeln!(err, "提示: {}", rename_program(hint))?;
}
Ok(())
}
/// 文案里的 `ull` 按被调用的二进制名显示(M82):`unlucky-launcher lst` 不该把人指去敲 `ull`。
fn rename_program(text: &str) -> String {
crate::cli::program::rename(text, &crate::cli::program::name())
}
/// 长任务的实时进度行(**只进 stderr**,不参与 [`Report`])。
///
/// 命令的正经输出仍然全部走 [`Report`](stdout、可断言的形状);下载/安装这类分钟级任务
+10 -2
View File
@@ -11,7 +11,13 @@ fn version_json_has_os_summary() {
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["name"], "ull");
// M82:自称的名字跟着被调用的二进制走(test harness 里是测试二进制名,`ull` 里是 `ull`);
// argv[0] → 名字的映射由 cli::program 的单测钉死。
assert_eq!(
value["data"]["name"],
unlucky_cli::cli::program::name(),
"{stdout}"
);
assert_eq!(value["data"]["version"], env!("CARGO_PKG_VERSION"));
// D19/D21:os 对象来自 core::platform::os_info(family + 两档 support)。
assert!(value["data"]["os"]["family"].is_string());
@@ -23,7 +29,9 @@ fn version_json_has_os_summary() {
fn welcome_without_subcommand_prints_version_and_help() {
let (code, stdout, _) = run_cli(&[]);
assert_eq!(code, 0);
assert!(stdout.contains("ull "), "{stdout}");
// banner 用被调用的二进制名(M82),不写死 `ull`。
let banner = format!("{} ", unlucky_cli::cli::program::name());
assert!(stdout.starts_with(&banner), "{stdout}");
assert!(stdout.contains("用法:"), "{stdout}");
let (code, stdout, _) = run_cli(&["--json"]);