diff --git a/README.md b/README.md index 534ed1c..2ce85fa 100644 --- a/README.md +++ b/README.md @@ -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 ` when there is. +Pick `ull` when there is no desktop, `unlucky-launcher ` 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 diff --git a/README.zh-CN.md b/README.zh-CN.md index d1f0260..6f4298d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 ·`。 ### 子命令分发 diff --git a/crates/app-iced/src/main.rs b/crates/app-iced/src/main.rs index 1965a58..d6a434a 100644 --- a/crates/app-iced/src/main.rs +++ b/crates/app-iced/src/main.rs @@ -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 ` 只读两张 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 { - 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 { 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 { let options = Options::from_env()?; @@ -111,66 +91,3 @@ fn run_gui() -> Result { } } } - -#[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::>()) - } - - #[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"])); - } -} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 644af3f..03789a8 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -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 { - 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 { 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::>()) - } - - #[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"])); - } -} diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index aee3416..9fa99ce 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -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::>()) + } + + #[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"])); + } +} diff --git a/crates/cli/src/cli/program.rs b/crates/cli/src/cli/program.rs new file mode 100644 index 0000000..c5c230b --- /dev/null +++ b/crates/cli/src/cli/program.rs @@ -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); + } +} diff --git a/crates/cli/src/commands/meta.rs b/crates/cli/src/commands/meta.rs index d49425c..6ad1799 100644 --- a/crates/cli/src/commands/meta.rs +++ b/crates/cli/src/commands/meta.rs @@ -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()) +} diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index 28aee0f..a7f5fed 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -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、可断言的形状);下载/安装这类分钟级任务 diff --git a/crates/cli/tests/output_test.rs b/crates/cli/tests/output_test.rs index a9df87c..862842c 100644 --- a/crates/cli/tests/output_test.rs +++ b/crates/cli/tests/output_test.rs @@ -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"]);