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

+3 -1
View File
@@ -82,7 +82,9 @@ Binaries in the workspace, so you can tell them apart:
`unlucky-launcher` and `ull` are not duplicates. The GUI binary needs the graphics stack when it
opens a window (it `dlopen`s X11 / Wayland / GL, and links `libfontconfig` at load); `ull` needs
nothing beyond glibc, so it runs on a server, a CI container or Termux where no window can exist.
Pick `ull` when there is no desktop, `unlucky-launcher <command>` when there is.
Pick `ull` when there is no desktop, `unlucky-launcher <command>` when there is. Both print the same
CLI text and name themselves after the binary you invoked: `unlucky-launcher --help` starts with
`unlucky-launcher ·`, `ull --help` starts with `ull ·`.
### Subcommand dispatch
+3 -1
View File
@@ -72,7 +72,9 @@ Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java
`unlucky-launcher` 与 `ull` 不是重复品:开窗时 GUI 二进制要用图形栈(X11 / Wayland / GL 走
`dlopen`,启动时直接链接 `libfontconfig`);`ull` 除了 glibc 什么都不需要,所以在没有窗口的服务器、
CI 容器、Termux 上跑得起来。没有桌面就用 `ull`,有桌面就用 `unlucky-launcher <子命令>`。
CI 容器、Termux 上跑得起来。没有桌面就用 `ull`,有桌面就用 `unlucky-launcher <子命令>`。两者打出来的
CLI 文案是同一套,自称的名字跟着**你敲的那个二进制**走:`unlucky-launcher --help` 首行是
`unlucky-launcher ·`,`ull --help` 首行是 `ull ·`。
### 子命令分发
+6 -89
View File
@@ -5,7 +5,8 @@
//! - 被子命令命中(`instances` / `install` / `launch` / `accounts` / `sessions` / `logs` /
//! `diag` / `config` / `java` / `agent`,或 `--help` / `--version`)→ 直接转给
//! `unlucky_cli::run`,**不初始化任何 UI**(不 probe 后端、不扫字体、不碰图形库)。
//! 子命令表不在这里抄一份:唯一真相源是 `crates/cli` 的解析器(见 [`is_cli_invocation`])。
//! 子命令表不在这里抄一份:唯一真相源是 `crates/cli` 的解析器
//! (`unlucky_cli::cli::looks_like_cli`,本文件只调用)。
//! - 其余参数(`--software-renderer` / `--renderer` / `--theme` / `--screenshot*` /
//! `--diff` …):开窗跑骨架,`--screenshot` 出图后退出;`--diff <a> <b>` 只读两张 PNG
//! 做逐像素对比(D30 的 GPU/CPU 几何一致性检查),不开窗。
@@ -45,8 +46,11 @@ fn main() -> ExitCode {
/// 这条路径上**不构造窗口、不探后端、不扫字体、不依赖任何图形库**(这些库缺失时同一条命令照样
/// 跑完,实测见 packaging/README.md §7.4)。`unlucky-launcher-iced launch …` 的耗时因此与首帧无关——
/// 这句是结构性判断,**没有重量**(原因见 §10-8)。
///
/// 「这条命令行算不算 CLI 调用」的判据不在这里重复:`unlucky_cli::cli::looks_like_cli` 是唯一
/// 一处(M82 复审去重),两个 GUI 二进制都只调它,改语义不会漂。
fn run_cli_if_requested(args: &[String]) -> Option<ExitCode> {
if !is_cli_invocation(args) {
if !unlucky_cli::cli::looks_like_cli(args) {
return None;
}
let mut stdout = std::io::stdout();
@@ -57,30 +61,6 @@ fn run_cli_if_requested(args: &[String]) -> Option<ExitCode> {
Some(ExitCode::from(u8::try_from(code).unwrap_or(2)))
}
/// 这条命令行是 CLI 调用还是 GUI 启动?
///
/// 判据只有一条,**子命令表不在这里重复**:`crates/cli` 的解析器就是唯一真相源,
/// 所以新增子命令不需要动这个文件(Slint 版 `crates/app/src/main.rs` 是同一份判据)。
///
/// - 首个 token 是裸词(不以 `-` 开头)→ 一定是子命令位置:GUI 不接受位置参数,
/// 而 `--diff` / `--screenshot` 的取值都在 flag 之后。未知子命令也走这条路,
/// 由 CLI 自己报「未知命令 + `--help`」并以 1 退出(静默落到 GUI 会让脚本用户困惑)。
/// - 首个 token 是 flag → 只有 CLI 自己解析出一条真命令才算 CLI 调用:`--help` / `--version` /
/// `--json instances list` 走 CLI;`--data-dir X` 解析成 `Welcome`(没有子命令)、
/// `--renderer` / `--screenshot` / `--diff` 等 GUI 参数干脆解析失败,一律仍走 GUI。
/// 注意「解析失败」在这里是 GUI,不是 CLI:`--screenshot out.png` 也会解析失败,把它判成
/// CLI 就会把截图脚本打死(这是本函数据以正确的那条边界)。
fn is_cli_invocation(args: &[String]) -> bool {
match args.first() {
None => false,
Some(first) if !first.starts_with('-') => true,
Some(_) => match unlucky_cli::cli::parse(args) {
Ok(invocation) => !matches!(invocation.command, unlucky_cli::cli::Command::Welcome),
Err(_) => false,
},
}
}
/// 开窗路径:解析 GUI 参数 → (`--diff` 走比对)→ 起 UI。
fn run_gui() -> Result<ExitCode, String> {
let options = Options::from_env()?;
@@ -111,66 +91,3 @@ fn run_gui() -> Result<ExitCode, String> {
}
}
}
#[cfg(test)]
mod tests {
use super::is_cli_invocation;
fn cli(args: &[&str]) -> bool {
is_cli_invocation(&args.iter().map(|arg| (*arg).to_owned()).collect::<Vec<_>>())
}
#[test]
fn no_args_starts_the_gui() {
assert!(!cli(&[]));
}
#[test]
fn gui_flags_keep_working() {
// CI 与验收脚本在用这些参数,一条都不能被当成子命令吃掉。
assert!(!cli(&["--software-renderer"]));
assert!(!cli(&["--screenshot", "out.png", "--data-dir", "/d/tmp/x"]));
assert!(!cli(&["--renderer=gpu", "--theme=dark"]));
assert!(!cli(&["--scale-factor", "1.0"]));
assert!(!cli(&["--force-state", "hover"]));
assert!(!cli(&["--diff", "a.png", "b.png"]));
// 只有 --data-dir 时 CLI 解析成 Welcome(没有子命令),仍是 GUI 启动。
assert!(!cli(&["--data-dir", "/d/tmp/x"]));
}
#[test]
fn known_subcommands_go_to_the_cli() {
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_cli_too() {
// iced 版以前不认 --version(会直接开窗),现在由 CLI 报版本并退出。
assert!(cli(&["--version"]));
assert!(cli(&["--help"]));
assert!(cli(&["-h"]));
}
#[test]
fn unknown_subcommand_is_left_to_the_cli_to_reject() {
// 语义定案(README 两版同款):未知子命令 → CLI 报「未知命令 + ull --help」并以 1 退出,
// 不静默落到 GUI(否则脚本用户会以为参数生效了)。
assert!(cli(&["lst"]));
assert!(cli(&["intsances", "list"]));
}
}
+6 -89
View File
@@ -16,7 +16,8 @@
//! 一个二进制两种用法(M82):`unlucky-launcher` 不带子命令时起 GUI;带子命令
//! (`instances` / `install` / `launch` / `accounts` / `sessions` / `logs` / `diag` /
//! `config` / `java` / `agent`)时直接转给 `unlucky_cli::run`,**不初始化任何 UI**。
//! 子命令表不在这里抄一份——唯一真相源是 `crates/cli` 的解析器(见 [`is_cli_invocation`])。
//! 子命令表不在这里抄一份——唯一真相源是 `crates/cli` 的解析器(见
//! `unlucky_cli::cli::looks_like_cli`)。
//! 零 UI 依赖的 `ull` 仍然保留:它连 fontconfig 都不需要,给服务器 / CI / Termux 用。
//!
//! 架构分层(本文件只有一个职责:分发 + 把窗口拉起来):
@@ -101,8 +102,11 @@ fn run_gui() -> Result<(), slint::PlatformError> {
/// 这条路径上**不构造窗口、不探后端、不扫字体、不依赖任何图形库**(这些库缺失时同一条命令照样
/// 跑完,实测见 packaging/README.md §7.4)。`unlucky-launcher launch …` 的耗时因此与首帧无关——
/// 这句是结构性判断,**没有重量**(原因见 §10-8)。
///
/// 「这条命令行算不算 CLI 调用」的判据不在这里重复:`unlucky_cli::cli::looks_like_cli` 是唯一
/// 一处(M82 复审去重),两个 GUI 二进制都只调它,改语义不会漂。
fn run_cli_if_requested(args: &[String]) -> Option<ExitCode> {
if !is_cli_invocation(args) {
if !unlucky_cli::cli::looks_like_cli(args) {
return None;
}
let mut stdout = std::io::stdout();
@@ -113,30 +117,6 @@ fn run_cli_if_requested(args: &[String]) -> Option<ExitCode> {
Some(ExitCode::from(u8::try_from(code).unwrap_or(2)))
}
/// 这条命令行是 CLI 调用还是 GUI 启动?
///
/// 判据只有一条,**子命令表不在这里重复**:`crates/cli` 的解析器就是唯一真相源,
/// 所以新增子命令不需要动这个文件(iced 版 `crates/app-iced/src/main.rs` 是同一份判据)。
///
/// - 首个 token 是裸词(不以 `-` 开头)→ 一定是子命令位置:GUI 不接受位置参数,
/// 而 `--diff` / `--screenshot` 的取值都在 flag 之后。未知子命令也走这条路,
/// 由 CLI 自己报「未知命令 + `--help`」并以 1 退出(静默落到 GUI 会让脚本用户困惑)。
/// - 首个 token 是 flag → 只有 CLI 自己解析出一条真命令才算 CLI 调用:`--help` / `--version` /
/// `--json instances list` 走 CLI;`--data-dir X` 解析成 `Welcome`(没有子命令)、
/// `--software-renderer` / `--screenshot` / `--diff` 等 GUI 参数干脆解析失败,一律仍走 GUI。
/// 注意「解析失败」在这里是 GUI,不是 CLI:`--screenshot out.png` 也会解析失败,把它判成
/// CLI 就会把截图脚本打死(这是本函数据以正确的那条边界)。
fn is_cli_invocation(args: &[String]) -> bool {
match args.first() {
None => false,
Some(first) if !first.starts_with('-') => true,
Some(_) => match unlucky_cli::cli::parse(args) {
Ok(invocation) => !matches!(invocation.command, unlucky_cli::cli::Command::Welcome),
Err(_) => false,
},
}
}
/// Win7 老机 / 无可用 OpenGL 驱动时的软渲染兜底开关(01c §6 的 Slint 核心卖点)。
fn apply_renderer_flag() {
let software = std::env::args()
@@ -146,66 +126,3 @@ fn apply_renderer_flag() {
std::env::set_var("SLINT_BACKEND", "winit-software");
}
}
#[cfg(test)]
mod tests {
use super::is_cli_invocation;
fn cli(args: &[&str]) -> bool {
is_cli_invocation(&args.iter().map(|arg| (*arg).to_owned()).collect::<Vec<_>>())
}
#[test]
fn no_args_starts_the_gui() {
assert!(!cli(&[]));
}
#[test]
fn gui_flags_keep_working() {
// 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(&["--diff", "a.png", "b.png"]));
assert!(!cli(&["--mock"]));
// 只有 --data-dir 时 CLI 解析成 Welcome(没有子命令),仍是 GUI 启动。
assert!(!cli(&["--data-dir", "/d/tmp/x"]));
}
#[test]
fn known_subcommands_go_to_the_cli() {
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_cli_too() {
// GUI 以前不认 --version(会直接开窗),现在由 CLI 报版本并退出。
assert!(cli(&["--version"]));
assert!(cli(&["--help"]));
assert!(cli(&["-h"]));
}
#[test]
fn unknown_subcommand_is_left_to_the_cli_to_reject() {
// 语义定案(README 两版同款):未知子命令 → CLI 报「未知命令 + ull --help」并以 1 退出,
// 不静默落到 GUI(否则脚本用户会以为参数生效了)。
assert!(cli(&["lst"]));
assert!(cli(&["intsances", "list"]));
}
}
+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"]);