release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run

本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
AskaEth committed 2026-11-11 21:41:38 +08:00
commit a5d1dec495
1580 files changed
+320938

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
[package]
name = "unlucky-cli"
description = "Unlucky-Launcher 命令行(bin `ull`):headless 消费者,零 UI 框架依赖(D18)"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
# 产物名钉死 `ull`(D18 定案):单文件 CLI,与 GUI 的 `unlucky-launcher` 并列。
[[bin]]
name = "ull"
path = "src/main.rs"
[lib]
name = "unlucky_cli"
path = "src/lib.rs"
# 依赖纪律(10-cli-design.md §2):core + agent,**不引 Slint**。
# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线,
# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。
[dependencies]
unlucky-core = { path = "../core", version = "0.2.4" }
# agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影,
# core 侧五个端口在 src/commands/agent/ports/ 装配)。
unlucky-agent = { path = "../agent", version = "0.2.4" }
# `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
[lints]
workspace = true
[features]
# M169:转发 core 的测试通道(`--features update-test-source` 才编进来,默认关)。
# `ull update check/apply` 的端到端验收要靠它指向本地目录源。
update-test-source = ["unlucky-core/update-test-source"]
# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。
# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回),
# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。
[build-dependencies]
winresource = "0.1"
+10
View File
@@ -0,0 +1,10 @@
//! `ull` 的 PE 版本资源(M190)。
//!
//! 只做一件事:调共享口径 [`emit_pe_version_resource`](定义在 `packaging/pe_version.rs`,
//! 两个 bin 共用同一份署名表)。非 windows 目标里那个函数会立刻返回,Linux/macOS 构建零影响。
include!("../../packaging/pe_version.rs");
fn main() {
emit_pe_version_resource("ull");
}
+287
View File
@@ -0,0 +1,287 @@
//! 帮助文案(`ull --help` / `ull help <命令>`)。
//!
//! 命令集与 `10-cli-design.md` §3 对齐;`agent serve|config`(M39 的 MCP server)已列,
//! `agent ask`(内置对话的 headless 形态)仍未列。
/// 总帮助。
#[must_use]
pub const fn general() -> &'static str {
"\
%prog% · Unlucky-Launcher 命令行(D18)
用法:
%prog% [全局选项] <命令> [参数]
命令:
instances list 列出实例与已安装版本
instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P]
[--backend default|opengl|vulkan] [--clear]
读/写实例级启动参数(不带选项 = 只读;--clear = 全部回跟随全局)
instances scan 列出 versions/ 里已铺好的版本(含别的启动器装的):能不能登记 + 原因
instances adopt <version-id> [--name N] [--isolation shared|isolated|custom] [--game-dir DIR]
[--dry-run] 把已铺好的版本登记成实例(只读外部、不搬文件;缺省运行目录
落 ULL 数据区的独立目录;--dry-run 只检查不写盘)
install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
下载并安装版本本体(--loader fabric|quilt|neoforge|forge|
liteloader 时再叠一层加载器)→ 登记实例;--dry-run 只出计划
launch <instance> [--dry-run] [--account <name>] [--java <path>] [--username <name>]
[--max-memory <MB>] 启动实例;--dry-run 只打印组装后的命令行
pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]
导入整合包(CF manifest.json / Modrinth .mrpack /
MultiMC·Prism 实例)→ 落内容 → 登记实例;
缺文件降级为「部分成功」(退出码 3)
accounts list 列出账户(名称 / 轨道 / UUID,**不含凭据**)
accounts add offline <name> 添加离线账户(HMCL 兼容 UUID)
accounts add msa 添加微软账户(设备码交互式登录)
accounts remove <name> 删除账户(连同凭据)
sessions list [--limit N] 列出会话记录(倒序)
logs <sid> [--tail N] 打印会话事件流末尾
diag <sid> [--tail N] 对会话跑规则引擎诊断
config get [<key>] [--show-secret] 读配置(生效值;无 key 时输出整份)
config set <key> <value> 写配置(走 setting schema 校验)
java list 列出可用 Java(含每个 JDK 的实际落点)
java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版;
--vendor temurin|oracle|microsoft|zulu|termux 钉死一家)
java vendors 列出各发行版的镜像/支持版本/当前优先级
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
agent config [--port N] 打印 Claude Code / Cursor 的配置样例
update check 查一次更新(只读:不动任何文件;失败退出码 1)
update apply [--dest <path>] 下载 → SHA256 校验 → 替换(缺省替换本进程 exe)
version 版本 + 系统摘要(同 --version)
help [命令] 本帮助
全局选项:
--json 机器可读输出:成功 {\"ok\":true,\"data\":…},失败 {\"ok\":false,\"error\":…}
--data-dir P 覆盖数据根(等价 custom 模式的根目录,即含 .ULL/ 与 .minecraft/ 的父目录)
--no-color 关闭颜色(v1 输出不含 ANSI,参数保留以兼容脚本)
--quiet 静默人类可读输出(错误与提示仍进 stderr)
--help, -h 帮助(有位置参数时按该命令给帮助)
--version, -V 版本 + 系统摘要
退出码: 0 成功 / 1 用户错误 / 2 内部错误 / 3 部分成功(仅 pack import)"
}
/// 单个命令的帮助;未知主题返回 `None`(调用方退回总帮助)。
#[must_use]
pub fn topic(name: &str) -> Option<&'static str> {
match name {
"instances" => Some(
"\
%prog% instances list
列出 profiles.json 里的实例与 versions/ 下已安装的版本。
--json 输出 {\"data\":{\"instances\":[…],\"installed_versions\":[…],\"data_root\":…}}
每行实例带 loader:{\"kind\":…,\"version\":…},由 version_id 解析(core::game::loader 这条唯一入口):
kind vanilla | fabric | quilt | forge | neoforge | optifine | liteloader;
原版恒为 {\"kind\":\"vanilla\",\"version\":\"\"}(键恒存在、不为 null,脚本不必判空)
version id 里的加载器版本段;原版、以及 id 里没写版本的(1.12.2-LiteLoader)为空串
注意:kind 是「认得出」的口径,不是「装得了」的口径 —— optifine 只能由别的启动器装好后
继承进来,install --loader 不接受它;liteloader 自 M222 起**可以**装
(install --loader liteloader)。kind=vanilla 只表示 id 里没有加载器痕迹(整包导入被
重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。
%prog% instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P] \
[--backend default|opengl|vulkan] [--clear]
读/写实例配置页「启动参数」区(M157)。不带取写选项时只读:
--jvm <text> 实例级 JVM 参数(空白分隔、支持引号);与全局 java.extra_args **叠加**,
实例这层在最高优先级段(同名选项实例赢,`-Xmx` 也由它赢过内存字段)
--game <text> 实例级游戏参数(追加在命令行最后,可覆盖前面一切游戏参数)
--memory <MB> 实例级最大堆(256~65536);留空/不写 = 跟随全局 java.default_memory_mb
--java <path> 实例级 Java 可执行文件;留空 = 跟随全局 java.selected_path 再退到自动挑
--backend <id> default(不下发)/ opengl / vulkan;只对 MC 26.2-snapshot-2+ 下发,
老版本会被版本门槛拦下(启动时给诊断告警,不静默)
--clear 先把五项清空再应用其它选项(= 全部回「跟随全局」)
每一项都是「实例 → 全局 → 默认」的独立回落,只填一项不会顶掉别的项。
值以 `--` 开头时(多数游戏参数都是)写成 `--game=--fullscreen`:单独一个 `--fullscreen`
会被当成下一个选项(缺少取值),这是全项目 CLI 的既有解析口径。
--json 输出 {\"data\":{\"launch\":{…实例层…},\"effective\":{…回落后的生效值…}, …}}
%prog% instances scan
列出当前游戏根 versions/ 里已经铺好的版本(可能是别的启动器装的),逐项给结论:
✓ 可登记 / 已登记(附父链);✗ 不可登记 + 每条原因(带稳定 code:manifest_missing /
manifest_invalid_json / missing_parent / circular_inheritance / outside_game_root …)。
只读:不写文件、不改 mtime。versions/ 不存在 = 空结果;versions/ 读不了 = 报错(不当空列表)。
--json 输出 {\"data\":{\"game_root\":…,\"versions_dir\":…,\"counts\":{…},\"candidates\":[…]}}
每个 candidate 带 {\"version_id\",\"jar_present\",\"inherits\",\"registered\",\"adoptable\",\"problems\"}。
%prog% instances adopt <version-id> [--name N] [--isolation shared|isolated|custom]
[--game-dir DIR] [--dry-run]
把 versions/<id>/ 登记成实例:只读外部、只写自己的配置——不搬文件、不补清单、不改任何
外部文件的 mtime(versions/ libraries/ assets/ 一个字节都不写)。不读任何启动器的账户凭据。
运行目录(--gameDir)三档,缺省不沿用全局 isolated(那会把 mods/saves/config 造进
别人的 versions/<id>/,那是别的启动器的版本目录):
不给 --isolation → <数据根>/.ULL/data/instances/<实例目录名>(新建空目录,隔离档写 custom)
--isolation shared → 别人的游戏根(共用其 mods/saves/config;明确选择后才生效)
--isolation isolated → versions/<version-id>/(第一次启动会在这里新增玩家数据目录)
--isolation custom --game-dir DIR → 指定目录(DIR 必须是绝对路径;不新建、不改动其中内容)
拒绝(退出码 1,原因逐条进 error.detail.problems):
· 清单坏了 / 读不出来 / 缺 id / id 与目录名不一致
· 缺父版本 / 父链成环(说清缺哪个、环是什么)
· 条目解析到游戏根之外(符号链接/目录联接/祖先链接);versions 入口存在但读不出来(断链)
· 配置写面被链到 .ULL 之外(config/、config/instances/、profiles.json 的真实落点)
· 写盘面与游戏树重叠(.ULL / data / 缺省运行目录解析后落进别人的 .minecraft)⇒ 换不重叠的数据目录;
整体迁移到游戏树之外仍允许
· 展示名已被别的实例占用;这个版本已经同名登记过(重复执行 = 无副作用失败)
· 缺省运行目录已存在且非空(空目录可复用;不猜「合并进已有实例」)
· 缺省目录会撞上别的实例(slug 同名 / 大小写不敏感)⇒ 自动顺延 -2/-3… 并点出撞的是谁;
撞满 32 个后缀才报错(不会让两个实例共用一个运行目录)
--dry-run:只检查并打印将要用的隔离档/运行目录,不写任何文件、不建目录。
--json 输出 {\"data\":{\"dry_run\":…,\"instance\":{\"id\",\"name\",\"isolation\",\"run_dir\",
\"run_dir_source\",\"config_path\",\"created\"},\"notes\":[…]}}。
",
),
"pack" => Some(
"\
%prog% pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]
导入一个整合包到实例目录。三种格式按优先级探测(形状校验在前,不靠文件名抢):
CurseForge manifestType=minecraftModpack 的 manifest.json + overrides/
Modrinth modrinth.index.json + overrides/ client-overrides/(后者胜;
server-overrides/ 客户端不解压)
MultiMC mmc-pack.json(formatVersion 必须为 1)+ instance.cfg + .minecraft/
(Prism 实例;非加载器 component 会逐项列进报告,不静默丢)
目标目录缺省 = 新实例目录 `<数据根>/.ULL/data/instances/<实例目录名>`(并登记实例,M216 起
不再落共享 .minecraft 根;目录名是包名/`--name` 经 ASCII 归一化后的结果,如 `Better-MC`);
`--target` 可钉到别处(例如某个实例的运行目录)。
两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。
· 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖
· 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」
· 目录穿越 / 符号链接 / 清单损坏 / 摘要不符一律整包回滚,目标目录零残留
`--no-register`:只落内容,不写 profiles.json。
退出码:0 成功 / 3 部分成功 / 1 失败(2 内部错误)。
--json:信封恒为 {\"ok\":true,\"data\":{…}},分流看 data.status 与 data.error.code;
data.stages 列出实际走过的阶段。",
),
"update" => Some(
"%prog% update check
%prog% update apply [--dest <path>]
自更新(D50:GitHub Releases 固定清单地址,先只发 stable)。
check 查一次更新,只读:报告当前版本、渠道、有没有新版(版本号/体积/说明摘要)。
四种结论分开报,不折叠:up_to_date / available / missing_artifact(有新版但没本平台
产物,属发布侧漏项)/ 失败(退出码 1,code=update_check_failed)。
apply 先复查一次(清单只有几 KB),有新版才下载 → 长度 + SHA256 双检 → 原子替换。
校验不符或替换被占用时**不会**留下暂存文件、也不会破坏现有 exe(自动回滚)。
缺省替换本进程的 exe:**下次启动生效**(正在跑的这一份仍是旧版本)。
--dest 钉到别的路径(取证 / 自定义安装位置)。不写 = 本进程 exe。
环境变量 ULL_UPDATE_DIR=<dir> + ULL_UPDATE_TEST_SOURCE=1:改走本地目录源(`<dir>/stable.json`
+ 产物),用于内网手工分发与更新链路的整环取证。**两者都只在带 `update-test-source` feature
编出来的验收构建里生效**(`cargo build --features update-test-source`);官方发布构建不含这条
路径,设了环境变量也照旧走 GitHub。
--json 输出 {\"data\":{\"status\":…,\"current\":…,\"version\":…}},脚本按 status 分流。",
),
"install" => Some(
"\
%prog% install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
解析版本清单与继承链 → 组下载计划 → 落盘(versions/ assets/ libraries/)→ 登记实例
(profiles.json + config/instances/<id>.json)。已存在且校验通过的文件会跳过,
重复装同一版本不重复下载、不重复建实例。
--dry-run:只输出计划(文件清单 + 总大小 + 每类资源的源选择),不下载、不登记。
--loader:vanilla(缺省)/ fabric / quilt / neoforge / forge / liteloader。装加载器时先生成
versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 父版本 + 主类 + loader 库),
原版没装会先装原版,实例的 versionId 指向 loader 版本。
optifine 认得出但**没接入安装管线**(要跑外部 Patcher 并原地改 zip),报 loader_unsupported;
其余认不出的拼法报 loader_unknown —— 两者不是一回事。
liteloader 的特殊之处:清单是从官方版本表**从零合成**的(mainClass = launchwrapper,
--tweakClass 追加在父链参数之后),不跑任何外部进程;不叠加载器时只占一个 loader 库。
<version> 位还可以写**已装的加载器版本 id**(`1.12.2-forge-14.23.5.2860 --loader liteloader`)
表示「叠在它上面」:原版版本从盘上的继承链读出来,父版本没装会具名报错(不会猜一个原版版本号)。
只有 liteloader 接受这种叠法,别家给 usage 错误说清「只能叠在原版上」。
--loader-version:加载器版本;缺省取官方默认(Fabric 系首个稳定版,maven 系最新,
LiteLoader 优先 release 分支、没有则最新快照)。
Forge 可写 47.2.0 或 1.20.1-47.2.0 两种形态。
NeoForge/Forge 里需要跑官方 installer processors 的版本会真跑一遍(M35 已实装):
缺 Java 时按 D23 的回退链自动装一个托管 JDK;--dry-run 只列出要跑的 processor 与依赖库。
--json:stdout 是单个结果信封 {\"data\":{\"plan\":…,\"result\":…,\"instance\":…}},
实时进度改走 stderr 的 JSONL(一行一条 {\"event\":\"progress\"|\"file_failed\"});
--quiet 时连进度也不打。",
),
"launch" => Some(
"\
%prog% launch <instance> [--dry-run] [--account <name>] [--java <path>] [--username <name>]
[--max-memory <MB>]
<instance> 可以是实例 id、实例名,或直接是版本 id。
--dry-run(D18):只组装命令行并打印,不 spawn 进程、不写会话文件、不刷新令牌,
也不碰版本树(不解压 natives、不建 natives 目录,连缺的目录也不补);启动器自身的
运行日志(<数据根>/logs/launcher.log)照记,这一点与真实启动一致。
账户:--account <name> 指定账户(玩家名或条目 id);不给时用唯一账户,一个都没有则回落
离线占位(--username 或 Player),有多个账户会报错并列出候选。
accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
),
"accounts" => Some(
"\
%prog% accounts list
%prog% accounts add offline <name>
%prog% accounts add msa
%prog% accounts remove <name>
账户存在 <数据根>/.ULL/data/auth/:accounts.json(可公开元数据)与 credentials.dat(凭据)
**分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。
凭据永不打印:list 只输出名称/轨道/UUID。
add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码)。
微软登录所需配置已内置在官方构建里,装完即可用;仍可用环境变量覆盖(优先于内置值):
ULL_MSA_CLIENT_ID=<id> %prog% accounts add msa 每次运行时给(优先于内置值)
ULL_MSA_CLIENT_ID=<id> cargo build 自己从源码构建时注入,二进制自带
两处都没有时(只有自建且不自带 id 才会这样)命令立刻报「微软登录未配置」(含怎么配),**不会去联网**。
authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。",
),
"sessions" => Some("%prog% sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"),
"logs" => Some("%prog% logs <sid> [--tail N] <sid> 允许唯一前缀;输出会话 JSONL 末尾 N 条事件(缺省 200)"),
"diag" => Some(
"%prog% diag <sid> [--tail N] 把会话的退出信号与日志喂给规则引擎(内嵌规则集),输出结构化诊断",
),
"config" => Some(
"\
%prog% config get [<key>] [--show-secret]
%prog% config set <key> <value>
读写 <数据根>/.ULL/config/launcher.json,走 core::setting 的 schema(类型/枚举校验、
原子写)。敏感键 get 时脱敏,--show-secret 才给明文;值先按 JSON 解析,失败按字符串。",
),
"java" => Some(
"\
%prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
并打印每个 JDK 的实际落点(managed 项带目录)
%prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
%prog% java vendors 列出各发行版的镜像/支持版本/当前优先级顺序
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
standard Windows %APPDATA%\\Java\\<发行方>\\<版本>\\;macOS ~/Library/Application Support/Java/…
Linux $XDG_DATA_HOME/java/…
custom 设置 java.installRoot 后按 <root>/<发行方>/<版本>/(覆盖前两者)
发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms)
→ zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序
(只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。
Termux 上缺省先用系统包(pkg install openjdk-<major>,支持 17/21/25),失败再走归档链;
--vendor termux 只走系统包。每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进
--json 的 chain 块与人读的「降级:」行。",
),
"agent" => Some(
"\
%prog% agent serve [--stdio] [--port N] [--host H] [--allow-write|--readonly]
%prog% agent config [--port N]
把 agent 的工具注册表挂成 **MCP server**(02 文档 §1 的第二形态:同一注册表、同一安全策略、
同一审计)。默认走 Streamable HTTP,只绑回环地址(127.0.0.1,默认端口 47823);
--stdio 是给 llama.cpp server 的内置 MCP client 用的形态。
写权限默认**关**(只读子集):要放开需设置键 agent.mcp.allow_write=true 或 --allow-write。
敏感键(High 分级)在 MCP 场景**默认拒绝**(外部客户端没有确认弹窗):让用户去设置页改,
或在设置 → AI 里打开 YOLO(审计照记 yolo=true)。
令牌:设置键 agent.mcp.token;非空时要求 Authorization: Bearer <token>。
`agent config` 直接给出 Claude Code {\"type\":\"http\",\"url\":…} 与 Cursor {\"url\":…} 的样例。",
),
_ => None,
}
}
+110
View File
@@ -0,0 +1,110 @@
//! 命令行解析(手写,不引 clap —— 子命令集小,省一棵依赖树)。
//!
//! 语法:`ull [全局选项] <命令> [子命令/参数]`。全局选项可以出现在任意位置
//! (脚本里 `ull instances list --json` 与 `ull --json instances list` 等价)。
//!
//! 帮助与版本是「提前返回」的伪命令:`--help` / `-h` 出现在任何位置都立即给帮助,
//! 有位置参数时按该主题给帮助。
pub mod help;
pub mod model;
pub mod parse;
pub mod program;
pub use model::{
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
DiagArgs, Globals, InstallArgs, InstanceLaunchArgs, Invocation, LaunchArgs, LogsArgs,
PackImportArgs, UpdateApplyArgs, UpdateCheckArgs,
};
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"]));
}
}
+284
View File
@@ -0,0 +1,284 @@
//! 命令模型(纯数据):解析结果就是这里的一个 [`Invocation`]。
use std::path::PathBuf;
use unlucky_core::game::AdoptTarget;
/// 全局选项(所有命令共享)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Globals {
/// `--json`:机器可读输出。
pub json: bool,
/// `--no-color`:关闭颜色。v1 输出本身不含 ANSI,参数保留以兼容脚本。
pub no_color: bool,
/// `--quiet`:静默人类可读输出(错误仍进 stderr)。
pub quiet: bool,
/// `--data-dir <dir>`:覆盖数据根(等价 custom 模式的 `custom_root`)。
pub data_dir: Option<PathBuf>,
}
/// 一条命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
/// 无子命令:版本摘要 + 帮助(用户原话「无子命令时 --version + --help」)。
Welcome,
/// 只打印版本 + 系统摘要。
Version,
/// 帮助(`Some(topic)` 给该子命令的帮助)。
Help(Option<String>),
/// `instances list`。
InstancesList,
/// `instances scan`:列出当前游戏根里可登记 / 不可登记的外部版本(只读,M221)。
InstancesScan,
/// `instances adopt <version-id> [--name N] [--isolation ...] [--game-dir DIR] [--dry-run]`。
InstancesAdopt(InstancesAdoptArgs),
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
InstancesLaunchArgs(InstanceLaunchArgs),
/// `install <version> [--loader X] [--name N]`。
Install(InstallArgs),
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
PackImport(PackImportArgs),
/// `launch <instance> [--dry-run]`。
Launch(LaunchArgs),
/// `sessions list [--limit N]`。
SessionsList {
/// 最多返回几条。
limit: usize,
},
/// `logs <sid> [--tail N]`。
Logs(LogsArgs),
/// `diag <sid>`。
Diag(DiagArgs),
/// `config get [<key>]`。
ConfigGet(ConfigGetArgs),
/// `config set <key> <value>`。
ConfigSet(ConfigSetArgs),
/// `java list`。
JavaList,
/// `java install <major> [--vendor X]`。
JavaInstall {
/// 目标 JDK 主版本(8 / 17 / 21)。
major: u32,
/// 钉死的发行版(缺省走 D23 的回退链)。
vendor: Option<unlucky_core::game::java::JdkVendor>,
},
/// `java vendors`:列出各发行版的可用性 / 镜像 / 当前优先级。
JavaVendors,
/// `accounts list`。
AccountsList,
/// `accounts add <kind> [<name>]`。
AccountsAdd(AccountsAddArgs),
/// `accounts remove <name>`。
AccountsRemove {
/// 账户名(或条目 id)。
name: String,
},
/// `agent serve`:对外 MCP server(长驻)。
AgentServe(AgentServeArgs),
/// `update check`:查一次更新,不动任何文件。
UpdateCheck(UpdateCheckArgs),
/// `update apply [--dest <path>]`:下载 → 校验 → 替换(默认替换本进程 exe)。
UpdateApply(UpdateApplyArgs),
/// `agent config`:打印 Claude Code / Cursor 的客户端配置样例。
AgentConfig {
/// 覆盖端点端口(默认取设置键 `agent.mcp.port`)。
port: Option<u16>,
},
}
/// `update check` 的参数(现在没有选项,留一个结构体以免将来加选项时改签名)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateCheckArgs {}
/// `update apply` 的参数。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateApplyArgs {
/// 替换目标(缺省 = 本进程的 exe;取证/自定义安装位置时才给)。
pub dest: Option<PathBuf>,
}
/// `agent serve` 的参数(命令行只做覆盖,默认值来自设置键 `agent.mcp.*`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentServeArgs {
/// 走 stdio 形态(llama.cpp 内置 MCP client 用);缺省是 Streamable HTTP。
pub stdio: bool,
/// 覆盖端口。
pub port: Option<u16>,
/// 覆盖监听地址(只接受回环地址,非回环会被 server 拒绝)。
pub host: Option<String>,
/// 本次运行放开写工具(仍需设置键 `agent.mcp.allow_write` 或本 flag 之一)。
pub allow_write: bool,
}
/// `accounts add` 的轨道。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountsAddKind {
/// 离线(`add offline <name>`)。
Offline,
/// 微软 device-code(`add msa`,交互式)。
Microsoft,
}
impl AccountsAddKind {
/// 稳定名(帮助文案与 JSON 输出用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Offline => "offline",
Self::Microsoft => "msa",
}
}
}
/// `accounts add` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountsAddArgs {
/// 轨道。
pub kind: AccountsAddKind,
/// 离线轨的玩家名(微软轨不需要)。
pub name: Option<String>,
}
/// `install` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallArgs {
/// 目标版本 id(`1.20.1` / `1.20.1-forge-47.2.0`)。
pub version: String,
/// 加载器(`vanilla` 缺省 / `fabric` / `quilt` / `neoforge` / `forge` / `liteloader`;
/// `optifine` 认得出但没接安装管线,报 `loader_unsupported`)。
pub loader: Option<String>,
/// 加载器版本(缺省取官方默认:Fabric 系首个稳定版,maven 系该原版下最新)。
pub loader_version: Option<String>,
/// 实例显示名(缺省用版本 id)。
pub name: Option<String>,
/// 只出安装计划(列清单 + 总大小 + 源选择),不下载、不登记。
pub dry_run: bool,
/// 是否往 stderr 打实时进度(`--quiet` 下关掉)。
pub progress: bool,
/// 实时进度用 JSONL 形态(`--json` 时开,stderr 一行一条,stdout 仍是单个信封)。
pub json_progress: bool,
}
/// `pack import` 的参数(M111)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackImportArgs {
/// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。
pub path: PathBuf,
/// 目标实例目录(缺省 = 新实例目录 `<数据根>/.ULL/data/instances/<实例目录名>`,M216 前是
/// 共享游戏目录 `game_home`)。
pub target: Option<PathBuf>,
/// 实例显示名(缺省 = 包名)。
pub name: Option<String>,
/// 冲突时用包内内容覆盖(缺省保留用户文件)。
pub overwrite: bool,
/// 不登记实例(只落内容)。
pub no_register: bool,
}
/// `launch` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchArgs {
/// 实例 id 或实例名;也可以直接给版本 id。
pub instance: String,
/// 只组装命令行,不 spawn(D18 的 `--dry-run`)。
pub dry_run: bool,
/// Java 可执行文件覆盖。
pub java: Option<PathBuf>,
/// 离线账户名(缺省 `Player`)。
pub username: Option<String>,
/// 账户名(`accounts` 里的玩家名或条目 id);缺省用唯一账户。
pub account: Option<String>,
/// 最大堆(MB)。
pub max_memory: Option<u32>,
}
/// `logs` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogsArgs {
/// 会话 id(允许唯一前缀)。
pub session_id: String,
/// 只打印末尾 N 条事件(缺省 200)。
pub tail: usize,
}
/// `diag` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagArgs {
/// 会话 id(允许唯一前缀)。
pub session_id: String,
/// 送入规则引擎的日志行上限(缺省 2000)。
pub tail: usize,
}
/// `instances adopt` 的参数(M221:把盘上已有的版本登记成实例,只读外部、只写自己的配置)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstancesAdoptArgs {
/// 要接入的版本 id(= `versions/<id>/`)。
pub version_id: String,
/// 展示名(缺省 = 版本 id)。
pub name: Option<String>,
/// 运行目录选择:缺省 [`AdoptTarget::InstanceDir`](ULL 数据区新目录),
/// `shared` / `isolated` / `custom` 都必须显式给。
pub target: AdoptTarget,
/// 只检查、不写任何文件(`--dry-run`)。
pub dry_run: bool,
}
/// `instances launch-args` 的参数(M157:实例级启动参数)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InstanceLaunchArgs {
/// 实例 id 或实例名。
pub instance: String,
/// 实例级 JVM 参数(空白分隔,支持引号)。
pub jvm_args: Option<String>,
/// 实例级游戏参数。
pub game_args: Option<String>,
/// 实例级最大堆(MiB 文本;空串由 `--memory ""` 表达「跟随全局」)。
pub memory_mb: Option<String>,
/// 实例级 Java 可执行文件。
pub java: Option<String>,
/// 图形后端(`default` / `opengl` / `vulkan`)。
pub backend: Option<String>,
/// 先清空五项再应用给出的项(`--clear`;不带其它选项时等于「全部跟随全局」)。
pub clear: bool,
}
impl InstanceLaunchArgs {
/// 这次调用是不是纯读取(没有任何写入口)。
#[must_use]
pub fn is_read_only(&self) -> bool {
!self.clear
&& self.jvm_args.is_none()
&& self.game_args.is_none()
&& self.memory_mb.is_none()
&& self.java.is_none()
&& self.backend.is_none()
}
}
/// `config get` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigGetArgs {
/// 点分键路径;`None` 表示整份生效配置。
pub key: Option<String>,
/// 敏感键也输出明文(默认脱敏)。
pub show_secret: bool,
}
/// `config set` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigSetArgs {
/// 点分键路径。
pub key: String,
/// 值的原始文本(先按 JSON 解析,失败按字符串)。
pub value: String,
}
/// 一次调用的完整输入。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invocation {
/// 全局选项。
pub globals: Globals,
/// 要执行的命令。
pub command: Command,
}
+382
View File
@@ -0,0 +1,382 @@
//! 手写解析器:全局选项 → 子命令 → 子命令内的选项。
use std::collections::BTreeMap;
use std::path::PathBuf;
use unlucky_core::game::java::JdkVendor;
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,
};
/// `logs` 缺省尾巴行数。
const DEFAULT_LOG_TAIL: usize = 200;
/// `diag` 缺省送入规则引擎的行数。
const DEFAULT_DIAG_TAIL: usize = 2000;
/// `sessions list` 缺省条数。
const DEFAULT_SESSION_LIMIT: usize = 20;
/// 解析整条命令行(不含 argv[0])。
///
/// # Errors
///
/// 用法错误(未知命令/选项、缺参数、数字解析失败)返回 [`CliError`](退出码 1)。
pub fn parse(args: &[String]) -> Result<Invocation, CliError> {
let mut globals = Globals::default();
let mut rest: Vec<String> = Vec::new();
let mut help_requested = false;
let mut version_requested = false;
let mut index = 0;
while index < args.len() {
let arg = args[index].as_str();
match arg {
"--json" => globals.json = true,
"--no-color" => globals.no_color = true,
"--quiet" | "-q" => globals.quiet = true,
// 帮助/版本是「伪命令」,但不提前返回:`--help` 出现在命令前面时,
// 主题要从后面收完的位置参数里取(`ull --help launch` 与 `ull launch --help` 等价)。
"--help" | "-h" => help_requested = true,
"--version" | "-V" => version_requested = true,
"--data-dir" => {
index += 1;
globals.data_dir = Some(PathBuf::from(take_value(args, index, "data-dir")?));
}
_ => match arg.strip_prefix("--data-dir=") {
Some(value) => globals.data_dir = Some(PathBuf::from(value)),
None => rest.push(arg.to_string()),
},
}
index += 1;
}
if help_requested {
return Ok(Invocation {
globals,
command: Command::Help(rest.first().cloned()),
});
}
if version_requested {
return Ok(Invocation {
globals,
command: Command::Version,
});
}
let command = parse_command(&rest, &globals)?;
Ok(Invocation { globals, command })
}
fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError> {
let Some(name) = rest.first() else {
return Ok(Command::Welcome);
};
let tail = &rest[1..];
match name.as_str() {
"version" => Ok(Command::Version),
"help" => Ok(Command::Help(tail.first().cloned())),
"instances" => instances::parse_instances(tail),
"install" => parse_install(tail, globals),
"pack" => parse_pack(tail),
"launch" => parse_launch(tail),
"sessions" => parse_sessions(tail),
"logs" => parse_logs(tail),
"diag" => parse_diag(tail),
"config" => parse_config(tail),
"java" => parse_java(tail),
"accounts" => accounts::parse_accounts(tail),
"agent" => agent::parse_agent(tail),
"update" => update::parse_update(tail),
other => {
Err(CliError::usage(format!("未知命令 `{other}`"))
.with_hint("%prog% --help 查看命令列表"))
}
}
}
fn parse_install(tail: &[String], globals: &Globals) -> Result<Command, CliError> {
let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?;
let version = require_single_positional(&parsed, "install <version>")?;
Ok(Command::Install(InstallArgs {
version,
loader: parsed.value("loader"),
loader_version: parsed.value("loader-version"),
name: parsed.value("name"),
dry_run: parsed.has("dry-run"),
// 实时进度进 stderr:人类模式是文本行,`--json` 模式是 JSONL(stdout 保持干净),
// `--quiet` 一律不打。
progress: !globals.quiet,
json_progress: globals.json,
}))
}
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
fn parse_pack(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("pack 需要子命令 import")
.with_hint("%prog% pack import <file> [--target <dir>]"));
};
if sub != "import" {
return Err(CliError::usage(format!(
"pack 的子命令只有 import,收到 `{sub}`"
)));
}
let parsed = parse_flags(
tail.get(1..).unwrap_or_default(),
&["target", "name"],
&["overwrite", "no-register"],
)?;
let path = require_single_positional(&parsed, "pack import <file>")?;
Ok(Command::PackImport(PackImportArgs {
path: PathBuf::from(path),
target: parsed.value("target").map(PathBuf::from),
name: parsed.value("name"),
overwrite: parsed.has("overwrite"),
no_register: parsed.has("no-register"),
}))
}
fn parse_launch(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(
tail,
&["java", "username", "account", "max-memory"],
&["dry-run"],
)?;
let instance = require_single_positional(&parsed, "launch <instance>")?;
let max_memory = match parsed.value("max-memory") {
Some(raw) => Some(
raw.parse::<u32>()
.map_err(|_| CliError::usage(format!("--max-memory 需要整数 MB,收到 `{raw}`")))?,
),
None => None,
};
Ok(Command::Launch(LaunchArgs {
instance,
dry_run: parsed.has("dry-run"),
java: parsed.value("java").map(PathBuf::from),
username: parsed.value("username"),
account: parsed.value("account"),
max_memory,
}))
}
fn parse_sessions(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some(other) => {
return Err(CliError::usage(format!(
"sessions 的子命令只有 list,收到 `{other}`"
)));
}
};
let parsed = parse_flags(args, &["limit"], &[])?;
ensure_no_positionals(&parsed, "sessions list")?;
let limit = match parsed.value("limit") {
Some(raw) => raw
.parse::<usize>()
.map_err(|_| CliError::usage(format!("--limit 需要整数,收到 `{raw}`")))?,
None => DEFAULT_SESSION_LIMIT,
};
Ok(Command::SessionsList { limit })
}
fn parse_logs(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(tail, &["tail"], &[])?;
let session_id = require_single_positional(&parsed, "logs <sid>")?;
let tail_lines = optional_count(&parsed, "tail", DEFAULT_LOG_TAIL)?;
Ok(Command::Logs(LogsArgs {
session_id,
tail: tail_lines,
}))
}
fn parse_diag(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(tail, &["tail"], &[])?;
let session_id = require_single_positional(&parsed, "diag <sid>")?;
let tail_lines = optional_count(&parsed, "tail", DEFAULT_DIAG_TAIL)?;
Ok(Command::Diag(DiagArgs {
session_id,
tail: tail_lines,
}))
}
fn parse_config(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("config 需要子命令 get|set")
.with_hint("%prog% config get <key> / %prog% config set <key> <value>"));
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"get" => {
let parsed = parse_flags(rest, &[], &["show-secret"])?;
if parsed.positionals.len() > 1 {
return Err(CliError::usage("config get 最多接受一个键路径"));
}
Ok(Command::ConfigGet(ConfigGetArgs {
key: parsed.positionals.first().cloned(),
show_secret: parsed.has("show-secret"),
}))
}
"set" => {
let parsed = parse_flags(rest, &[], &[])?;
if parsed.positionals.len() != 2 {
return Err(CliError::usage(
"config set 需要键路径与值:%prog% config set <key> <value>",
));
}
Ok(Command::ConfigSet(ConfigSetArgs {
key: parsed.positionals[0].clone(),
value: parsed.positionals[1].clone(),
}))
}
other => Err(CliError::usage(format!(
"config 的子命令只有 get|set,收到 `{other}`"
))),
}
}
fn parse_java(tail: &[String]) -> Result<Command, CliError> {
match tail.first().map(String::as_str) {
None | Some("list") => {
ensure_no_extra(tail.get(1..).unwrap_or_default(), "java list")?;
Ok(Command::JavaList)
}
Some("vendors") => {
ensure_no_extra(tail.get(1..).unwrap_or_default(), "java vendors")?;
Ok(Command::JavaVendors)
}
Some("install") => {
let parsed = parse_flags(tail.get(1..).unwrap_or_default(), &["vendor"], &[])?;
let raw = require_single_positional(&parsed, "java install <major>")?;
let major = raw
.parse::<u32>()
.map_err(|_| CliError::usage(format!("JDK 主版本需要整数,收到 `{raw}`")))?;
let vendor = match parsed.value("vendor") {
Some(text) => Some(JdkVendor::parse(&text).ok_or_else(|| {
CliError::usage(format!(
"--vendor 只有 temurin|oracle|microsoft|zulu,收到 `{text}`"
))
})?),
None => None,
};
Ok(Command::JavaInstall { major, vendor })
}
Some(other) => Err(CliError::usage(format!(
"java 的子命令只有 list|install|vendors,收到 `{other}`"
))),
}
}
/// 单个位置参数(多一个少一个都算用法错误)。
fn require_single_positional(parsed: &ParsedArgs, usage: &str) -> Result<String, CliError> {
match parsed.positionals.as_slice() {
[only] => Ok(only.clone()),
[] => Err(CliError::usage(format!("缺少参数:%prog% {usage}"))),
_ => Err(CliError::usage(format!("参数过多:%prog% {usage}"))),
}
}
fn optional_count(parsed: &ParsedArgs, name: &str, default: usize) -> Result<usize, CliError> {
match parsed.value(name) {
Some(raw) => raw
.parse::<usize>()
.map_err(|_| CliError::usage(format!("--{name} 需要整数,收到 `{raw}`"))),
None => Ok(default),
}
}
fn ensure_no_extra(args: &[String], usage: &str) -> Result<(), CliError> {
if args.is_empty() {
Ok(())
} else {
Err(CliError::usage(format!(
"{usage} 不接受参数:{}",
args.join(" ")
)))
}
}
fn ensure_no_positionals(parsed: &ParsedArgs, usage: &str) -> Result<(), CliError> {
ensure_no_extra(&parsed.positionals, usage)
}
/// 解析后的一条子命令参数。
struct ParsedArgs {
positionals: Vec<String>,
flags: BTreeMap<String, Option<String>>,
}
impl ParsedArgs {
fn value(&self, name: &str) -> Option<String> {
self.flags.get(name).cloned().flatten()
}
fn has(&self, name: &str) -> bool {
self.flags.contains_key(name)
}
}
/// 取值形 flag 的下一 token。
///
/// 下一个 token 若以 `--` 开头,说明用户漏了值(`ull launch x --java --dry-run`),
/// 此时报用法错误,而不是把 flag 当值吞掉后在下游报出费解的「找不到 Java」。
/// 负数(`-1`)不以 `--` 开头,不会被误伤。
fn take_value(args: &[String], index: usize, name: &str) -> Result<String, CliError> {
let value = args
.get(index)
.ok_or_else(|| CliError::usage(format!("--{name} 需要一个值")))?;
if value.starts_with("--") {
return Err(CliError::usage(format!(
"--{name} 需要一个值,但收到选项 `{value}`"
)));
}
Ok(value.clone())
}
/// `--flag` / `--flag=value` / `--flag value` + 位置参数。
fn parse_flags(args: &[String], valued: &[&str], flags: &[&str]) -> Result<ParsedArgs, CliError> {
let mut parsed = ParsedArgs {
positionals: Vec::new(),
flags: BTreeMap::new(),
};
let mut index = 0;
while index < args.len() {
let arg = &args[index];
let Some(body) = arg.strip_prefix("--") else {
parsed.positionals.push(arg.clone());
index += 1;
continue;
};
let (name, inline) = match body.split_once('=') {
Some((name, value)) => (name, Some(value.to_string())),
None => (body, None),
};
if valued.contains(&name) {
let value = match inline {
Some(value) => value,
None => {
index += 1;
take_value(args, index, name)?
}
};
parsed.flags.insert(name.to_string(), Some(value));
} else if flags.contains(&name) {
if inline.is_some() {
return Err(CliError::usage(format!("--{name} 不接受值")));
}
parsed.flags.insert(name.to_string(), None);
} else {
return Err(CliError::usage(format!("未知选项 --{name}")));
}
index += 1;
}
Ok(parsed)
}
+68
View File
@@ -0,0 +1,68 @@
//! `ull accounts` 子命令的解析(从 `parse.rs` 拆出,避免解析器主文件顶到 400 行)。
//!
//! 语法:
//! - `ull accounts [list]`
//! - `ull accounts add offline <name>`
//! - `ull accounts add msa`(别名 `microsoft`)
//! - `ull accounts remove <name>`(别名 `rm`)
//!
//! 只开这三条轨道入口:authlib 的密码登录不走 CLI(密码进 argv 会泄露,见
//! `DevDocs/SubAgentsTalk/auth-接口约定.md` §4)。
use super::{ensure_no_positionals, parse_flags, require_single_positional};
use crate::cli::model::{AccountsAddArgs, AccountsAddKind, Command};
use crate::error::CliError;
/// 解析 `accounts ...`。
pub(super) fn parse_accounts(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Ok(Command::AccountsList);
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"list" => {
let parsed = parse_flags(rest, &[], &[])?;
ensure_no_positionals(&parsed, "accounts list")?;
Ok(Command::AccountsList)
}
"add" => parse_add(rest),
"remove" | "rm" => {
let parsed = parse_flags(rest, &[], &[])?;
let name = require_single_positional(&parsed, "accounts remove <name>")?;
Ok(Command::AccountsRemove { name })
}
other => Err(CliError::usage(format!(
"accounts 的子命令只有 list|add|remove,收到 `{other}`"
))),
}
}
fn parse_add(rest: &[String]) -> Result<Command, CliError> {
let Some(kind) = rest.first().map(String::as_str) else {
return Err(
CliError::usage("accounts add 需要轨道:offline|msa").with_hint(
"%prog% accounts add offline <玩家名> / %prog% accounts add msa(设备码交互式登录)",
),
);
};
let parsed = parse_flags(rest.get(1..).unwrap_or_default(), &[], &[])?;
match kind {
"offline" => {
let name = require_single_positional(&parsed, "accounts add offline <name>")?;
Ok(Command::AccountsAdd(AccountsAddArgs {
kind: AccountsAddKind::Offline,
name: Some(name),
}))
}
"msa" | "microsoft" => {
ensure_no_positionals(&parsed, "accounts add msa")?;
Ok(Command::AccountsAdd(AccountsAddArgs {
kind: AccountsAddKind::Microsoft,
name: None,
}))
}
other => Err(CliError::usage(format!(
"accounts add 的轨道只有 offline|msa,收到 `{other}`"
))),
}
}
+66
View File
@@ -0,0 +1,66 @@
//! `agent` 子命令解析(`serve` / `config`)。
//!
//! 与 `parse::accounts` 同样的拆分理由:`parse.rs` 挨着 400 行硬上限,
//! 而且「MCP server 的参数」本来就是一撮独立的东西,混在主解析器里迟早长成一片。
use crate::cli::model::{AgentServeArgs, Command};
use crate::cli::parse::ParsedArgs;
use crate::error::CliError;
use super::{ensure_no_positionals, parse_flags};
/// 解析 `ull agent <sub>`。
///
/// # Errors
/// 缺子命令、未知子命令、未知选项、端口不是 1~65535 时返回用法错误。
pub fn parse_agent(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("agent 需要子命令 serve|config")
.with_hint("%prog% agent serve / %prog% agent config"));
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"serve" => parse_serve(rest),
"config" => parse_config(rest),
other => Err(CliError::usage(format!(
"agent 的子命令只有 serve|config,收到 `{other}`"
))),
}
}
fn parse_serve(rest: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(
rest,
&["port", "host"],
&["stdio", "allow-write", "readonly"],
)?;
ensure_no_positionals(&parsed, "agent serve")?;
if parsed.has("allow-write") && parsed.has("readonly") {
return Err(CliError::usage(
"--allow-write 与 --readonly 不能同时给(二选一)",
));
}
Ok(Command::AgentServe(AgentServeArgs {
stdio: parsed.has("stdio"),
port: optional_port(&parsed)?,
host: parsed.value("host"),
allow_write: parsed.has("allow-write"),
}))
}
fn parse_config(rest: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(rest, &["port"], &[])?;
ensure_no_positionals(&parsed, "agent config")?;
Ok(Command::AgentConfig {
port: optional_port(&parsed)?,
})
}
fn optional_port(parsed: &ParsedArgs) -> Result<Option<u16>, CliError> {
match parsed.value("port") {
Some(raw) => Ok(Some(raw.parse::<u16>().map_err(|_| {
CliError::usage(format!("--port 需要 1~65535,收到 `{raw}`"))
})?)),
None => Ok(None),
}
}
+114
View File
@@ -0,0 +1,114 @@
//! `instances` 的子命令解析(`list` / `launch-args` / `scan` / `adopt`)。
//!
//! 从 `parse.rs` 拆出来只为守 400 行硬线(M157 的 `launch-args` 让那边到 415 行);
//! 纯搬移,语义未变。M221 在这里加了 `scan` 与 `adopt` 两个只读/登记入口。
use std::path::PathBuf;
use unlucky_core::game::AdoptTarget;
use super::super::model::{InstanceLaunchArgs, InstancesAdoptArgs};
use super::*;
pub(super) fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some("launch-args") => {
return parse_instances_launch_args(tail.get(1..).unwrap_or_default())
}
Some("scan") => {
let parsed = parse_flags(tail.get(1..).unwrap_or_default(), &[], &[])?;
ensure_no_positionals(&parsed, "instances scan")?;
return Ok(Command::InstancesScan);
}
Some("adopt") => return parse_instances_adopt(tail.get(1..).unwrap_or_default()),
Some(other) => {
return Err(CliError::usage(format!(
"instances 的子命令只有 list / launch-args / scan / adopt,收到 `{other}`"
)))
}
};
let parsed = parse_flags(args, &[], &[])?;
ensure_no_positionals(&parsed, "instances list")?;
Ok(Command::InstancesList)
}
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
///
/// 五个取值选项都接受空串(`--jvm ""`)= 该清空,与 `--clear` 的「先清后改」互补。
fn parse_instances_launch_args(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(
tail,
&["jvm", "game", "memory", "java", "backend"],
&["clear"],
)?;
let instance = require_single_positional(&parsed, "instances launch-args <instance>")?;
Ok(Command::InstancesLaunchArgs(InstanceLaunchArgs {
instance,
jvm_args: parsed.value("jvm"),
game_args: parsed.value("game"),
memory_mb: parsed.value("memory"),
java: parsed.value("java"),
backend: parsed.value("backend"),
clear: parsed.has("clear"),
}))
}
/// `instances adopt <version-id> [--name N] [--isolation shared|isolated|custom] [--game-dir DIR] [--dry-run]`。
///
/// 运行目录的词表只在这里翻译一次([`AdoptTarget::parse`] 是唯一判据):
/// - 什么都不给 ⇒ [`AdoptTarget::InstanceDir`](ULL 数据区新目录,**不沿用全局 `isolated`**);
/// - `--isolation custom` 必须配 `--game-dir`;
/// - `--isolation shared|isolated` 不许再配 `--game-dir`(那两个档的运行目录是派生的)。
fn parse_instances_adopt(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(tail, &["name", "isolation", "game-dir"], &["dry-run"])?;
let version_id = require_single_positional(&parsed, "instances adopt <version-id>")?;
let isolation = parsed.value("isolation");
let game_dir = match parsed.value("game-dir") {
Some(raw) => {
let dir = PathBuf::from(&raw);
// 用法错误在解析期就报(和 `--data-dir` 同一口径),别把「参数写错」拖成命令期失败。
if !dir.is_absolute() {
return Err(CliError::usage(format!(
"--game-dir 需要绝对路径,收到 `{raw}`"
)));
}
Some(dir)
}
None => None,
};
let target = match isolation.as_deref() {
None => match game_dir {
// `--game-dir` 单独给 = 就是 custom(少写一个选项,不制造第二种说法)。
Some(dir) => AdoptTarget::Custom(dir),
// 缺省**只能**来自 core 的 `AdoptTarget::default()`:这里再写一遍 `InstanceDir`
// 就等于有了第二处真相源,改缺省时会漏一处。
None => AdoptTarget::default(),
},
Some("custom") => {
let dir = game_dir.ok_or_else(|| {
CliError::usage("--isolation custom 需要配 --game-dir <dir>(绝对路径)")
})?;
AdoptTarget::Custom(dir)
}
Some(other) => {
if game_dir.is_some() {
return Err(CliError::usage(
"--game-dir 只配 --isolation custom(shared / isolated 的运行目录是派生的)",
));
}
AdoptTarget::parse(other, None).ok_or_else(|| {
CliError::usage(format!(
"--isolation 只有 shared|isolated|custom,收到 `{other}`"
))
.with_hint("不给 --isolation = 落 ULL 数据区的独立运行目录(推荐)")
})?
}
};
Ok(Command::InstancesAdopt(InstancesAdoptArgs {
version_id,
name: parsed.value("name"),
target,
dry_run: parsed.has("dry-run"),
}))
}
+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}`"
))),
}
}
+120
View File
@@ -0,0 +1,120 @@
//! 程序名:CLI 输出里自称什么,取决于**被调用的那个二进制**叫什么。
//!
//! GUI 二进制(`unlucky-launcher`)与 `ull` 共用同一套命令行(M82),所以 banner / 用法行 /
//! 「`<名字> --help`」这类提示不能写死 `ull` —— 用户敲的是 `unlucky-launcher`,把他指去敲
//! 一个可能不存在的名字只会让人困惑。
//!
//! 做法(M82 r2 定案):文案里放**占位符**,渲染时替换;**绝不对插值后的整条文本做词替换**。
//! 上一版按「独立词 `ull`」替换整条消息,于是用户数据被改坏过:账户名叫 `ull` 的会变成
//! `unlucky-launcher`,路径 `D:/tmp/ull/.ULL` 会变成 `D:/tmp/unlucky-launcher/.ULL`。
//! 占位符只在模板里出现,插值进来的用户名/路径碰不到它。
//!
//! 取不到 argv[0](某些 exec 场景)就回落 [`FALLBACK`],输出因此与老版本逐字相同。
/// 文案里的程序名占位符。
///
/// 用 `%prog%` 而不是 `{prog}`:这些模板要活在 `format!` 里,`{prog}` 会被 `format!` 当成
/// 参数名直接编译失败,而 `%prog%` 原样穿过去,到渲染时才替换。
pub const PLACEHOLDER: &str = "%prog%";
/// 取不到 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())
}
/// 把模板里的 [`PLACEHOLDER`] 换成程序名。
///
/// **只认占位符**:文本里其它位置的 `ull`(账户名、路径段、回显的子命令)原样保留——这是
/// 与「按词替换整条文本」的分界线,也是本函数唯一要保证的事。
#[must_use]
pub fn fill(text: &str) -> String {
if !text.contains(PLACEHOLDER) {
return text.to_owned();
}
text.replace(PLACEHOLDER, &name())
}
#[cfg(test)]
mod tests {
use super::{fill, name_in, FALLBACK, PLACEHOLDER};
#[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 fill_replaces_only_the_placeholder() {
assert_eq!(
fill(&format!("{PLACEHOLDER} --help 查看命令列表")),
format!("{} --help 查看命令列表", super::name())
);
// 没有占位符就原样返回(不碰任何词)。
assert_eq!(fill("ull --help"), "ull --help");
}
#[test]
fn user_data_that_says_ull_is_never_rewritten() {
// 回归:账户名恰好是 `ull` / 路径里有 `\ull\` / 回显的子命令是 `ull`,
// 一个字节都不能被改成程序名(M82 r1 的真缺陷)。
for text in [
"找不到账户 `ull`",
r"索引文件:D:/tmp/ull\.ULL\data\agent\sessions\index.jsonl",
"未知命令 `ull`",
"过滤词 null 与 pull 也不算",
] {
assert_eq!(fill(text), text, "{text} 被改了");
}
}
}
+198
View File
@@ -0,0 +1,198 @@
//! `ull accounts list|add|remove`:账户元数据操作,**凭据永不打印**。
//!
//! 设计约束(D9/Q21 + D4 凭据红线):
//! - 只开四条路径:`list` / `add offline <name>` / `add msa` / `remove <name>`;
//! authlib 的密码登录**故意不开 CLI 通道**(密码进 argv 会直接进 shell 历史与
//! `ps` 输出,破坏红线;留给 GUI 输入框);
//! - 输出只经 `AccountMeta`(可公开字段),`Report`/`--json` 里没有任何 token 字段;
//! - device-code 的 `user_code`/URL 打到 **stderr**(不管 `--quiet` 都要打:不打用户没法完成登录)。
use serde_json::json;
use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials};
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth};
use unlucky_core::auth::offline::offline_meta;
use unlucky_core::auth::{AccountStore, AuthJournal};
use crate::cli::{AccountsAddArgs, AccountsAddKind};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{progress_line, Report};
/// `accounts list`:列出账户(名称/轨道/UUID/创建时间)。
///
/// # Errors
///
/// 数据根解析失败(在 `Ctx`)或读盘 IO 失败。
pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
let store = open_store(ctx);
let mut human = Vec::new();
if store.is_empty() {
human.push("还没有账户。".to_string());
human.push(" 离线:%prog% accounts add offline <玩家名>".to_string());
human.push(" 微软:%prog% accounts add msa(设备码交互式登录)".to_string());
} else {
human.push(format!("账户({} 个):", store.accounts().len()));
for meta in store.accounts() {
human.push(format!(
" {} · {} · {}",
meta.display_name(),
meta.kind.label(),
meta.profile_id
));
}
}
for warning in store.warnings() {
human.push(format!("提示: {warning}"));
}
let accounts: Vec<serde_json::Value> = store.accounts().iter().map(account_json).collect();
let data = json!({
"accounts": accounts,
"count": accounts.len(),
"auth_dir": store.paths().root().display().to_string(),
"warnings": store.warnings(),
});
Ok(Report::new(data, human))
}
/// `accounts add offline <name>`。
///
/// # Errors
///
/// 名字不合法、同名账户已存在、落盘失败。
pub fn add(ctx: &Ctx, args: &AccountsAddArgs) -> Result<Report, CliError> {
match args.kind {
AccountsAddKind::Offline => add_offline(ctx, args.name.as_deref().unwrap_or_default()),
AccountsAddKind::Microsoft => add_microsoft(ctx),
}
}
fn add_offline(ctx: &Ctx, name: &str) -> Result<Report, CliError> {
let meta = offline_meta(name, now_ms()).map_err(super::map_auth_error)?;
let mut store = open_store(ctx);
store
.insert(meta.clone(), Credentials::Offline)
.map_err(super::map_auth_error)?;
store.save().map_err(super::map_auth_error)?;
let human = vec![
format!(
"已添加离线账户 {}(HMCL 兼容 UUID {})",
meta.display_name(),
meta.profile_id
),
format!("账户文件:{}", store.paths().accounts_file().display()),
];
Ok(Report::new(added_json(&meta), human))
}
/// `accounts add msa`:device-code 交互式登录。
fn add_microsoft(ctx: &Ctx) -> Result<Report, CliError> {
// 失败日志落**本次命令的实际数据根**(`ctx.layout().logs()`):`MicrosoftAuth::from_env()`
// 默认挂的是便携根(exe 同级 `.ULL/logs`),`--data-dir` / launcher.json 的 standard 模式下
// 那是别人的目录,排障时找不到 auth.jsonl(M38 遗留 finding)。
let auth = MicrosoftAuth::from_env()
.map_err(super::map_auth_error)?
.with_journal(Some(AuthJournal::in_logs_dir(ctx.layout().logs())));
// 设备码要先给用户看,再开始等:core 的 `present` 回调正好卡在这个时间点。
let session = unlucky_core::download::blocking::block_on(auth.device_code_login(|prompt| {
let (headline, code) = prompt.lines();
progress_line(&headline);
progress_line(&code);
}))
.map_err(super::map_auth_error)?;
let meta = AccountMeta::new(
AccountKind::Microsoft,
session.profile_id.clone(),
session.profile_name.clone(),
now_ms(),
);
let mut store = open_store(ctx);
store
.insert(meta.clone(), Credentials::Microsoft(session))
.map_err(super::map_auth_error)?;
store.save().map_err(super::map_auth_error)?;
let human = vec![
format!(
"已添加微软账户 {}(档案 UUID {})",
meta.display_name(),
meta.profile_id
),
format!("账户文件:{}", store.paths().accounts_file().display()),
];
Ok(Report::new(added_json(&meta), human))
}
/// `accounts remove <name>`。
///
/// # Errors
///
/// 找不到该账户、落盘失败。
pub fn remove(ctx: &Ctx, name: &str) -> Result<Report, CliError> {
let mut store = open_store(ctx);
let meta = store.remove(name).map_err(super::map_auth_error)?;
store.save().map_err(super::map_auth_error)?;
let human = vec![format!(
"已删除账户 {}({})",
meta.display_name(),
meta.kind.label()
)];
let data = json!({
"removed": account_json(&meta),
"count": store.accounts().len(),
});
Ok(Report::new(data, human))
}
fn open_store(ctx: &Ctx) -> AccountStore {
AccountStore::open(ctx.auth_data())
}
fn added_json(meta: &AccountMeta) -> serde_json::Value {
json!({
"account": account_json(meta),
"account_id": meta.id.as_str(),
})
}
/// 账户元数据 → JSON(**只含可公开字段**)。
fn account_json(meta: &AccountMeta) -> serde_json::Value {
json!({
"name": meta.display_name(),
"profile_name": meta.profile_name,
"id": meta.id.as_str(),
"kind": meta.kind.as_str(),
"profile_id": meta.profile_id,
"login_name": meta.login_name,
"server_url": meta.server_url,
"created_at_ms": meta.created_at_ms,
})
}
#[cfg(test)]
mod tests {
use super::account_json;
use unlucky_core::auth::account::{AccountKind, AccountMeta};
#[test]
fn account_json_has_no_credential_fields() {
let mut meta = AccountMeta::new(
AccountKind::Microsoft,
"0f8fad5bd9cb469fa16570867728950e",
"Steve",
7,
);
meta.login_name = Some("me@example.com".to_string());
let value = account_json(&meta);
let keys: Vec<&str> = value
.as_object()
.expect("对象")
.keys()
.map(String::as_str)
.collect();
for forbidden in ["access_token", "refresh_token", "token", "password"] {
assert!(!keys.contains(&forbidden), "不该出现 {forbidden}");
}
assert_eq!(value["name"], "Steve");
assert_eq!(value["kind"], "microsoft");
}
}
+136
View File
@@ -0,0 +1,136 @@
//! `ull agent`:对外 MCP server 的 headless 入口(02 文档 §1 的第二形态)。
//!
//! 两个子命令:
//! - `agent serve`:长驻服务(`--stdio` 或 Streamable HTTP on 127.0.0.1);
//! - `agent config`:打印 Claude Code / Cursor 的配置样例(含当前端口与令牌状态)。
//!
//! 为什么 `serve` 不能走 `commands::run` 的「返回 Report 再打印」套路:stdio 形态下
//! **stdout 就是 JSON-RPC 流**,任何一行多余输出都会让客户端解析失败。所以 runner
//! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。
pub mod ports;
pub mod probe;
pub mod serve;
use serde_json::{json, Value};
use unlucky_agent::mcp::config_samples;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::AgentServeArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// `ull agent serve`:起服务(阻塞)。
///
/// # Errors
/// 见 [`serve::run`]。
pub fn run_serve(ctx: &Ctx, args: &AgentServeArgs) -> Result<i32, CliError> {
serve::run(ctx, args)
}
/// runner 的分流入口:解析数据根 → 起服务。错误由 runner 统一打印。
///
/// # Errors
/// 数据根缺失、平台不达基线、端口占用、非回环监听等返回用户错误。
pub fn run_serve_with(
globals: &crate::cli::Globals,
args: &AgentServeArgs,
err: &mut dyn std::io::Write,
) -> Result<i32, CliError> {
let ctx = Ctx::resolve(globals)?;
ctx.ensure_supported()?;
for note in ctx.notes() {
let _ = writeln!(err, "提示: {note}");
}
run_serve(&ctx, args)
}
/// `ull agent config`:输出两份可粘贴的客户端配置 + 当前设置状态。
///
/// # Errors
/// 配置不可读时返回用户错误(schema 不认识的键不影响)。
pub fn config_report(ctx: &Ctx, port: Option<u16>) -> Result<Report, CliError> {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
let doc = store.doc();
let enabled = doc
.effective("agent.mcp.enabled")
.and_then(Value::as_bool)
.unwrap_or(false);
let allow_write = doc
.effective("agent.mcp.allow_write")
.and_then(Value::as_bool)
.unwrap_or(false);
let yolo = doc
.effective("ai.yolo")
.and_then(Value::as_bool)
.unwrap_or(false);
let token = doc
.effective_str("agent.mcp.token")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string);
let configured_port = doc
.effective("agent.mcp.port")
.and_then(Value::as_u64)
.and_then(|value| u16::try_from(value).ok())
.unwrap_or(47823);
let port = port.unwrap_or(configured_port);
let url = format!("http://127.0.0.1:{port}/mcp");
let samples = config_samples(&url, token.as_deref());
let registry = unlucky_agent::tools::build_v1_registry(&ports::build(ctx))
.map_err(|error| CliError::internal("agent_registry", error.to_string()))?;
let read_only_tools = registry.read_only_definitions().len();
let all_tools = registry.len();
let data = json!({
"url": url,
"enabled": enabled,
"allow_write": allow_write,
"yolo": yolo,
"token_configured": token.is_some(),
"read_only_tools": read_only_tools,
"all_tools": all_tools,
"claude_code": samples.claude_code,
"cursor": samples.cursor,
});
let mut human = vec![
format!("MCP 端点:{url}"),
format!(
"开关:{} · 写权限:{} · YOLO:{} · 令牌:{}",
if enabled {
"已启用"
} else {
"未启用(serve 仍可手动起)"
},
if allow_write {
"开"
} else {
"关(只读)"
},
if yolo { "开" } else { "关" },
if token.is_some() {
"已设置"
} else {
"未设置"
}
),
"Claude Code(~/.claude.json 或 `claude mcp add --json`):".to_string(),
];
human.extend(pretty_lines(&samples.claude_code));
human.push("Cursor(.cursor/mcp.json):".to_string());
human.extend(pretty_lines(&samples.cursor));
human.push(
"启动:%prog% agent serve(默认 Streamable HTTP) / %prog% agent serve --stdio".to_string(),
);
Ok(Report::new(data, human))
}
fn pretty_lines(value: &Value) -> Vec<String> {
match serde_json::to_string_pretty(value) {
Ok(text) => text.lines().map(|line| format!(" {line}")).collect(),
Err(_) => vec![" (序列化失败)".to_string()],
}
}
@@ -0,0 +1,49 @@
//! 账户端口:只读 `AccountStore::accounts()`(公开字段)。**凭据红线在这里落闸**。
//!
//! `AccountStore::credentials()` 存在,但本文件**不调用**它——agent 侧也没有能承接
//! `Credentials` 的类型([`unlucky_agent::port::AccountSummary`])。这条界线不是靠自觉:
//! 只要有人想从工具结果里带出 token,就必须先改 `AccountSummary`,而 `port::game` 的
//! 单测会先炸(序列化结果里不许出现 token/password/secret)。
use unlucky_agent::port::game::{AccountPort, AccountSummary};
use unlucky_agent::port::PortResult;
use unlucky_core::auth::AccountStore;
/// M31 账户库的只读端口。
#[derive(Debug, Clone)]
pub struct CoreAccounts {
root: std::path::PathBuf,
}
impl CoreAccounts {
/// `root` = `<数据根>/.ULL/data/auth`。
#[must_use]
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self { root: root.into() }
}
}
impl AccountPort for CoreAccounts {
fn list_accounts(&self) -> PortResult<Vec<AccountSummary>> {
let store = AccountStore::open(self.root.clone());
if !store.warnings().is_empty() {
// 账户文件有坏条目:不阻断(用户还能看到其余账户),但要留痕到 stderr。
eprintln!(
"[unlucky-agent] 账户库有 {} 条提示(只读本工具不受影响)",
store.warnings().len()
);
}
Ok(store
.accounts()
.iter()
.map(|meta| AccountSummary {
id: meta.id.as_str().to_string(),
kind: meta.kind.as_str().to_string(),
profile_id: meta.profile_id.clone(),
profile_name: meta.profile_name.clone(),
login_name: meta.login_name.clone(),
created_at_ms: meta.created_at_ms,
})
.collect())
}
}
@@ -0,0 +1,296 @@
//! 诊断端口:调用 M15 的规则引擎(**规则先跑,模型补位**,02 §3.2)。
//!
//! 工具层只把「规则结论 + 相关片段」交给模型,不把裸日志倒进去——这是 token 预算
//! 与结论质量的双重考虑,也是 analyse/02 §8.3 的分层思路。
//!
//! ## M242:结论读**会话里落盘的那份**,不在脱敏文本上重跑
//!
//! 会话 JSONL 是写前脱敏的,而脱敏拿不准形状时是**有损**的:`≥64` 的不透明串、带点的票据串
//! 都会被换成占位符。M240 收窄了这条规则——**路径形状**的长串现在整条保留,所以 M239 实测的
//! 那条 `org/spongepowered/…/FabricMixinTransformerProxy` 今天能活下来。但那是**白名单**:
//! 形状一变(或又登记了别的 secret)就回到「抹掉」那一侧,而抹掉之后的重跑不是「少一点细节」,
//! 是**换一条结论** —— `fabric.loader_0_12`(critical)降级成 `integrity.no_class_def_found`
//! (warning),把「Fabric Loader 版本不配套」说成「库没下全」。M241 起写会话时已经在
//! **原始行**上算过一次结论并落成 `diagnosis` 事件,这里优先读它;没有该事件的老会话才回落到
//! 重跑(与今天的读法完全一致)。
//!
//! **隐私选择不变**:给模型的游戏日志仍是脱敏文本(`read_game_log` 与这里的 `evidence`
//! 片段都没动)。变的只是「结论从哪儿来」——盘上那份结论自己也是写入时过 `scrub_json` 的,
//! 所以既没把原文写进盘,也没把原文交给模型。
use std::collections::BTreeMap;
use serde_json::Value;
use unlucky_agent::port::{
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
PortResult,
};
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput, Finding};
use unlucky_core::diagnose::loader::RuleLoader;
use unlucky_core::diagnose::rules::FixActionKind as CoreFixKind;
use unlucky_core::launch::session_index::SessionIndexEntry;
use super::events::{tail_join, SessionSource};
#[cfg(test)]
#[path = "diagnose/tests.rs"]
mod tests;
/// 送进规则引擎的默认尾巴行数(与 CLI `diag` 同值,避免两条路看到不同证据)。
pub const DEFAULT_TAIL: usize = 2000;
/// 会话级证据片段条数(与重跑路径同值:`stderr` 末尾 3 行)。
const EVIDENCE_LINES: usize = 3;
/// M15 规则引擎的端口实现。
#[derive(Debug, Clone)]
pub struct CoreDiagnose {
source: SessionSource,
tail: usize,
}
impl CoreDiagnose {
/// `root` = `<数据根>/.ULL/logs`(M188 起;旧名是 `data/agent/sessions`)。
#[must_use]
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self {
source: SessionSource::new(root),
tail: DEFAULT_TAIL,
}
}
/// 再挂一个只读兼容的旧根(`data/agent/sessions`):旧会话也能诊断。
#[must_use]
pub fn with_legacy(mut self, legacy: impl Into<std::path::PathBuf>) -> Self {
self.source = self.source.with_legacy(legacy);
self
}
}
fn map_kind(kind: CoreFixKind) -> FixActionKind {
match kind {
CoreFixKind::ChangeJava => FixActionKind::ChangeJava,
CoreFixKind::AdjustMemory => FixActionKind::AdjustMemory,
CoreFixKind::ChangeGraphics => FixActionKind::ChangeGraphics,
CoreFixKind::RemoveMod => FixActionKind::RemoveMod,
CoreFixKind::Reinstall => FixActionKind::Reinstall,
CoreFixKind::SeekHelp => FixActionKind::SeekHelp,
}
}
fn candidate_of(finding: &Finding) -> DiagnosisCandidate {
let core_candidate = finding.to_candidate();
DiagnosisCandidate {
id: core_candidate.id,
severity: core_candidate.severity.to_string(),
category: core_candidate.category.to_string(),
confidence: core_candidate.confidence,
message_key: core_candidate.message_key,
message: core_candidate.message,
detail: core_candidate.detail.unwrap_or_default(),
evidence: core_candidate.evidence,
fix_actions: core_candidate
.fix_actions
.into_iter()
.map(|action| FixAction {
kind: map_kind(action.kind),
tool: action.tool.to_string(),
label: String::new(),
args: action
.args
.into_iter()
.collect::<BTreeMap<String, String>>(),
})
.collect(),
}
}
// ─────────────────── 落盘的结论(`diagnosis` 事件) ───────────────────
//
// 下面三个是 `unlucky_core::diagnose::finding::{DiagnosisRecord, Candidate,
// MaterializedFixAction}` 的**只读镜像**:core 侧那些类型只 derive `Serialize`(写盘用),
// agent 侧的类型也只 derive `Serialize`(回灌给模型用),所以「读回来」这一件事在这里落一次。
/// `diagnosis` 事件的 payload。
#[derive(Debug, Default, serde::Deserialize)]
struct StoredRecord {
#[serde(default)]
source: Option<String>,
#[serde(default)]
needs_more_info: bool,
#[serde(default)]
candidates: Vec<StoredCandidate>,
#[serde(default)]
secondary: Vec<StoredCandidate>,
}
/// 一条落盘的候选结论。
#[derive(Debug, Default, serde::Deserialize)]
struct StoredCandidate {
#[serde(default)]
id: String,
#[serde(default)]
severity: String,
#[serde(default)]
category: String,
#[serde(default)]
confidence: f32,
#[serde(default)]
message_key: String,
#[serde(default)]
message: String,
#[serde(default)]
detail: Option<String>,
#[serde(default)]
evidence: Vec<String>,
#[serde(default)]
fix_actions: Vec<StoredAction>,
}
/// 一条落盘的修复动作(`kind` 是 `snake_case` 的六维之一)。
#[derive(Debug, Default, serde::Deserialize)]
struct StoredAction {
#[serde(default)]
kind: String,
#[serde(default)]
tool: String,
#[serde(default)]
args: BTreeMap<String, String>,
}
impl StoredRecord {
/// 事件流里最后一条**有主因**的 `diagnosis` 事件。
///
/// 正常只有一条(挂钩写的或收尾补的,二选一);真有多条取**最后一条**:收尾补的证据最新鲜。
/// 没有候选的 payload 与「没结论」等价(`conclude` 也不会写它)→ 返回 `None`,让调用方回落重跑。
fn find(events: &[Value]) -> Option<Self> {
let payload = events
.iter()
.rev()
.find(|event| event.get("kind").and_then(Value::as_str) == Some("diagnosis"))?
.get("payload")?;
let record: Self = serde_json::from_value(payload.clone()).ok()?;
(!record.candidates.is_empty()).then_some(record)
}
/// 落盘结论 → 给模型的 [`CrashDiagnosis`]。
///
/// `exit` 仍取自索引条目(launch 报的事实,与盘上那份结论的计算输入同源),
/// `evidence` 仍是**脱敏**的 stderr 尾窗——隐私选择不变,只有结论换了来源。
fn into_diagnosis(self, entry: &SessionIndexEntry, evidence: Vec<String>) -> CrashDiagnosis {
CrashDiagnosis {
session_id: entry.session_id.clone(),
source: self.source.unwrap_or_else(|| "rules".to_string()),
needs_more_info: self.needs_more_info,
candidates: self
.candidates
.into_iter()
.map(StoredCandidate::into_candidate)
.collect(),
secondary: self
.secondary
.into_iter()
.map(StoredCandidate::into_candidate)
.collect(),
evidence,
exit: ExitSummary {
code: entry.exit_code,
exit_type: entry.exit_type.clone(),
silent_failure: entry.silent_failure,
},
}
}
}
impl StoredCandidate {
fn into_candidate(self) -> DiagnosisCandidate {
DiagnosisCandidate {
id: self.id,
severity: self.severity,
category: self.category,
confidence: self.confidence,
message_key: self.message_key,
message: self.message,
detail: self.detail.unwrap_or_default(),
evidence: self.evidence,
fix_actions: self
.fix_actions
.into_iter()
.filter_map(StoredAction::into_action)
.collect(),
}
}
}
impl StoredAction {
/// 未知维度返回 `None`:宁可少一条建议,也不瞎猜成别的维度(六维口径在 core 一处)。
fn into_action(self) -> Option<FixAction> {
let kind = match self.kind.as_str() {
"change_java" => FixActionKind::ChangeJava,
"adjust_memory" => FixActionKind::AdjustMemory,
"change_graphics" => FixActionKind::ChangeGraphics,
"remove_mod" => FixActionKind::RemoveMod,
"reinstall" => FixActionKind::Reinstall,
"seek_help" => FixActionKind::SeekHelp,
_ => return None,
};
Some(FixAction {
kind,
tool: self.tool,
label: String::new(),
args: self.args,
})
}
}
impl DiagnosePort for CoreDiagnose {
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
let entry = self.source.find(session_id)?;
let events = self.source.events(&entry)?;
let (stdout, stderr, error_lines) = SessionSource::split_text(&events);
// 会话级证据片段:仍是**脱敏**文本(隐私选择不变)。
let evidence: Vec<String> = stderr.iter().rev().take(EVIDENCE_LINES).cloned().collect();
// 盘上有结论就用它——那是写会话时在原始行上算的,不是这份脱敏文本能重算出来的。
if let Some(record) = StoredRecord::find(&events) {
return Ok(record.into_diagnosis(&entry, evidence));
}
// 老会话(M241 之前的 JSONL):回落到在脱敏文本上重跑。
let exit = ExitSignal::classify(
entry.exit_code,
Some(entry.duration_ms),
error_lines,
&stderr.join("\n"),
);
let mut input = DiagnosisInput::new().with_exit(exit);
let stdout_text = tail_join(&stdout, self.tail);
if !stdout_text.is_empty() {
input.push_text(SignalOrigin::Stdout, stdout_text);
}
let stderr_text = tail_join(&stderr, self.tail);
if !stderr_text.is_empty() {
input.push_text(SignalOrigin::Stderr, stderr_text);
}
let loader = RuleLoader::embedded();
let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec());
let report = diagnoser.analyze(&input);
Ok(CrashDiagnosis {
session_id: entry.session_id.clone(),
source: "rules".to_string(),
needs_more_info: report.needs_more_info(),
candidates: report.findings.iter().map(candidate_of).collect(),
secondary: report.secondary.iter().map(candidate_of).collect(),
evidence,
exit: ExitSummary {
code: entry.exit_code,
exit_type: entry.exit_type.clone(),
silent_failure: entry.silent_failure,
},
})
}
}
@@ -0,0 +1,187 @@
//! M242:AI 侧 `diagnose_crash` 的结论来源(落盘那份优先,老会话回落重跑)。
//!
//! 两条端到端用例都走**真链路**:真 `SessionWriter` 写盘 → 真工具注册(
//! `unlucky_agent::tools::diagnose::register`)→ 真 handler。判据是「命中哪条规则」,
//! 因为脱敏有损:在脱敏文本上重跑会**换一条**规则(`fabric.loader_0_12` →
//! `integrity.no_class_def_found`)。
//!
//! ## 夹具为什么把类路径登记成 secret
//!
//! M240 收窄了「`≥64` 不透明串」那条规则(路径形状的长串现在保留),所以 `SessionWriter`
//! 落盘时这条证据行今天**不会被抹**。要复现「读回脱敏文本就丢了证据」这个世界(M239 实测的
//! 老落盘形状),夹具把类路径登记成 secret:写盘前它变成 `<redacted:token>`,而结论是在
//! **原始行**上算的 —— 与用户盘上真实存在的那类会话同样失真,足以分辨两条路。
//!
//! 变异证明(改完已还原):
//! - 删掉 `diagnose()` 里的 `StoredRecord::find(&events)` 分支(等于每次都重跑)⇒
//! `ai_gets_the_stored_conclusion...` 红(`candidates[0].id` 变成 `integrity.no_class_def_found`)。
use std::path::Path;
use std::sync::Arc;
use serde_json::{json, Value};
use unlucky_agent::port::DiagnosePort;
use unlucky_agent::registry::{ToolCtx, ToolRegistry};
use unlucky_agent::tools::diagnose::DIAGNOSE_CRASH;
use unlucky_core::launch::exit_analysis::{self, ExitSignals};
use unlucky_core::launch::pump::{LogLine, Stream};
use unlucky_core::launch::redact::Redactor;
use unlucky_core::launch::session::{EndReason, SessionMeta, SessionWriter};
use unlucky_core::launch::session_index;
use super::CoreDiagnose;
/// `fabric.loader_0_12`(critical / priority 80)唯一的证据行(`rules.d/fabric.yaml`)里的类路径。
const CLASS_PATH: &str = "org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy";
/// 真凭据(写盘前必须被抹掉的形状)。
const TOKEN: &str = "SECRET-TOKEN-123456";
/// 那条完整的证据行。
fn evidence_line() -> String {
format!("java.lang.NoClassDefFoundError: {CLASS_PATH}")
}
/// 一个临时数据根(`<root>` 当数据根,会话根就是 `<root>`——端口只认会话根这一层)。
fn temp_root(tag: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("ull-m242-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
root
}
/// 与生产写入同形(M212/D2):`log_path` 相对会话根,事件流落在 `<根>/game/<id>.jsonl`。
fn seed(root: &std::path::Path, stderr_line: &str) {
let sid = "20261029T124501-9f3c2a";
let log_path = session_index::log_path_for(sid);
let index = session_index::index_path(root);
std::fs::create_dir_all(index.parent().expect("索引父目录")).expect("建目录");
let entry = json!({
"v": 1,
"session_id": sid,
"instance_id": "inst-1",
"instance_name": "1.20.1 Forge",
"game_version": "1.20.1",
"started_at": "2026-10-29T12:45:01.123Z",
"ended_at": "2026-10-29T12:45:05.123Z",
"duration_ms": 4000,
"exit_code": 1,
"exit_type": "application_error",
"silent_failure": false,
"window_detected": false,
"event_count": 1,
"log_path": log_path.clone(),
"crash_found": true,
"diagnosis_top": null
});
std::fs::write(index, format!("{entry}\n")).expect("写索引");
let event = json!({
"seq": 0, "ts": "2026-10-29T12:45:01.123Z", "t_ms": 0,
"kind": "stderr",
"payload": { "line": stderr_line, "level": "error" }
});
std::fs::write(
session_index::event_file(root, &log_path),
format!("{event}\n"),
)
.expect("写事件");
}
/// 一局真会话:`SessionWriter` 写盘(写前脱敏)→ `finish` 在原始行上补结论。
fn write_real_session(sessions_root: &Path) -> String {
let session_id = "20261102T091500-m242ai";
let mut writer = SessionWriter::create_with_id(
sessions_root,
session_id,
SessionMeta::new("inst-1", "1.20.1 Fabric", "1.20.1"),
// 类路径登记成 secret ⇒ 盘上那条证据行被抹掉(见模块文档:为什么要人为造这份失真)。
Redactor::new().with_secret(TOKEN).with_secret(CLASS_PATH),
)
.expect("建会话");
writer.session_start().expect("session_start");
let lines = vec![
LogLine::new(Stream::Stderr, "Failed to start Fabric loader"),
LogLine::new(Stream::Stderr, evidence_line()),
LogLine::new(Stream::Stdout, format!("auth: token={TOKEN}")),
];
for line in &lines {
writer.line(line).expect("写行");
}
let analysis = exit_analysis::analyze(&ExitSignals::new(1, 40_000, &lines));
writer.exit(&analysis).expect("exit");
writer.finish(EndReason::Exit).expect("finish");
session_id.to_string()
}
/// 走真工具入口调 `diagnose_crash`:注册表 + schema + handler 与 `ull agent serve` 同一份。
fn call_diagnose_crash(sessions_root: &Path, session_id: &str) -> Value {
let mut registry = ToolRegistry::new();
unlucky_agent::tools::diagnose::register(
&mut registry,
Arc::new(CoreDiagnose::new(sessions_root)),
)
.expect("注册 diagnose_crash");
let spec = registry.get(DIAGNOSE_CRASH).expect("工具已注册").clone();
let ctx = ToolCtx {
agent_session_id: "m242".to_string(),
confirmed: false,
};
let output =
(spec.handler)(&ctx, &json!({ "session_id": session_id })).expect("diagnose_crash 应成功");
output.content
}
#[test]
fn ai_gets_the_stored_conclusion_instead_of_a_rerun_on_redacted_text() {
let root = temp_root("ai-stored");
let session_id = write_real_session(&root);
let content = call_diagnose_crash(&root, &session_id);
assert_eq!(content["session_id"], session_id.as_str());
assert_eq!(
content["candidates"][0]["id"], "fabric.loader_0_12",
"落盘的结论必须原样交给模型(重跑会降级成 integrity.no_class_def_found):{content}"
);
assert_eq!(content["needs_more_info"], false);
let confidence = content["top_confidence"].as_f64().expect("置信度是数字");
assert!(
(confidence - 0.85).abs() < 1e-3,
"落盘结论的置信度应原样透传:{content}"
);
// 隐私选择不变:给模型的文本片段仍是脱敏的(类路径与凭据都不许出现)。
let rendered = content.to_string();
assert!(
!rendered.contains("FabricMixinTransformerProxy"),
"{rendered}"
);
assert!(!rendered.contains(TOKEN), "{rendered}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn runs_the_rule_engine_over_a_session() {
let root = temp_root("diagnose");
seed(
&root,
"java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher",
);
let port = CoreDiagnose::new(root.clone());
let report = port.diagnose("20261029T124501-9f3c2a").expect("应能诊断");
assert_eq!(report.session_id, "20261029T124501-9f3c2a");
assert_eq!(report.source, "rules");
assert_eq!(report.exit.code, Some(1));
// 老会话(没有 `diagnosis` 事件)→ 回落重跑:规则集里应有完整性类结论命中这条 stderr。
assert!(!report.candidates.is_empty(), "应至少命中一条规则");
assert_eq!(report.candidates[0].id, "integrity.no_class_def_found");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn unknown_session_is_a_readable_error() {
let root = temp_root("diagnose-none");
let port = CoreDiagnose::new(root);
let err = port.diagnose("nope").unwrap_err();
assert!(err.contains("找不到会话"), "{err}");
}
@@ -0,0 +1,279 @@
//! 会话数据的读取底座:索引(`index.jsonl`)+ 事件流(`<id>.jsonl`)。
//!
//! 三个端口([`super::session::CoreSessions`] 的列表/切片、[`super::diagnose::CoreDiagnose`]
//! 的规则跑批)共用这一份读取逻辑——「同一份 jsonl 两种读法」若各写一遍,
//! 迟早出现「列表说有 5 条事件、诊断只看到 3 条」这种对不上的账。
//!
//! 形状依据 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5。
use std::path::{Path, PathBuf};
use serde_json::Value;
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
use unlucky_agent::port::PortResult;
/// 一个数据根下的会话索引 + 事件流(可以再挂一个**只读兼容**的旧根,M188)。
#[derive(Debug, Clone)]
pub struct SessionSource {
root: PathBuf,
legacy: Option<PathBuf>,
}
impl SessionSource {
/// `root` = `<数据根>/.ULL/logs`(M188 起;旧名是 `data/agent/sessions`)。
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
legacy: None,
}
}
/// 再挂一个只读兼容的旧根(`data/agent/sessions`):旧的会话仍然列得出来、打得开。
#[must_use]
pub fn with_legacy(mut self, legacy: impl Into<PathBuf>) -> Self {
self.legacy = Some(legacy.into());
self
}
/// 读会话时要看的根,**新根在前**。
fn roots(&self) -> Vec<&Path> {
let mut roots = vec![self.root.as_path()];
if let Some(legacy) = &self.legacy {
if legacy.is_dir() {
roots.push(legacy.as_path());
}
}
roots
}
/// 索引文件路径(新根)。
#[must_use]
pub fn index(&self) -> PathBuf {
session_index::index_path(&self.root)
}
/// 全部会话(按 `started_at` 倒序,同 id 取最后一行;两个根合并,新根优先)。
///
/// # Errors
/// 索引读不了(权限/IO)时返回用户可读错误;不存在视为空列表。
pub fn entries(&self) -> PortResult<Vec<SessionIndexEntry>> {
let mut merged: Vec<SessionIndexEntry> = Vec::new();
for root in self.roots() {
let index = session_index::index_path(root);
let entries = session_index::read_all(&index)
.map_err(|error| format!("读会话索引 {} 失败:{error}", index.display()))?;
for entry in entries {
if merged
.iter()
.any(|item| item.session_id == entry.session_id)
{
continue;
}
merged.push(entry);
}
}
merged.sort_by(|a, b| b.started_at.cmp(&a.started_at));
Ok(merged)
}
/// 按完整 id 或唯一前缀找会话。
///
/// # Errors
/// 找不到 / 前缀歧义时返回用户可读错误。
pub fn find(&self, key: &str) -> PortResult<SessionIndexEntry> {
let entries = self.entries()?;
if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) {
return Ok(entry.clone());
}
let matches: Vec<&SessionIndexEntry> = entries
.iter()
.filter(|entry| entry.session_id.starts_with(key))
.collect();
match matches.as_slice() {
[only] => Ok((*only).clone()),
[] => Err(format!(
"找不到会话 `{key}`(索引:{})",
self.index().display()
)),
_ => Err(format!(
"`{key}` 匹配到 {} 个会话,请给更长的前缀",
matches.len()
)),
}
}
/// 一个会话的原始事件行(解析失败的行跳过,容忍被 kill 时截断的尾行)。
///
/// # Errors
/// 事件流文件读不了时返回用户可读错误。
pub fn events(&self, entry: &SessionIndexEntry) -> PortResult<Vec<Value>> {
let path = self.event_path(entry);
let text = std::fs::read_to_string(&path)
.map_err(|error| format!("读会话事件流 {} 失败:{error}", path.display()))?;
Ok(text
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.collect())
}
/// 事件流文件路径(新根没有时回落到旧根)。
///
/// 解析只走 [`session_index::event_file`] 一处(M212/D2):索引里的 `log_path` 已经是
/// **相对会话根**的 `game/<id>.jsonl`,这里再拼一层 `game/` 就会拼成
/// `game/game/<id>.jsonl` —— `ull agent` / MCP 的「读游戏日志」「诊断崩溃」两个工具
/// 因此对每个真实会话都报「读会话事件流失败」,夹具写裸文件名时这个 bug 测不出来。
#[must_use]
pub fn event_path(&self, entry: &SessionIndexEntry) -> PathBuf {
for root in self.roots() {
let path = session_index::event_file(root, &entry.log_path);
if path.is_file() {
return path;
}
}
// 两处都没有:返回**新根**的路径,调用方据此说出「该去哪找」。
session_index::event_file(&self.root, &entry.log_path)
}
/// 事件里的 stdout / stderr 文本(诊断与日志阅读共用)。
///
/// 返回 `(stdout 行, stderr 行, stderr 里 level=error 的条数)`。
#[must_use]
pub fn split_text(events: &[Value]) -> (Vec<String>, Vec<String>, u32) {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut error_lines = 0u32;
for event in events {
let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else {
continue;
};
match event.get("kind").and_then(Value::as_str) {
Some("stdout") => stdout.push(line.to_string()),
Some("stderr") => {
stderr.push(line.to_string());
if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") {
error_lines += 1;
}
}
_ => {}
}
}
(stdout, stderr, error_lines)
}
}
/// 取末尾 `tail` 行并拼成文本(诊断输入用)。
#[must_use]
pub fn tail_join(lines: &[String], tail: usize) -> String {
let start = lines.len().saturating_sub(tail.max(1));
let mut text = lines[start..].join("\n");
if !text.is_empty() {
text.push('\n');
}
text
}
/// 索引里有没有这个会话(存在性判断,不读事件流)。
#[must_use]
pub fn index_exists(root: &Path) -> bool {
session_index::index_path(root).is_file()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const SID: &str = "20261029T124501-9f3c2a";
fn entry(log_path: &str) -> SessionIndexEntry {
serde_json::from_value(json!({
"v": 1,
"session_id": SID,
"instance_id": "inst-1",
"instance_name": "1.20.1 Forge",
"game_version": "1.20.1",
"started_at": "2026-10-29T12:45:01.123Z",
"ended_at": "2026-10-29T12:46:31.123Z",
"duration_ms": 90000,
"exit_code": 1,
"exit_type": "application_error",
"silent_failure": false,
"window_detected": true,
"event_count": 1,
"log_path": log_path,
"crash_found": false,
"diagnosis_top": null
}))
.expect("索引条目")
}
fn temp_root(tag: &str) -> PathBuf {
let root =
std::env::temp_dir().join(format!("ull-agent-events-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("game")).expect("建临时根");
root
}
/// 抓得住:新根解析出的路径**正好**是写入侧落盘的那一个(`<根>/game/<id>.jsonl`)。
/// 抓不住:旧根回落(下一条用例单独守)与事件内容解析(`ports::session` 的用例守)。
#[test]
fn event_path_matches_what_the_writer_wrote() {
let root = temp_root("primary");
let log_path = session_index::log_path_for(SID);
let written = session_index::event_file(&root, &log_path);
std::fs::write(&written, "{\"seq\":0}\n").expect("写事件流");
let source = SessionSource::new(root.clone());
let resolved = source.event_path(&entry(&log_path));
assert_eq!(resolved, root.join("game").join(format!("{SID}.jsonl")));
assert!(
resolved.is_file(),
"解析出的路径应真的是写入侧那份:{resolved:?}"
);
let _ = std::fs::remove_dir_all(&root);
}
/// 文件暂时不在时,返回的「该去哪找」也必须是生产形状,不能多一层 `game/`。
#[test]
fn missing_event_file_still_points_at_the_production_shape() {
let root = temp_root("missing");
let log_path = session_index::log_path_for(SID);
let source = SessionSource::new(root.clone());
let resolved = source.event_path(&entry(&log_path));
assert_eq!(resolved, session_index::event_file(&root, &log_path));
assert_eq!(resolved, root.join("game").join(format!("{SID}.jsonl")));
let _ = std::fs::remove_dir_all(&root);
}
/// 旧根(`data/agent/sessions`,M188 前的生产落点)里的存量会话仍要读得回来。
#[test]
fn legacy_root_fallback_uses_the_same_resolution() {
let primary = temp_root("legacy-primary");
let legacy = temp_root("legacy-old");
let log_path = session_index::log_path_for(SID);
let legacy_file = session_index::event_file(&legacy, &log_path);
std::fs::create_dir_all(legacy_file.parent().expect("父目录")).expect("建 game/");
std::fs::write(&legacy_file, "{\"seq\":0}\n").expect("写旧根事件流");
let source = SessionSource::new(primary.clone()).with_legacy(legacy.clone());
let resolved = source.event_path(&entry(&log_path));
assert_eq!(resolved, legacy_file);
assert!(
resolved.is_file(),
"旧根里的存量会话应读得回来:{resolved:?}"
);
let _ = std::fs::remove_dir_all(&primary);
let _ = std::fs::remove_dir_all(&legacy);
}
}
+204
View File
@@ -0,0 +1,204 @@
//! 实例 / 版本 / Java 端口(M14 + M33)。
use std::path::PathBuf;
use unlucky_agent::port::game::{
GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus, VersionSummary,
};
use unlucky_agent::port::PortResult;
use unlucky_core::game::java::{self, JavaOrigin};
use unlucky_core::game::{install::instance::read_profiles, GameLayout};
use unlucky_core::platform::data_dir::DataMode;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::platform::paths::{Platform, ProcessEnv};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
/// 实例 / 版本 / Java 的只读端口。
#[derive(Debug, Clone)]
pub struct CoreGame {
layout: LauncherLayout,
game: GameLayout,
launcher_config: PathBuf,
mode: DataMode,
platform: Platform,
}
impl CoreGame {
/// 装配:`layout` 是 `.ULL/`,`game` 是 `.minecraft/`。
#[must_use]
pub fn new(
layout: LauncherLayout,
game: GameLayout,
mode: DataMode,
platform: Platform,
) -> Self {
let launcher_config = layout.launcher_config();
Self {
layout,
game,
launcher_config,
mode,
platform,
}
}
fn store(&self) -> SettingStore {
SettingStore::load(&self.launcher_config, launcher_schema())
}
fn managed_roots(&self) -> Vec<PathBuf> {
let store = self.store();
let install_root = store
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
java::location::scan_roots(
self.mode,
&self.layout,
install_root.as_deref(),
self.platform,
&ProcessEnv,
)
}
}
impl GamePort for CoreGame {
fn list_instances(&self) -> PortResult<Vec<InstanceSummary>> {
let profiles =
read_profiles(&self.layout).map_err(|error| format!("读实例清单失败:{error}"))?;
let mut out: Vec<InstanceSummary> = profiles
.profiles
.iter()
.map(|profile| InstanceSummary {
id: profile.id.as_str().to_string(),
name: profile.name.clone(),
version_id: profile.version_id.clone(),
version_present: self.game.is_version_installed(&profile.version_id),
game_dir: profile.game_dir.as_ref().map(|p| p.display().to_string()),
java_path: profile.java_path.as_ref().map(|p| p.display().to_string()),
last_played_at: profile.last_played_at.as_deref().and_then(parse_rfc3339_ms),
})
.collect();
// `profiles.json` 的顺序是 UI 顺序;这里按最近启动时间倒序(最近的先看)。
out.sort_by_key(|instance| std::cmp::Reverse(instance.last_played_at));
Ok(out)
}
fn list_versions(&self) -> PortResult<Vec<VersionSummary>> {
let versions = self
.game
.installed_versions()
.map_err(|error| format!("读已安装版本失败:{error}"))?;
let profiles = read_profiles(&self.layout).map_err(|error| error.to_string())?;
Ok(versions
.into_iter()
.map(|id| {
let instance_count = profiles
.profiles
.iter()
.filter(|profile| profile.version_id == id)
.count();
VersionSummary {
id,
kind: None,
instance_count,
}
})
.collect())
}
fn java_status(&self) -> PortResult<JavaStatus> {
let roots = self.managed_roots();
let mut candidates = java::candidates_in_roots(&roots);
if let Ok(profiles) = read_profiles(&self.layout) {
for profile in &profiles.profiles {
if let Some(path) = &profile.java_path {
candidates.push(java::probe(path, JavaOrigin::Instance));
}
}
}
let candidates = java::discover::dedupe(candidates);
let summary: Vec<JavaRuntimeSummary> = candidates
.iter()
.map(|runtime| JavaRuntimeSummary {
binary: runtime.binary.display().to_string(),
version: runtime.version.clone(),
major: runtime.major,
vendor: runtime.vendor.clone(),
origin: runtime.origin.category().to_string(),
})
.collect();
let default_memory = self
.store()
.effective("java.default_memory_mb")
.and_then(serde_json::Value::as_u64);
Ok(JavaStatus {
default_memory_mb: default_memory,
managed_roots: roots
.iter()
.map(|root| root.display().to_string())
.collect(),
candidates: summary,
})
}
}
/// RFC3339 → Unix 毫秒(解析失败返回 `None`,不让一个坏时间戳毁掉整个列表)。
///
/// 只处理 launch 写出来的那一种形态(总是 UTC、总是 `Z` 结尾)。不引第三方日期库:
/// agent 侧只需要「排序用的毫秒」,多一棵依赖树不值当。
fn parse_rfc3339_ms(text: &str) -> Option<u64> {
let body = text.trim().trim_end_matches('Z');
let (date, time) = body.split_once('T')?;
let mut date_parts = date.split('-');
let year: i64 = date_parts.next()?.parse().ok()?;
let month: i64 = date_parts.next()?.parse().ok()?;
let day: i64 = date_parts.next()?.parse().ok()?;
let mut time_parts = time.split(':');
let hour: i64 = time_parts.next()?.parse().ok()?;
let minute: i64 = time_parts.next()?.parse().ok()?;
let seconds_text = time_parts.next()?;
let (secs, millis) = match seconds_text.split_once('.') {
Some((secs, frac)) => (secs.parse::<i64>().ok()?, {
let padded = format!("{frac:0<3}");
padded.get(..3)?.parse::<i64>().ok()?
}),
None => (seconds_text.parse::<i64>().ok()?, 0),
};
let days = days_from_civil(year, month, day);
let total_ms = (((days * 24 + hour) * 60 + minute) * 60 + secs) * 1000 + millis;
u64::try_from(total_ms).ok()
}
/// Howard Hinnant 的 `days_from_civil`(1970-01-01 为 0)。
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (month + 9) % 12;
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_launch_timestamps() {
assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00.000Z"), Some(0));
// 与既有审计测试同源:1_793_304_753_123 = 2026-10-29T20:12:33.123Z
assert_eq!(
parse_rfc3339_ms("2026-10-29T20:12:33.123Z"),
Some(1_793_304_753_123)
);
assert_eq!(parse_rfc3339_ms("not-a-time"), None);
}
}
@@ -0,0 +1,62 @@
//! 端口装配:把 `unlucky-core` 的实现塞进 agent 的五个端口。
//!
//! 为什么这一层在 CLI 而不在 agent:agent 只知道 trait(`#!` 依赖倒置),
//! `crates/app` 是 GUI 的装配处,而 `ull agent serve` 是**headless 的另一个装配入口**。
//! 放这里让 CLI 自洽,也让 GUI 后续复用同一批适配器(把本目录上移到 app 是一次纯搬家)。
//!
//! 每个文件一个端口,与 `crates/agent/src/port*.rs` 一一对应,读起来不用跳来跳去。
pub mod accounts;
pub mod diagnose;
pub mod events;
pub mod game;
pub mod session;
pub mod setting;
use std::sync::Arc;
use unlucky_agent::port::Ports;
use unlucky_core::platform::data_dir::DataMode;
use unlucky_core::platform::paths::Platform;
use crate::context::Ctx;
pub use accounts::CoreAccounts;
pub use diagnose::CoreDiagnose;
pub use game::CoreGame;
pub use session::CoreSessions;
pub use setting::CoreSetting;
/// 按一次 CLI 上下文装配五个端口。
#[must_use]
pub fn build(ctx: &Ctx) -> Ports {
Ports {
// 数据根键(`core.*`)落便携目录那份,与 `Ctx::resolve` 同源——CLI `config set`
// 与 AI `set_config` 共用 `Ctx` 给出的两个落点(finding …-agent-settingport-core-ai)。
setting: Arc::new(CoreSetting::split(
ctx.launcher_config(),
ctx.portable_launcher_config(),
)),
sessions: Arc::new(
CoreSessions::new(ctx.sessions_root()).with_legacy(ctx.legacy_sessions_root()),
),
diagnose: Arc::new(
CoreDiagnose::new(ctx.sessions_root()).with_legacy(ctx.legacy_sessions_root()),
),
game: Arc::new(CoreGame::new(
ctx.layout(),
ctx.game(),
data_mode(ctx),
platform(ctx),
)),
accounts: Arc::new(CoreAccounts::new(ctx.auth_data())),
}
}
pub(crate) fn data_mode(ctx: &Ctx) -> DataMode {
ctx.data.mode
}
pub(crate) fn platform(ctx: &Ctx) -> Platform {
ctx.platform
}
@@ -0,0 +1,213 @@
//! 会话端口:索引 → `list_game_sessions`,事件流切片 → `read_game_log`。
use serde_json::{json, Value};
use unlucky_agent::port::{
GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult,
};
use super::events::SessionSource;
/// M18 的会话索引 + 事件流。
#[derive(Debug, Clone)]
pub struct CoreSessions {
source: SessionSource,
}
impl CoreSessions {
/// `root` = `<数据根>/.ULL/logs`(M188 起;`LauncherConfig` 在其下建 `game/`)。
#[must_use]
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self {
source: SessionSource::new(root),
}
}
/// 再挂一个只读兼容的旧根(`<数据根>/.ULL/data/agent/sessions`,M188)。
#[must_use]
pub fn with_legacy(mut self, legacy: impl Into<std::path::PathBuf>) -> Self {
self.source = self.source.with_legacy(legacy);
self
}
}
fn summary_of(
entry: &unlucky_core::launch::session_index::SessionIndexEntry,
) -> GameSessionSummary {
GameSessionSummary {
session_id: entry.session_id.clone(),
instance_id: Some(entry.instance_id.clone()).filter(|id| !id.is_empty()),
instance_name: Some(entry.instance_name.clone()).filter(|name| !name.is_empty()),
game_version: Some(entry.game_version.clone()).filter(|v| !v.is_empty()),
started_at: Some(entry.started_at.clone()).filter(|v| !v.is_empty()),
ended_at: Some(entry.ended_at.clone()).filter(|v| !v.is_empty()),
duration_ms: entry.duration_ms,
exit_code: entry.exit_code,
exit_type: entry.exit_type.clone(),
silent_failure: entry.silent_failure,
window_detected: entry.window_detected,
event_count: entry.event_count,
crash_found: entry.crash_found,
diagnosis_top: entry.diagnosis_top.clone(),
}
}
fn to_log_event(event: &Value, fallback_seq: u64) -> LogEvent {
LogEvent {
seq: event
.get("seq")
.and_then(Value::as_u64)
.unwrap_or(fallback_seq),
ts: event
.get("ts")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
t_ms: event.get("t_ms").and_then(Value::as_u64).unwrap_or(0),
kind: event
.get("kind")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
payload: event.get("payload").cloned().unwrap_or(Value::Null),
}
}
impl GameSessionPort for CoreSessions {
fn list_sessions(
&self,
limit: usize,
instance_id: Option<&str>,
) -> PortResult<Vec<GameSessionSummary>> {
let mut entries = self.source.entries()?;
if let Some(instance) = instance_id {
entries.retain(|entry| entry.instance_id == instance);
}
entries.truncate(limit);
Ok(entries.iter().map(summary_of).collect())
}
fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult<LogPage> {
let entry = self.source.find(session_id)?;
let raw = self.source.events(&entry)?;
let events: Vec<LogEvent> = raw
.iter()
.enumerate()
.map(|(index, event)| to_log_event(event, index as u64))
.collect();
let mut kind_counts = std::collections::BTreeMap::new();
for event in &events {
*kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1;
}
let matched: Vec<&LogEvent> = events
.iter()
.filter(|e| query.from_seq.is_none_or(|from| e.seq >= from))
.filter(|e| query.to_seq.is_none_or(|to| e.seq <= to))
.filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind))
.collect();
let max = if query.max_events == 0 {
matched.len()
} else {
query.max_events
};
let truncated = matched.len() > max;
Ok(LogPage {
session_id: entry.session_id,
total_events: events.len() as u64,
matched_events: matched.len() as u64,
truncated,
kind_counts,
first_ts: events.first().map(|e| e.ts.clone()),
last_ts: events.last().map(|e| e.ts.clone()),
events: matched.into_iter().take(max).cloned().collect(),
})
}
}
/// 会话摘要 + 事件流 → JSON 里用得到的稳定形状(`read_game_log` 的 payload 直接透传)。
#[must_use]
pub fn log_event_json(event: &LogEvent) -> Value {
json!({
"seq": event.seq,
"ts": event.ts,
"t_ms": event.t_ms,
"kind": event.kind,
"payload": event.payload,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use unlucky_core::launch::session_index;
/// 生产写入侧真会写出的形状(M212/D2):`log_path` 相对**会话根**,事件流真落点
/// `<根>/game/<id>.jsonl`。夹具以前写裸文件名、把事件流也放在 `game/` 下,
/// 与生产只差一层 `game/` —— 正是这一层让「读取侧多 join 一层」永远测不出来。
fn seed_session(root: &Path) {
let log_path = session_index::log_path_for("20261029T124501-9f3c2a");
let index = session_index::index_path(root);
std::fs::create_dir_all(index.parent().expect("索引父目录")).expect("建目录");
let entry = json!({
"v": 1,
"session_id": "20261029T124501-9f3c2a",
"instance_id": "inst-1",
"instance_name": "1.20.1 Forge",
"game_version": "1.20.1",
"started_at": "2026-10-29T12:45:01.123Z",
"ended_at": "2026-10-29T12:46:31.123Z",
"duration_ms": 90000,
"exit_code": 1,
"exit_type": "application_error",
"silent_failure": false,
"window_detected": true,
"event_count": 2,
"log_path": log_path.clone(),
"crash_found": true,
"diagnosis_top": "integrity.forge_missing_library"
});
std::fs::write(index, format!("{entry}\n")).expect("写索引");
let events = [
json!({"seq":0,"ts":"2026-10-29T12:45:01.123Z","t_ms":0,"kind":"stdout","payload":{"line":"hi","level":"info"}}),
json!({"seq":1,"ts":"2026-10-29T12:45:02.123Z","t_ms":1000,"kind":"stderr","payload":{"line":"boom","level":"error"}}),
];
let text: String = events
.iter()
.map(|event| format!("{event}\n"))
.collect::<String>();
std::fs::write(session_index::event_file(root, &log_path), text).expect("写事件");
}
#[test]
fn lists_and_slices_a_session() {
let root = std::env::temp_dir().join(format!("ull-mcp-sessions-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
seed_session(&root);
let port = CoreSessions::new(root.clone());
let sessions = port.list_sessions(10, None).expect("应能列出");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].exit_code, Some(1));
assert!(sessions[0].crash_found);
let page = port
.read_log(
"20261029T124501",
&LogQuery {
max_events: 1,
kinds: vec!["stderr".to_string()],
..LogQuery::default()
},
)
.expect("应能读");
assert_eq!(page.total_events, 2);
assert_eq!(page.events.len(), 1);
assert_eq!(page.events[0].kind, "stderr");
assert!(page.kind_counts.contains_key("stdout"));
let _ = std::fs::remove_dir_all(&root);
}
}
@@ -0,0 +1,211 @@
//! 配置端口:`core::setting` 的直译(**唯一真相源**在 core,这里不复制校验规则)。
//!
//! 三条纪律:
//! 1. 每次调用都重新 `SettingStore::load`——外部 MCP 客户端可能刚改了配置,
//! 缓存一份快照会让审计里的 `old` 骗人;
//! 2. **敏感值一律掩码**(M12 `Sensitivity::High` + agent 的凭据词表两道):
//! 工具结果会回灌模型、也可能发往远端 LLM,AI 只需要知道「这个键是什么、怎么改」;
//! 3. **落点与 `ull config` 同源**(分流规则仍只在 `crate::context` 一处):数据根键
//! 读写便携目录那份 `launcher.json`,其余键读写解析后的数据根。只持一份路径时
//! standard / `--data-dir` 模式下 AI 改 `core.data_mode` 会「写了没反应」
//! (finding 20261031-worker-chore40-improve-agent-settingport-core-ai)。
use std::path::{Path, PathBuf};
use serde_json::{json, Map, Value};
use unlucky_agent::confirm::Sensitivity;
use unlucky_agent::port::{PortResult, SettingChange, SettingKey, SettingPort};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::spec::{Sensitivity as CoreSensitivity, ValueKind};
use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason};
use crate::context::is_data_root_key;
/// 掩码占位(与 CLI `config get` 同一串,读输出的人不用学两套)。
pub const MASK: &str = "<redacted>";
/// 把 `core::setting` 包成 agent 的 [`SettingPort`]。
#[derive(Debug, Clone)]
pub struct CoreSetting {
/// 解析后的数据根那份 `launcher.json`(非数据根键的落点)。
path: PathBuf,
/// 便携目录那份 `launcher.json`(数据根键的落点);与 `path` 重合或拿不到
/// exe 目录时为 `None`。
core_path: Option<PathBuf>,
}
impl CoreSetting {
/// 只指向数据根那份 `<数据根>/.ULL/config/launcher.json`(两份落点重合时够用)。
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self::split(path, None)
}
/// 数据根那份 + 便携目录那份(`Ctx::portable_launcher_config`,与数据根解析同源)。
///
/// 两份路径重合(便携模式)时只留一份,免得同一个文件被加载两次。
#[must_use]
pub fn split(path: impl Into<PathBuf>, core_path: Option<PathBuf>) -> Self {
let path = path.into();
let core_path = core_path.filter(|core| core.as_path() != path.as_path());
Self { path, core_path }
}
/// 某个键的落点文件(数据根键 → 便携目录那份,其余 → 数据根那份)。
fn path_for(&self, key: &str) -> &Path {
match &self.core_path {
Some(core) if is_data_root_key(key) => core.as_path(),
_ => self.path.as_path(),
}
}
/// 按落点加载 store(读写都用它,`get`/`set` 不会各看一份文件)。
fn store_for(&self, key: &str) -> SettingStore {
SettingStore::load(self.path_for(key), launcher_schema())
}
/// 数据根那份(schema / 白名单等与落点无关的元数据都从它出)。
fn store(&self) -> SettingStore {
SettingStore::load(&self.path, launcher_schema())
}
}
/// `summary` 里某个键的**原始**取值来源(掩码在 `summary` 里做):数据根键取便携目录
/// 那份——也就是 `Ctx::resolve` 读的同一份,否则概要和「真正生效值」是两套真相。
fn raw_value<'a>(
data: &'a SettingStore,
core: Option<&'a SettingStore>,
key: &str,
) -> Option<&'a Value> {
match core {
Some(core) if is_data_root_key(key) => core.effective(key),
_ => data.effective(key),
}
}
fn sensitivity_of(value: CoreSensitivity) -> Sensitivity {
match value {
CoreSensitivity::Low => Sensitivity::Low,
CoreSensitivity::Medium => Sensitivity::Medium,
CoreSensitivity::High => Sensitivity::High,
}
}
fn kind_label(kind: &ValueKind) -> &'static str {
match kind {
ValueKind::Bool => "boolean",
ValueKind::Int { .. } => "integer",
ValueKind::Float { .. } => "number",
ValueKind::Text { .. } => "string",
ValueKind::Path => "path",
ValueKind::Enum(_) => "enum",
ValueKind::TextList => "string[]",
ValueKind::EnumList(_) => "enum[]",
ValueKind::Any => "json",
}
}
/// 该键的值要不要在 AI 侧掩码:凭据词表命中,或 schema 标 `High`。
fn mask(value: Value, sensitive: bool) -> Value {
if sensitive {
json!(MASK)
} else {
value
}
}
impl SettingPort for CoreSetting {
/// 全量概要:每个键的生效值都取自**它自己的落点文件**(数据根键取便携目录那份)。
fn summary(&self) -> Value {
let store = self.store();
let core = self
.core_path
.as_ref()
.map(|path| SettingStore::load(path, launcher_schema()));
let schema = store.doc().schema().clone();
let mut map = Map::new();
for spec in schema.specs() {
if spec.is_deprecated() {
continue;
}
let sensitive = spec.ai.sensitivity == CoreSensitivity::High
|| unlucky_agent::redact::is_sensitive_key(spec.key);
let value = raw_value(&store, core.as_ref(), spec.key)
.cloned()
.unwrap_or(Value::Null);
map.insert(spec.key.to_string(), mask(value, sensitive));
}
Value::Object(map)
}
fn get(&self, path: &str) -> PortResult<Value> {
if unlucky_agent::redact::is_sensitive_key(path) {
return Err(format!(
"`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看"
));
}
let store = self.store_for(path);
let schema = store.doc().schema().clone();
let Some(value) = store.effective(path).cloned() else {
return Err(format!("配置键 `{path}` 不在 schema 里"));
};
let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High);
Ok(mask(value, sensitive))
}
fn set(&self, path: &str, value: &Value) -> PortResult<SettingChange> {
let file = self.path_for(path);
let mut store = self.store_for(path);
let schema = store.doc().schema().clone();
let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High);
let old = store
.effective(path)
.cloned()
.map(|old| mask(old, sensitive));
store
.set(path, value.clone())
.map_err(|error| error.to_string())?;
match store.save() {
Ok(SaveOutcome::Saved(_)) | Ok(SaveOutcome::Skipped(SkipReason::NotDirty)) => {}
// 只读/不可信:**不能**报成功——外部客户端会以为改完了。
Ok(SaveOutcome::Skipped(reason)) => {
return Err(format!(
"{} 当前不可写({});请让用户检查文件权限或内容",
file.display(),
reason.as_str()
))
}
Err(error) => return Err(format!("写 {} 失败:{error}", file.display())),
}
Ok(SettingChange {
path: path.to_string(),
old,
new: mask(value.clone(), sensitive),
})
}
fn writable_keys(&self) -> Vec<SettingKey> {
let store = self.store();
let schema = store.doc().schema().clone();
schema
.specs()
.iter()
.filter(|spec| spec.ai.writable && !spec.is_deprecated())
.map(|spec| SettingKey {
path: spec.key.to_string(),
sensitivity: sensitivity_of(spec.ai.sensitivity),
value_type: kind_label(&spec.kind).to_string(),
description: spec.doc.to_string(),
})
.collect()
}
fn schema(&self) -> Value {
// core 的 `to_json_schema()` 只遍历 `ai.writable && !deprecated`,
// 凭据键(`locked`)天然不在其中——这正是 `get_config_schema` 要的东西。
self.store().doc().schema().to_json_schema()
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,226 @@
//! [`super`] 的单测:数据根键与其余键的落点分流、AI 侧写入真的生效。
//!
//! 与 `commands/config/tests.rs` 同一套造场手法:临时目录 + `Ctx::resolve_in`
//! 把便携目录钉死在临时路径里,不动真实数据根。
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
use crate::cli::Globals;
use crate::commands::agent::ports::build;
use crate::context::Ctx;
fn temp_config() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-core-setting-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir.join("launcher.json")
}
#[test]
fn schema_omits_credential_keys_and_writable_keys_match_policy() {
let port = CoreSetting::new(temp_config());
let schema = port.schema();
let text = schema.to_string();
assert!(!text.contains("ai.api_key"), "凭据键不得进 AI schema");
assert!(text.contains("agent.mcp.port"), "M39 的新键应可写:{text}");
let paths: Vec<String> = port.writable_keys().into_iter().map(|k| k.path).collect();
assert!(paths.contains(&"download.concurrency".to_string()));
assert!(!paths.contains(&"ai.api_key".to_string()));
}
#[test]
fn summary_masks_high_sensitivity_but_keeps_low() {
let port = CoreSetting::new(temp_config());
let summary = port.summary();
assert_eq!(summary["ai.base_url"], json!(MASK), "High 键要掩码");
assert_ne!(
summary["download.concurrency"],
json!(MASK),
"Low 键不必掩码"
);
}
#[test]
fn credential_key_get_is_refused() {
let port = CoreSetting::new(temp_config());
let err = port.get("ai.api_key").unwrap_err();
assert!(err.contains("凭据类配置"), "{err}");
}
#[test]
fn set_then_get_round_trips_a_low_key() {
let path = temp_config();
let port = CoreSetting::new(path.clone());
let change = port
.set("download.concurrency", &json!(16))
.expect("应写入");
assert_eq!(change.new, json!(16));
assert_eq!(port.get("download.concurrency").unwrap(), json!(16));
assert!(path.is_file(), "应落盘 {}", path.display());
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("ull-ai-setting-{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 正确的 `$schema`(认不出的前缀会让加载降级成只读)。
fn schema_url() -> String {
use unlucky_core::setting::keys::launcher_version;
use unlucky_core::setting::schema::{SchemaId, SchemaRef};
SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url()
}
fn write_json(path: &Path, value: &Value) {
std::fs::create_dir_all(path.parent().expect("有父目录")).expect("建目录");
std::fs::write(path, value.to_string()).expect("写配置");
}
fn read_json(path: &Path) -> Value {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读 {} 失败:{e}", path.display()));
serde_json::from_slice(&bytes).expect("合法 JSON")
}
/// standard 模式现场:便携目录那份说 standard + 自定义 launcher home(数据根留在临时目录里)。
fn standard_fixture(temp: &TempDir) -> (PathBuf, PathBuf, PathBuf) {
let exe = temp.path().join("exe");
let launcher_home = temp.path().join("lhome");
let portable = exe.join(".ULL").join("config").join("launcher.json");
write_json(
&portable,
&json!({
"$schema": schema_url(),
"schemaVersion": 1,
"core": {
"data_mode": "standard",
"launcher_home": launcher_home.display().to_string(),
},
}),
);
(exe, portable, launcher_home)
}
/// **AI 面改 `core.data_mode` 真的生效**:写便携目录那份,下一次解析换模式。
///
/// 走的是 `ports::build` 装出来的同一个端口(`set_config` 工具背后就是它)。
#[test]
fn ai_set_of_core_data_mode_takes_effect_on_the_next_resolve() {
let temp = TempDir::new("core-ai");
let (exe, portable, launcher_home) = standard_fixture(&temp);
let ctx = Ctx::resolve_in(&Globals::default(), Some(exe.clone())).expect("解析数据根");
assert_eq!(ctx.data.mode.as_str(), "standard");
let ports = build(&ctx);
ports
.setting
.set("core.data_mode", &json!("portable"))
.expect("AI 侧写 core.data_mode");
assert_eq!(
read_json(&portable)["core"]["data_mode"],
json!("portable"),
"数据根键要落在便携目录那份"
);
let data_file = launcher_home.join("config").join("launcher.json");
assert!(
!data_file.exists() || read_json(&data_file)["core"].get("data_mode").is_none(),
"数据根那份不该被写入 core.data_mode"
);
let next = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("重新解析");
assert_eq!(
next.data.mode.as_str(),
"portable",
"改了没反应到此为止:下一次解析必须换模式"
);
// 其余键仍走数据根那份,且不污染便携目录那份。
ports
.setting
.set("download.concurrency", &json!(16))
.expect("AI 侧写普通键");
assert_eq!(read_json(&data_file)["download"]["concurrency"], json!(16));
assert!(
read_json(&portable)["download"]
.get("concurrency")
.is_none(),
"普通键不该写进便携目录那份"
);
}
/// 概要的取值来源与解析同源:数据根键取便携那份(哪怕数据根那份留着陈旧值)。
#[test]
fn summary_takes_data_root_keys_from_the_portable_file() {
let temp = TempDir::new("core-summary");
let (exe, portable, launcher_home) = standard_fixture(&temp);
// 老版本 AI 写错文件留下的现场:数据根那份有个陈旧的 core.data_mode。
let data_file = launcher_home.join("config").join("launcher.json");
write_json(
&data_file,
&json!({
"$schema": schema_url(),
"schemaVersion": 1,
"core": { "data_mode": "custom" },
"download": { "concurrency": 4 },
}),
);
let ctx = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("解析数据根");
let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config());
let data = port.store();
let core = SettingStore::load(&portable, launcher_schema());
assert_eq!(
raw_value(&data, Some(&core), "core.data_mode"),
Some(&json!("standard")),
"数据根键取便携那份 = 解析读到的那份"
);
assert_eq!(
raw_value(&data, Some(&core), "download.concurrency"),
Some(&json!(4)),
"其余键仍取数据根那份"
);
// `core.*` 是 M12 的 High 键,概要里照旧掩码(值不可见,但来源必须对)。
assert_eq!(port.summary()["core.data_mode"], json!(MASK));
}
/// 便携模式下两份落点本就是同一份文件:不留第二份,免得重复加载。
#[test]
fn portable_mode_keeps_a_single_landing_file() {
let temp = TempDir::new("core-portable");
let ctx =
Ctx::resolve_in(&Globals::default(), Some(temp.path().to_path_buf())).expect("解析数据根");
assert_eq!(ctx.data.mode.as_str(), "portable");
let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config());
assert_eq!(
port.path_for("core.data_mode"),
ctx.launcher_config().as_path()
);
}
+361
View File
@@ -0,0 +1,361 @@
//! 「测试连接」(M174):拿 `config/launcher.json` 里的 AI 四项,走**现有 provider 实现**
//! ([`RigProvider`])发一次最小请求,并把失败分成四类。
//!
//! ## 分类从哪来(r1 复审 P2-5 之后)
//!
//! **不在本模块猜文本**:分类由 `unlucky_agent::brain::rig::map_error` 从 rig 0.45 的结构化
//! 报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成
//! [`ProviderFailure`],本模块只做「枚举 → 界面结论」的映射:
//!
//! | [`ProviderFailure`] | 本模块产出 |
//! |---|---|
//! | `Auth`(401/403) | [`ProbeOutcome::Auth`] |
//! | `ModelNotFound`(404 / `model_not_found`) | [`ProbeOutcome::ModelNotFound`] |
//! | `Unreachable`(传输层:连接被拒 / DNS / TLS) | [`ProbeOutcome::Unreachable`] |
//! | `Timeout`(8 秒时限 / 传输层超时) | [`ProbeOutcome::Timeout`] |
//! | `NotConfigured`(没发请求) | [`ProbeOutcome::NotConfigured`] |
//! | `Other`(解析 / 5xx / 未知) | [`ProbeOutcome::Failed`] |
//!
//! ## 三条硬纪律
//!
//! 1. **配置不完整时一个请求都不发**:[`ProbeOutcome::NotConfigured`] 在造 provider **之前**返回,
//! 连工厂都不会被调用(守卫②:假 provider 的构造计数必须是 0)。
//! 2. **密钥永不外流**:`detail` 出本模块前先过 [`unlucky_agent::redact::redact_text`],再把
//! **本次的密钥原文**替换成 [`MASK`]——即使服务端把 key 回显在错误体里,界面上也看不到它。
//! [`AiSettings`] 的 `Debug` 是手写的(掩码),[`ProbeOutcome`] 里没有密钥字段。
//! 3. **每次现读文件**:[`probe_file`] 读的是「现在这一份」,所以设置页保存完再测,测的就是刚
//! 存下的 `ai.base_url`(不是启动时缓存的旧值)。
//!
//! 失败结论**一律带上被请求的 target**(r1 复审 P2-1):只说「鉴权失败」而不说「对哪个端点、
//! 哪个模型」,用户改完 key 也没法确认自己改的是不是那一份配置。
use std::path::Path;
use std::time::Duration;
use unlucky_agent::brain::provider::{
ProviderConfig, ProviderError, ProviderFailure, ProviderKind,
};
use unlucky_agent::brain::rig::RigProvider;
use unlucky_agent::brain::{ChatMessage, ChatRequest, NoopObserver, ProviderFactory};
use unlucky_agent::redact::{redact_text, MASK};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
/// 一次「测试连接」的默认时限。
///
/// 8 秒:够一个跨洋 HTTPS 往返 + 服务端首 token;再长用户会以为按钮没反应。
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8);
/// 探测请求只发这一句:够触发一次真实补全,又不烧 token。
const PING: &str = "ping";
/// 从设置文件读出来的 AI 四项(快照)。
///
/// `api_key` 在这里是**明文**(要用它发请求),所以 `Debug` 手写成掩码。
#[derive(Clone, PartialEq, Eq)]
pub struct AiSettings {
/// `ai.enabled`
pub enabled: bool,
/// `ai.provider`(设置词表:`openai_compatible` / `anthropic` / `ollama` / `disabled`)
pub provider: String,
/// `ai.base_url`(空 = 用该形态的默认端点)
pub base_url: String,
/// `ai.model`
pub model: String,
/// `ai.api_key`(空/缺 = 没配置)
pub api_key: Option<String>,
}
impl std::fmt::Debug for AiSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AiSettings")
.field("enabled", &self.enabled)
.field("provider", &self.provider)
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("api_key", &self.api_key.as_deref().map(|_| MASK))
.finish()
}
}
/// 缺哪一项配置(`NotConfigured` 的载荷:界面上要说清楚「差什么」)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Missing {
/// `ai.enabled=false` 或 `ai.provider=disabled`
Disabled,
/// `ai.provider` 是空/未知值
Provider,
/// 该形态需要端点,但 `ai.base_url` 是空的
BaseUrl,
/// `ai.model` 是空的
Model,
/// 该形态(Anthropic)必须有密钥,但 `ai.api_key` 是空的
ApiKey,
}
impl Missing {
/// 稳定名(CLI 输出 / 日志 / 测试断言用;界面走 i18n)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Provider => "provider",
Self::BaseUrl => "base_url",
Self::Model => "model",
Self::ApiKey => "api_key",
}
}
}
/// 被请求的那一份配置(端点 + 模型):失败结论也要带它,用户才知道「改的是不是这一份」。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
/// 实际请求的端点(`resolved_base_url`)。
pub base_url: String,
/// 实际请求的模型名。
pub model: String,
}
/// 一次探测的结论(**不含密钥**;`detail` 已脱敏,可直接上屏/进日志)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome {
/// 配置不完整:**没有发出任何请求**(因此也没有 target)。
NotConfigured {
/// 缺的是哪一项。
missing: Missing,
},
/// 通了(拿到一次真实补全的回复)。
Passed {
/// 实际请求的目标。
target: Target,
},
/// 鉴权失败(401 / 403)。
Auth {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。
detail: String,
},
/// 模型不存在(404 / `model_not_found`)。
ModelNotFound {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。
detail: String,
},
/// 端点不可达(连接被拒 / DNS / TLS)。
Unreachable {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。
detail: String,
},
/// 超时。
Timeout {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因(含时限)。
detail: String,
},
/// 其它(协议 / 解析 / 5xx):不硬塞进上面四类。
Failed {
/// 实际请求的目标。
target: Target,
/// 脱敏后的原因。
detail: String,
},
}
impl ProbeOutcome {
/// 是否「通」。
#[must_use]
pub fn is_pass(&self) -> bool {
matches!(self, Self::Passed { .. })
}
/// 稳定名(`passed` / `auth` / `model_not_found` / `unreachable` / `timeout` /
/// `not_configured` / `failed`)。
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::NotConfigured { .. } => "not_configured",
Self::Passed { .. } => "passed",
Self::Auth { .. } => "auth",
Self::ModelNotFound { .. } => "model_not_found",
Self::Unreachable { .. } => "unreachable",
Self::Timeout { .. } => "timeout",
Self::Failed { .. } => "failed",
}
}
/// 这条结论是关于哪个端点/模型的(`NotConfigured` 没有)。
#[must_use]
pub fn target(&self) -> Option<&Target> {
match self {
Self::NotConfigured { .. } => None,
Self::Passed { target }
| Self::Auth { target, .. }
| Self::ModelNotFound { target, .. }
| Self::Unreachable { target, .. }
| Self::Timeout { target, .. }
| Self::Failed { target, .. } => Some(target),
}
}
}
impl AiSettings {
/// 从设置文件读四项(文件缺失/坏掉时给「全默认」= 未配置,不报错)。
#[must_use]
pub fn load(path: &Path) -> Self {
let store = SettingStore::load(path, launcher_schema());
let doc = store.doc();
let text = |key: &str| doc.effective_str(key).unwrap_or_default().to_string();
let api_key = doc
.effective_str("ai.api_key")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Self {
enabled: doc.effective_bool("ai.enabled").unwrap_or(true),
provider: text("ai.provider"),
base_url: text("ai.base_url"),
model: text("ai.model"),
api_key,
}
}
/// 四项 → [`ProviderConfig`];缺项时给出**具体缺哪一项**(不发请求)。
///
/// # Errors
/// 禁用、端点/模型/密钥缺失或 `ai.provider` 不是已知取值时返回 [`Missing`]。
pub fn to_config(&self) -> Result<ProviderConfig, Missing> {
if !self.enabled {
return Err(Missing::Disabled);
}
let Some(kind) = ProviderKind::from_setting(&self.provider) else {
return Err(Missing::Provider);
};
if kind == ProviderKind::Disabled {
return Err(Missing::Disabled);
}
let base_url = self.base_url.trim();
if base_url.is_empty() && kind.default_base_url().is_empty() {
return Err(Missing::BaseUrl);
}
let model = self.model.trim();
if model.is_empty() {
return Err(Missing::Model);
}
if kind.requires_api_key() && self.api_key.is_none() {
return Err(Missing::ApiKey);
}
let mut config = match kind {
ProviderKind::OpenAiCompatible => ProviderConfig::openai_compatible(base_url, model),
ProviderKind::Anthropic => ProviderConfig::anthropic(model),
ProviderKind::Ollama => ProviderConfig::ollama(model),
// 上面已经拦掉;留着是因为穷尽匹配比 `_ =>` 值钱(加形态时编译器会提醒)。
ProviderKind::Disabled => return Err(Missing::Disabled),
};
if !base_url.is_empty() {
config.base_url = base_url.to_string();
}
if let Some(key) = self.api_key.as_deref() {
config = config.with_api_key(key);
}
Ok(config)
}
/// 本次的密钥(脱敏用;空值给 `None`)。
fn secret(&self) -> Option<&str> {
self.api_key.as_deref().filter(|key| !key.trim().is_empty())
}
}
/// 读设置文件并测一次连接(真 provider、真网络、带时限)。
#[must_use]
pub fn probe_file(path: &Path, timeout: Duration) -> ProbeOutcome {
probe_with(&RigProvider::factory(), &AiSettings::load(path), timeout)
}
/// 测一次连接(工厂可注入:单测用计数假件证明「未配置时一次都没发」)。
///
/// # Panics
///
/// 在已有 tokio 运行时上下文里调用会 panic(`core::download::blocking::block_on` 的既有约定);
/// 本函数的两个调用方(GUI 的 thread-pool 任务、CLI)都不在运行时里。
#[must_use]
pub fn probe_with(
factory: &ProviderFactory,
settings: &AiSettings,
timeout: Duration,
) -> ProbeOutcome {
let config = match settings.to_config() {
Ok(config) => config,
Err(missing) => return ProbeOutcome::NotConfigured { missing },
};
let target = Target {
base_url: config.resolved_base_url().to_string(),
model: config.model.clone(),
};
let provider = match factory(&config) {
Ok(provider) => provider,
Err(error) => return outcome_from_error(&error, settings, target),
};
let request = ChatRequest {
model: target.model.clone(),
messages: vec![ChatMessage::user(PING)],
tools: Vec::new(),
temperature: Some(0.0),
max_tokens: Some(16),
};
unlucky_core::download::blocking::block_on(async move {
match tokio::time::timeout(timeout, provider.complete(&request, &NoopObserver)).await {
Ok(Ok(_reply)) => ProbeOutcome::Passed { target },
Ok(Err(error)) => outcome_from_error(&error, settings, target),
Err(_) => ProbeOutcome::Timeout {
target,
detail: format!("{} s", timeout.as_secs_f32()),
},
}
})
}
/// provider 错误 → 分类结论(`detail` 先脱敏再出门;分类来自 [`ProviderError::kind`])。
fn outcome_from_error(
error: &ProviderError,
settings: &AiSettings,
target: Target,
) -> ProbeOutcome {
let detail = scrub(&error.to_string(), settings.secret());
match error.kind() {
ProviderFailure::Auth => ProbeOutcome::Auth { target, detail },
ProviderFailure::ModelNotFound => ProbeOutcome::ModelNotFound { target, detail },
ProviderFailure::Unreachable => ProbeOutcome::Unreachable { target, detail },
ProviderFailure::Timeout => ProbeOutcome::Timeout { target, detail },
// 工厂阶段就报「配置不完整」(`validate()` 拒绝):仍然没发请求,但缺项已经在上游
// `to_config()` 拦过,这里退到 `Provider` 这个具名缺项。
ProviderFailure::NotConfigured => ProbeOutcome::NotConfigured {
missing: Missing::Provider,
},
ProviderFailure::Other => ProbeOutcome::Failed { target, detail },
}
}
/// 文本脱敏:先走 agent 的通用脱敏(主目录 / Bearer),再把本次密钥原文换成 [`MASK`]。
///
/// 第二道是必须的:`redact_text` 认不出「某个服务端回显的自定义密钥」,只有本模块知道本次的
/// 密钥原文。长度上限 400:错误体可能是整页 HTML,界面上放不下,日志里也没必要。
pub fn scrub(text: &str, secret: Option<&str>) -> String {
let mut out = redact_text(text);
if let Some(secret) = secret.filter(|value| !value.trim().is_empty()) {
out = out.replace(secret, MASK);
}
out.chars().take(400).collect()
}
#[cfg(test)]
#[path = "probe/tests.rs"]
mod tests;
@@ -0,0 +1,328 @@
//! 「测试连接」的守卫(M174)。四类失败**各有一条真 TCP 假服务器**用例——不是拿字符串假装的。
//!
//! | 用例 | 守什么 | 变异(改成什么会红) |
//! |---|---|---|
//! | `auth_failure_is_classified_as_auth` | 401 → 鉴权失败 | `map_error` 的 401 分支改成 `Other` |
//! | `missing_model_is_model_not_found` | 404 → 模型不存在 | 把 404 归进 auth |
//! | `closed_port_is_unreachable` | 连不上 → 端点不可达 | 把 `ErrorKind::Http` 归到 `Other` |
//! | `silent_server_times_out` | 不响应 → 超时,且在**本次预算**到点收手 | 去掉 `tokio::time::timeout`(那就得等假服务器睡满 5s) |
//! | `success_is_one_real_completion` | 200 + SSE → 通过 | 把 `Ok(Ok(_))` 当成失败 |
//! | `the_request_carries_the_configured_model` | **请求体里的 `model` 就是配置那个** | `probe_with` 里把 model 写死成别的名字 |
//! | 每条失败用例都断言 `target` | 失败结论带「对哪个端点/模型」 | `outcome_from_error` 丢掉 target(r1 复审 P2-1) |
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use unlucky_agent::brain::rig::RigProvider;
use super::{probe_with, AiSettings, ProbeOutcome};
/// 假服务器的行为。
enum Behaviour {
/// 立刻回这一份(状态行 + Content-Type + body)。
Reply(&'static str, &'static str, &'static str),
/// 收下请求但**永远不回**(超时用例)。
Silent,
}
/// 一条收到的请求:头 + 正文(P2-3:正文里才有 `model`)。
#[derive(Clone)]
struct Hit {
head: String,
body: String,
}
/// 一个只服务 127.0.0.1 的假 HTTP 服务器:记下每一份请求,按 `behaviour` 回。
struct Server {
port: u16,
hits: Arc<Mutex<Vec<Hit>>>,
}
impl Server {
fn start(behaviour: Behaviour) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑本地端口");
let port = listener.local_addr().expect("本地地址").port();
let hits = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&hits);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
handle(stream, &behaviour, &sink);
}
});
Self { port, hits }
}
fn base_url(&self) -> String {
format!("http://127.0.0.1:{}/v1", self.port)
}
fn hits(&self) -> Vec<Hit> {
self.hits.lock().expect("锁").clone()
}
}
fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc<Mutex<Vec<Hit>>>) {
let mut buffer = [0_u8; 8192];
let mut raw = Vec::new();
// 先读头,再按 `Content-Length` 把正文读全(P2-3:只读头就断言不到 `model`)。
let head_end = loop {
if let Some(at) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
break at + 4;
}
match stream.read(&mut buffer) {
Ok(0) => break raw.len(),
Ok(n) => raw.extend_from_slice(&buffer[..n]),
Err(_) => return,
}
};
let head = String::from_utf8_lossy(&raw[..head_end]).to_string();
let length = head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
while raw.len() < head_end + length {
match stream.read(&mut buffer) {
Ok(0) => break,
Ok(n) => raw.extend_from_slice(&buffer[..n]),
Err(_) => break,
}
}
let body =
String::from_utf8_lossy(&raw[head_end..raw.len().min(head_end + length)]).to_string();
sink.lock().expect("锁").push(Hit { head, body });
match behaviour {
Behaviour::Silent => {
// 拖住这条连接(客户端会先超时):比客户端时限长得多。
std::thread::sleep(Duration::from_secs(5));
}
Behaviour::Reply(status, content_type, body) => {
let response = format!(
"{status}\r\nContent-Type: {content_type}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}",
len = body.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
}
}
fn settings(base_url: &str, model: &str) -> AiSettings {
AiSettings {
enabled: true,
provider: "openai_compatible".to_string(),
base_url: base_url.to_string(),
model: model.to_string(),
api_key: Some("sk-test-not-a-real-key".to_string()),
}
}
const AUTH_BODY: &str = r#"{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}"#;
const MISSING_MODEL_BODY: &str =
r#"{"error":{"message":"model_not_found: no such model gpt-nope","code":"model_not_found"}}"#;
/// 一份最小但合法的 OpenAI 流式回复(rig 走 `model.stream(..)`,要 SSE 不是整包 JSON)。
const SSE_BODY: &str = concat!(
"data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":1,\"total_tokens\":4}}\n\n",
"data: [DONE]\n\n"
);
#[test]
fn auth_failure_is_classified_as_auth() {
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
AUTH_BODY,
));
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-test"),
Duration::from_secs(5),
);
let ProbeOutcome::Auth { target, detail } = &outcome else {
panic!("401 必须归到鉴权失败,实际 {outcome:?}");
};
assert!(!detail.is_empty());
// P2-1:失败结论必须说清「对哪个端点 / 哪个模型」。
assert_eq!(target.base_url, server.base_url());
assert_eq!(target.model, "gpt-test");
// 请求确实发出去了(否则「鉴权失败」是凭空说的)。
assert_eq!(server.hits().len(), 1, "鉴权失败之前必须真的连上过");
}
#[test]
fn missing_model_is_model_not_found() {
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 404 Not Found",
"application/json",
MISSING_MODEL_BODY,
));
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-nope"),
Duration::from_secs(5),
);
assert_eq!(
outcome.kind(),
"model_not_found",
"404 必须归到模型不存在,实际 {outcome:?}"
);
assert_eq!(outcome.target().map(|t| t.model.as_str()), Some("gpt-nope"));
}
#[test]
fn closed_port_is_unreachable() {
// 绑一个端口再放掉:这个端口上没人监听 = 连接被拒。
let port = {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑端口");
listener.local_addr().expect("地址").port()
};
let base_url = format!("http://127.0.0.1:{port}/v1");
let outcome = probe_with(
&RigProvider::factory(),
&settings(&base_url, "gpt-test"),
Duration::from_secs(5),
);
assert_eq!(
outcome.kind(),
"unreachable",
"没人监听的端口必须归到端点不可达,实际 {outcome:?}"
);
assert_eq!(
outcome.target().map(|t| t.base_url.as_str()),
Some(base_url.as_str())
);
}
/// 超时用例的预算:跟产品默认时限(`DEFAULT_TIMEOUT` = 8s)同一个量级,取 1/20 只是为了让整个
/// 测试套不必等 8 秒。判据只依赖「预算生效时远早于假服务器沉默的 5 秒」,不依赖这个绝对值。
const TIMEOUT_BUDGET: Duration = Duration::from_millis(400);
/// 允许的调度噪声:一次本地回环往返 + 建 tokio 运行时是毫秒级,1 秒是给繁忙机器的余量;它必须
/// **远小于**「预算没生效」的代价(假服务器要睡 5s 才收手),否则两种情形区分不开。
const TIMEOUT_SLACK: Duration = Duration::from_secs(1);
/// 不回应 ⇒ 超时,且在**本次预算**到点时就收手(不是等对面 5 秒沉默、也不是等别处的默认时限)。
///
/// 三层判据各管一件事,别混:
///
/// 1. **走了哪条分支**:`kind == "timeout"`。没被本模块预算截住的「不回应」落到的是传输层错误
/// ——实测:摘掉 `tokio::time::timeout` ⇒ 假服务器睡满 5s 后关连接,结论是 `unreachable`,
/// 所以这一条先把「根本没走到超时分支」挡在外面。
/// 2. **是本次预算那条分支**:`detail` 里的时限就是传进去的预算(Timeout 分支把 `timeout` 原样
/// 写进 detail)。它证明「结论由这条预算分支产出」,**不**证明「计时器用的就是这个值」:分支
/// 里把计时值换掉、detail 照抄参数,本条抓不住——那是下一条的活。
/// 3. **墙钟**:`elapsed < 预算 + SLACK`,抓的正是上一条抓不住的形态(「分支对、但等的不是这个
/// 预算」)。实测:把计时值硬编码成 3s ⇒ 本断言红(3.006s > 1.4s)。
///
/// 抓得住:「预算没生效 / 生效的不是这个预算」——那种情况要等假服务器睡满 5s 或等 3s 之类的
/// 别处时限,远超 `预算 + SLACK`。
/// 抓不住:预算到底是 400ms 还是 1.2s(都落在 SLACK 里),也不量定时器精度;机器再忙只吃掉
/// SLACK 的余量,不吃预算本身。
#[test]
fn silent_server_times_out() {
// 假服务器收下请求后睡 5s(`Behaviour::Silent`):这就是「预算没生效」时本用例会等到的墙钟。
let server = Server::start(Behaviour::Silent);
let started = std::time::Instant::now();
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-test"),
TIMEOUT_BUDGET,
);
let elapsed = started.elapsed();
assert_eq!(
outcome.kind(),
"timeout",
"不回应的端点必须归到超时,实际 {outcome:?}"
);
let ProbeOutcome::Timeout { detail, .. } = &outcome else {
unreachable!("上一行已经断言 kind 是 timeout")
};
assert!(
detail.contains(&TIMEOUT_BUDGET.as_secs_f32().to_string()),
"超时结论里的时限不是本次预算({TIMEOUT_BUDGET:?}):{detail}"
);
assert_eq!(
server.hits().len(),
1,
"超时之前必须真的把一次请求发给了假服务器(否则「超时」可能只是没连上)"
);
assert!(
elapsed < TIMEOUT_BUDGET + TIMEOUT_SLACK,
"预算没生效或收手太晚:预算 {TIMEOUT_BUDGET:?},实际 {elapsed:?};假服务器会沉默 5s,\
超过 {:?} 就说明等的不是这个预算",
TIMEOUT_BUDGET + TIMEOUT_SLACK
);
}
#[test]
fn success_is_one_real_completion() {
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 200 OK",
"text/event-stream",
SSE_BODY,
));
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-test"),
Duration::from_secs(5),
);
match &outcome {
ProbeOutcome::Passed { target } => {
assert_eq!(target.base_url, server.base_url());
assert_eq!(target.model, "gpt-test");
}
other => panic!("200 + 合法 SSE 必须算通过,实际 {other:?}"),
}
}
/// **P2-3**:请求体里的 `model` 必须是用户配置的那个(不是写死的)。
///
/// 变异:把 `probe_with` 的 `model: target.model.clone()` 改成 `"gpt-4o".to_string()` ⇒ 本用例
/// 断言 `\"model\":\"gpt-test\"` 失败。这是「配置里的模型真的上了线」唯一可信的证据:只看
/// 状态码通过并不能证明服务端收到的是哪个模型。
#[test]
fn the_request_carries_the_configured_model() {
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 200 OK",
"text/event-stream",
SSE_BODY,
));
let outcome = probe_with(
&RigProvider::factory(),
&settings(&server.base_url(), "gpt-test"),
Duration::from_secs(5),
);
assert!(outcome.is_pass(), "前提:这一轮应当通过,实际 {outcome:?}");
let hits = server.hits();
assert_eq!(hits.len(), 1, "应当恰好一次请求");
let hit = &hits[0];
assert!(
hit.head.starts_with("POST /v1/chat/completions"),
"请求行不对:{}",
hit.head.lines().next().unwrap_or("")
);
assert!(
!hit.body.is_empty(),
"没有读到请求正文(夹具没按 Content-Length 读 body?)"
);
assert!(
hit.body.contains("\"model\":\"gpt-test\""),
"请求正文里的 model 不是配置里的那个:{}",
hit.body
);
}
#[path = "tests_guards.rs"]
mod guards;
@@ -0,0 +1,290 @@
//! 「测试连接」的**不发请求**那一半守卫(M174):未配置不建 provider / 改完设置打新地址 /
//! 密钥不外流 / 文件读取。与 `probe/tests.rs` 的分工:那边起真 TCP 假服务器验四类失败,
//! 这边只验「什么时候不发」「发到哪」「发出去的东西里有什么」。
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use unlucky_agent::brain::provider::ProviderConfig;
use unlucky_agent::brain::{
ChatProvider, ChatRequest, DeltaObserver, ProviderError, ProviderFuture,
};
use unlucky_agent::redact::MASK;
use super::{settings, Behaviour, Server, AUTH_BODY};
use crate::commands::agent::probe::{probe_file, probe_with, AiSettings, Missing, ProbeOutcome};
#[test]
fn incomplete_config_never_builds_a_provider() {
struct Counter;
impl ChatProvider for Counter {
fn config(&self) -> &ProviderConfig {
unreachable!("不该被造出来")
}
fn complete<'a>(
&'a self,
_request: &'a ChatRequest,
_observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a> {
unreachable!("不该被造出来")
}
}
let builds = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&builds);
let factory: unlucky_agent::brain::ProviderFactory =
Arc::new(move |_config: &ProviderConfig| {
counter.fetch_add(1, Ordering::SeqCst);
Ok(Arc::new(Counter) as Arc<dyn ChatProvider>)
});
// 四种「不完整」:禁用 / 缺端点 / 缺模型 / 缺密钥(Anthropic)
let mut cases = vec![
(
AiSettings {
enabled: false,
..settings("http://127.0.0.1:1/v1", "m")
},
Missing::Disabled,
),
(
AiSettings {
base_url: String::new(),
..settings("", "m")
},
Missing::BaseUrl,
),
(
AiSettings {
model: String::new(),
..settings("http://127.0.0.1:1/v1", "")
},
Missing::Model,
),
(
AiSettings {
provider: "anthropic".to_string(),
base_url: String::new(),
api_key: None,
..settings("", "claude-sonnet-4")
},
Missing::ApiKey,
),
(
AiSettings {
provider: "openai-compatible".to_string(),
..settings("http://127.0.0.1:1/v1", "m")
},
Missing::Provider,
),
];
// `..::settings(..)` 里那些字段会被逐个覆盖,这里只要求「不全的配置都能被拦下」。
for (case, expected) in cases.drain(..) {
let outcome = probe_with(&factory, &case, Duration::from_millis(50));
assert_eq!(
outcome,
ProbeOutcome::NotConfigured { missing: expected },
"配置不全时必须返回 NotConfigured({expected:?}),实际 {outcome:?}"
);
}
assert_eq!(
builds.load(Ordering::SeqCst),
0,
"未配置时一个 provider 都不许造(更不许发请求)"
);
}
/// 守卫:设置页保存完再测,请求打到的是**文件里现在那个** `ai.base_url`。
#[test]
fn saved_base_url_is_the_address_actually_dialed() {
let old = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
AUTH_BODY,
));
let new = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
AUTH_BODY,
));
let path = temp_config("base-url");
write_config(
&path,
&format!(
r#"{{"ai":{{"enabled":true,"provider":"openai_compatible","base_url":"{}","model":"gpt-test","api_key":"sk-saved"}}}}"#,
old.base_url()
),
);
let first = probe_file(&path, Duration::from_secs(5));
assert!(!first.is_pass(), "假服务器回 401,不该算通过");
assert_eq!(old.hits().len(), 1, "第一次必须打到旧地址");
// 用户在设置页改了 base_url(同一份 schema、同一个文件)之后再测一次。
write_config(
&path,
&format!(
r#"{{"ai":{{"enabled":true,"provider":"openai_compatible","base_url":"{}","model":"gpt-test","api_key":"sk-saved"}}}}"#,
new.base_url()
),
);
let second = probe_file(&path, Duration::from_secs(5));
assert!(!second.is_pass());
assert_eq!(
new.hits().len(),
1,
"改完 base_url 之后请求必须打到新地址(读的是文件里现在那一份)"
);
assert_eq!(old.hits().len(), 1, "旧地址不该再被打一次");
let hit = &new.hits()[0];
assert!(
hit.head.starts_with("POST /v1/chat/completions"),
"OpenAI 兼容形态请求的是 /v1/chat/completions,实际请求行:{}",
hit.head.lines().next().unwrap_or("")
);
assert!(
hit.body.contains("\"model\":\"gpt-test\""),
"请求正文里的 model 不是配置里的那个:{}",
hit.body
);
// P2-1:改完 base_url 之后,失败结论里说的也必须是**新**地址。
assert_eq!(
second.target().map(|target| target.base_url.as_str()),
Some(new.base_url().as_str())
);
}
/// 守卫①:密钥不得出现在 `ProbeOutcome` 的任何字段里,也不得出现在 `AiSettings` 的 `Debug` 里。
///
/// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。
#[test]
fn key_never_leaks_into_outcome_or_debug() {
const SECRET: &str = "sk-live-LEAK-MARKER-9f3a";
// 服务端把 key 原样回显(有些网关会这么干)。
let body = r#"{"error":{"message":"Incorrect API key provided: sk-live-LEAK-MARKER-9f3a","code":"invalid_api_key"}}"#;
let server = Server::start(Behaviour::Reply(
"HTTP/1.1 401 Unauthorized",
"application/json",
body,
));
let settings = AiSettings {
api_key: Some(SECRET.to_string()),
..settings(&server.base_url(), "gpt-test")
};
let outcome = probe_with(
&unlucky_agent::brain::rig::RigProvider::factory(),
&settings,
Duration::from_secs(5),
);
let printed = format!("{outcome:?}");
assert!(
!printed.contains(SECRET),
"ProbeOutcome 里出现了密钥明文:{printed}"
);
assert!(
printed.contains(MASK),
"被掩码后应当看得见掩码标记:{printed}"
);
let debug = format!("{settings:?}");
assert!(
!debug.contains(SECRET),
"AiSettings 的 Debug 漏了密钥:{debug}"
);
assert!(
debug.contains(MASK),
"AiSettings 的 Debug 应当显示掩码:{debug}"
);
}
#[test]
fn settings_load_reads_the_five_keys() {
let path = temp_config("load");
write_config(
&path,
r#"{"ai":{"enabled":false,"provider":"ollama","base_url":"http://127.0.0.1:11434","model":"llama3","api_key":"sk-x"}}"#,
);
let settings = AiSettings::load(&path);
assert!(!settings.enabled);
assert_eq!(settings.provider, "ollama");
assert_eq!(settings.base_url, "http://127.0.0.1:11434");
assert_eq!(settings.model, "llama3");
assert_eq!(settings.api_key.as_deref(), Some("sk-x"));
// 空的 api_key(null / 空串)读成 `None`,不是 `Some("")`。
let empty = temp_config("empty-key");
write_config(
&empty,
r#"{"ai":{"provider":"ollama","model":"llama3","api_key":""}}"#,
);
assert_eq!(AiSettings::load(&empty).api_key, None);
// 文件不存在也不 panic(`effective_*` 给 schema 默认值 = 未配置,不是坏值)。
let missing = temp_config("missing");
let settings = AiSettings::load(&missing);
assert_eq!(
settings.provider, "openai_compatible",
"缺文件时是 schema 默认值"
);
assert!(settings.model.is_empty());
assert_eq!(settings.api_key, None);
assert_eq!(settings.to_config(), Err(Missing::BaseUrl));
}
// ------------------------------------------------------------------ 夹具
fn temp_config(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-probe-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
dir.join("config/launcher.json")
}
fn write_config(path: &PathBuf, body: &str) {
std::fs::write(path, body).expect("写配置文件");
}
/// 工厂阶段的错误也按**结构化分类**走(r1 复审 P2-5 之后:这里不再看文本)。
///
/// 两条一起钉住:
/// 1. 工厂给的是 `classified(Auth, …)` ⇒ 结论就是 `auth`(分类信息不丢);
/// 2. 工厂给的是 `ProviderError::new("401 bad key")`(**纯文本、没有分类**)⇒ 结论是 `failed`,
/// **不是** `auth`。第二条是 P2-5 的绊线:谁把「按 `"401"` 子串猜类别」加回来,这条立刻红。
#[test]
fn factory_error_carries_the_structured_classification() {
let classified: unlucky_agent::brain::ProviderFactory = Arc::new(|_config: &ProviderConfig| {
Err(ProviderError::classified(
unlucky_agent::brain::ProviderFailure::Auth,
"401 Unauthorized",
))
});
let outcome = probe_with(
&classified,
&settings("http://127.0.0.1:1/v1", "gpt-test"),
Duration::from_millis(50),
);
assert_eq!(
outcome.kind(),
"auth",
"工厂阶段的结构化分类不许丢:{outcome:?}"
);
// 纯文本错误(无分类)**不许**被猜成鉴权失败。
let text_only: unlucky_agent::brain::ProviderFactory =
Arc::new(|_config: &ProviderConfig| Err(ProviderError::new("401 bad key")));
let outcome = probe_with(
&text_only,
&settings("http://127.0.0.1:1/v1", "gpt-test"),
Duration::from_millis(50),
);
assert_eq!(
outcome.kind(),
"failed",
"没有结构化分类的错误必须落在「其它」,不许按文本猜成 auth(P2-5):{outcome:?}"
);
}
+218
View File
@@ -0,0 +1,218 @@
//! `ull agent serve`:把注册表挂成 MCP server,长驻运行。
//!
//! 装配顺序(每一步都对应 02 文档的一条约束):
//! 1. 设置 → `McpConfig`(`agent.mcp.*`,命令行可覆盖);
//! 2. 端口([`super::ports`])→ 注册表(`tools::build_v1_registry`);
//! 3. 只读模式 → `read_only_subset()`(物理上不含写工具,列表/调用天然一致);
//! 4. 执行器:`JsonlAudit`(落盘 `<data>/agent/audit`)+ fail-closed 闸门 +
//! `ConfirmPolicy { yolo }`(读 `ai.yolo`)+ `DenyGuidance::External`(没有弹窗可点);
//! 5. 传输:stdio 或 Streamable HTTP(127.0.0.1)。
use std::sync::Arc;
use serde_json::Value;
use unlucky_agent::audit::{audit_dir, JsonlAudit};
use unlucky_agent::brain::exec::ToolExecutor;
use unlucky_agent::clock::SystemClock;
use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate};
use unlucky_agent::event::NoopSink;
use unlucky_agent::mcp::{serve_http, serve_stdio, McpConfig, RegistryServer};
use unlucky_agent::tools::build_v1_registry;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::AgentServeArgs;
use crate::context::Ctx;
use crate::error::CliError;
/// 起一次 server(阻塞直到进程被杀/stdio 对端关闭)。
///
/// # Errors
/// 注册表装配失败、端口被占用、非回环监听等返回用户错误。
pub fn run(ctx: &Ctx, args: &AgentServeArgs) -> Result<i32, CliError> {
let settings = Settings::load(ctx);
let config = settings.mcp_config(args);
let yolo = settings.yolo();
let read_only = config.read_only;
if !settings.enabled {
eprintln!(
"[unlucky-agent] 提示:设置键 agent.mcp.enabled=false,本次是手动启动(GUI 不会自动起)"
);
}
let ports = super::ports::build(ctx);
let registry = Arc::new(
build_v1_registry(&ports)
.map_err(|error| CliError::internal("agent_registry", error.to_string()))?,
);
// 路由注册表始终是完整的那份;「哪些工具真能跑」由执行器持有的注册表决定
// (只读模式下是 read_only_subset):硬调写工具会得到可读的 tool-level 错误 + 审计,
// 而不是 rmcp 的协议级 -32602(客户端只会显示 "internal error")。
let exposed = if read_only {
Arc::new(registry.read_only_subset())
} else {
Arc::clone(&registry)
};
let audit = Arc::new(JsonlAudit::new(
audit_dir(&ctx.layout().data()),
Arc::new(SystemClock),
));
let executor = Arc::new(
ToolExecutor::new(
Arc::clone(&exposed),
audit,
Arc::new(NoUiGate),
ConfirmPolicy { yolo },
Arc::new(SystemClock),
Arc::new(NoopSink),
)
.with_guidance(DenyGuidance::External),
);
let server = RegistryServer::new(registry, executor, transport_label(args), read_only);
if args.stdio {
// stdio:stdout 是协议流,人类提示一律走 stderr。
eprintln!(
"[unlucky-agent] MCP server 走 stdio({} 个工具;写权限:{};YOLO:{})",
server.tool_count(),
if read_only { "关(只读)" } else { "开" },
yolo
);
return block_on(async move { serve_stdio(server).await })
.map(|()| crate::error::EXIT_OK)
.map_err(|error| CliError::user("mcp_stdio", error.to_string()));
}
let url = config.url();
eprintln!("[unlucky-agent] MCP 端点:{url}");
block_on(async move { serve_http(server, config).await })
.map(|()| crate::error::EXIT_OK)
.map_err(|error| CliError::user("mcp_http", error.to_string()))
}
fn transport_label(args: &AgentServeArgs) -> &'static str {
if args.stdio {
"mcp-stdio"
} else {
"mcp-http"
}
}
/// 在独立线程里跑一个 tokio 运行时(stdio 形态要独占 stdin/stdout,不能被主线程抢)。
fn block_on<F, T>(future: F) -> Result<T, unlucky_agent::mcp::McpServeError>
where
F: std::future::Future<Output = Result<T, unlucky_agent::mcp::McpServeError>> + Send + 'static,
T: Send + 'static,
{
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| unlucky_agent::mcp::McpServeError::Io(error.to_string()))?;
runtime.block_on(future)
}
/// 从 `launcher.json` 读 MCP 相关设置(坏文件退化成默认值,不阻断启动)。
struct Settings {
enabled: bool,
port: u16,
allow_write: bool,
token: Option<String>,
yolo: bool,
}
impl Settings {
fn load(ctx: &Ctx) -> Self {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
let doc = store.doc();
let port = doc
.effective("agent.mcp.port")
.and_then(Value::as_u64)
.and_then(|value| u16::try_from(value).ok())
.unwrap_or(47823);
Self {
enabled: doc
.effective("agent.mcp.enabled")
.and_then(Value::as_bool)
.unwrap_or(false),
port,
allow_write: doc
.effective("agent.mcp.allow_write")
.and_then(Value::as_bool)
.unwrap_or(false),
token: doc
.effective_str("agent.mcp.token")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string),
yolo: doc
.effective("ai.yolo")
.and_then(Value::as_bool)
.unwrap_or(false),
}
}
fn mcp_config(&self, args: &AgentServeArgs) -> McpConfig {
McpConfig {
host: args.host.clone().unwrap_or_else(|| "127.0.0.1".to_string()),
port: args.port.unwrap_or(self.port),
token: self.token.clone(),
// 命令行 --allow-write 只能**收紧或放开到设置允许的程度**:
// 设置里没开写权限时,命令行也不放开(设置是用户的意思表示,命令行是运行参数)。
read_only: !(self.allow_write || args.allow_write),
}
}
fn yolo(&self) -> bool {
self.yolo
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args() -> AgentServeArgs {
AgentServeArgs::default()
}
#[test]
fn settings_default_to_read_only_on_47823() {
// Settings 需要 Ctx(要真实数据根),这里只测「无设置时的合成规则」:
// 用 Default 的字段逐项对照,避免为此造一个假 Ctx。
let defaults = Settings {
enabled: false,
port: 47823,
allow_write: false,
token: None,
yolo: false,
};
let config = defaults.mcp_config(&args());
assert_eq!(config.port, 47823);
assert!(config.read_only);
assert_eq!(config.host, "127.0.0.1");
}
#[test]
fn cli_flags_override_port_and_write_switch() {
let settings = Settings {
enabled: true,
port: 47823,
allow_write: true,
token: Some("t".to_string()),
yolo: true,
};
let overridden = settings.mcp_config(&AgentServeArgs {
port: Some(50000),
allow_write: false,
..AgentServeArgs::default()
});
assert_eq!(overridden.port, 50000);
assert!(!overridden.read_only, "设置里允许写时命令行不必再开");
assert_eq!(overridden.token.as_deref(), Some("t"));
assert!(settings.yolo());
// `enabled` 只是 GUI 的自动启动开关:命令行显式 `agent serve` 不看它
// (所以这里 enabled=true 既不影响 mcp_config,也不影响能不能起服务)。
assert!(settings.enabled);
}
}
+375
View File
@@ -0,0 +1,375 @@
//! `config get|set`:走 `core::setting` 的 schema API(单一真相源)。
//!
//! # 落点(M40:与数据根解析同源)
//!
//! - **数据根键**([`DATA_ROOT_KEYS`]:`core.data_mode` / `core.data_custom_root` /
//! `core.launcher_home` / `core.game_home`)→ 便携目录那份 `launcher.json`
//! (`<exe 目录>/.ULL/config/launcher.json`)。这正是 `Ctx::resolve` 决定数据根时读的
//! 同一份文件(落点由 `Ctx::setting_path_for` 给出),所以 `config get core.data_mode`
//! 展示的就是真正生效的值,`config set` 也真的影响下一次解析;
//! - 其余键 → 解析后的数据根(`<data>/.ULL/config/launcher.json`)。
//!
//! 之前两条路各看一份文件:`--data-dir` / standard 模式下「改了 `core.data_mode` 没反应、
//! `config get` 还报另一份文件里的值」(finding `…-improve-ull-config-get-set-launcher-json`)。
//!
//! 实例级配置(`config/instances/<id>.json`)留给实例管理 mission,这里不半途实现。
//!
//! **凭据纪律**(D9 / `02-ai-agent-design.md` §3.1):schema 标 `High` 的敏感键
//! (`ai.api_key` / `download.proxy_url` / `java.extra_args` …)在 read 路径上一律掩码——
//! 单键 get、无键全量 dump、set 的旧值回显**三条**都要掩,`--show-secret` 才放行明文。
//! 判定叠两道:schema 的 `Sensitivity::High` + agent 的 `redact::is_sensitive_key` 词表。
use serde_json::{json, Map, Value};
use unlucky_core::setting::catalog::SettingSchema;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::spec::Sensitivity;
use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason};
use crate::context::{is_data_root_key, Ctx, DATA_ROOT_KEYS};
use crate::error::CliError;
use crate::output::Report;
/// 敏感值在输出里的占位。
const MASK: &str = "<redacted>";
mod prefs_migration;
use prefs_migration::migrate_update_prefs;
/// `ull config get [<key>] [--show-secret]`。
///
/// # Errors
///
/// 未知键、配置不可读时返回用户错误。
pub fn get(ctx: &Ctx, key: Option<&str>, show_secret: bool) -> Result<Report, CliError> {
match key {
Some(key) => get_one(ctx, key, show_secret),
None => dump(ctx, show_secret),
}
}
/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。
fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError> {
// 先跑一次遗留偏好迁移(幂等;没有旧文件时只是一次 stat)。必须在 `load` **之前**:
// 迁移写的正是这份 `launcher.json`,先读后迁会让这一条输出还是旧值。
let migration = migrate_update_prefs(ctx);
let path = ctx.setting_path_for(key);
let store = SettingStore::load(&path, launcher_schema());
let schema = store.doc().schema().clone();
let Some(value) = store.effective(key) else {
return Err(
CliError::user("unknown_key", format!("配置键 `{key}` 不在 schema 里"))
.with_hint("`%prog% config get`(不带键)可以看到全部生效键"),
);
};
let sensitive = is_sensitive(&schema, key);
let hidden = sensitive && !show_secret;
let shown = if hidden {
Value::String(MASK.to_string())
} else {
value.clone()
};
let problems = render_problems(&store);
let mut data = json!({
"key": key,
"value": shown,
"redacted": hidden,
"sensitive": sensitive,
"path": path.display().to_string(),
"problems": problems,
});
let mut human = vec![format!(
"{key} = {}{}",
render_value(&shown),
if hidden {
"(敏感键;加 --show-secret 显示明文)"
} else {
""
}
)];
human.push(format!("来自 {}", path.display()));
if let Some(note) = migration {
human.push(note.human);
if let Some(object) = data.as_object_mut() {
object.insert("migration".to_string(), note.data);
}
}
for problem in &problems {
human.push(format!("问题:{problem}"));
}
human.extend(source_note(ctx, key));
Ok(Report::new(data, human))
}
/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。
fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
let migration = migrate_update_prefs(ctx);
let path = ctx.launcher_config();
let store = SettingStore::load(&path, launcher_schema());
let schema = store.doc().schema().clone();
let core_path = ctx
.portable_launcher_config()
.filter(|core| core.as_path() != path.as_path());
let core_store = core_path
.as_ref()
.map(|core| SettingStore::load(core, launcher_schema()));
let mut document = store.doc().to_value();
if let Some(core) = &core_store {
// 只叠加**便携目录那份里真有的键**(默认值不落盘,dump 也只展示文件里有的键);
// 生效数据根在下面的 `data_root` 里单独给,不靠这里的默认值推。
for key in DATA_ROOT_KEYS {
if let Some(value) = core.doc().get(key) {
overlay(&mut document, key, value.clone());
}
}
}
let mut problems = render_problems(&store);
if let Some(core) = &core_store {
problems.extend(render_problems(core));
}
let (document, masked) = if show_secret {
(document, false)
} else {
mask_document(&schema, &document)
};
let text = match serde_json::to_string_pretty(&document) {
Ok(text) => text,
Err(error) => return Err(CliError::internal("config_serialize", error.to_string())),
};
let mut human: Vec<String> = Vec::new();
human.push(format!("配置 {}({:?})", path.display(), store.compat()));
if let Some(core) = &core_path {
human.push(format!("数据根键(core.*)读的是 {}", core.display()));
}
human.push(format!(
"数据根:{}(launcher home {},游戏目录 {})",
ctx.data.mode.as_str(),
ctx.data.launcher_home.display(),
ctx.data.game_home.display()
));
human.extend(text.lines().map(str::to_string));
if let Some(note) = &migration {
human.push(note.human.clone());
}
for problem in &problems {
human.push(format!("问题:{problem}"));
}
if masked {
human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string());
}
let mut data = json!({
"path": path.display().to_string(),
"core_path": core_path.as_ref().map(|core| core.display().to_string()),
"config": document,
"redacted": masked,
"problems": problems,
"data_root": {
"mode": ctx.data.mode.as_str(),
"launcher_home": ctx.data.launcher_home.display().to_string(),
"game_home": ctx.data.game_home.display().to_string(),
},
});
if let Some(note) = migration {
if let Some(object) = data.as_object_mut() {
object.insert("migration".to_string(), note.data);
}
}
Ok(Report::new(data, human))
}
/// `ull config set <key> <value>`。
///
/// 敏感键的**旧值回显要掩码**(新值是用户刚敲进来的,照常回显)。
///
/// # Errors
///
/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。
pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
// 同一份遗留偏好先迁进来:否则用户「set 一个、get 另一个」会看到两份真相。
let migration = migrate_update_prefs(ctx);
let path = ctx.setting_path_for(key);
let mut store = SettingStore::load(&path, launcher_schema());
let sensitive = is_sensitive(store.doc().schema(), key);
let value = parse_value(raw);
let old = store.effective(key).cloned().map(|old| {
if sensitive {
Value::String(MASK.to_string())
} else {
old
}
});
store.set(key, value.clone()).map_err(|error| {
CliError::user("config_set", error.to_string())
.with_hint(format!("键与取值范围见 {}", path.display()))
})?;
let outcome = store.save().map_err(|error| {
CliError::user(
"config_save",
format!("写 {} 失败:{error}", path.display()),
)
})?;
match &outcome {
SaveOutcome::Saved(saved) => {
let mut data = json!({
"key": key,
"old": old,
"new": value,
"saved": saved.display().to_string(),
});
let mut human = vec![
format!(
"{key} = {}(原值 {})",
render_value(&value),
render_value(&old.unwrap_or(Value::Null))
),
format!("已保存:{}", saved.display()),
];
if let Some(note) = migration {
human.push(note.human);
if let Some(object) = data.as_object_mut() {
object.insert("migration".to_string(), note.data);
}
}
human.extend(source_note(ctx, key));
Ok(Report::new(data, human))
}
SaveOutcome::Skipped(SkipReason::NotDirty) => {
let mut human = vec![format!("{key} 已经是该值,未写盘")];
if let Some(note) = &migration {
human.push(note.human.clone());
}
Ok(Report::new(
json!({ "key": key, "new": value, "saved": null }),
human,
))
}
SaveOutcome::Skipped(reason) => {
let error = CliError::user(
"config_read_only",
format!("{} 当前不可写({})", path.display(), reason.as_str()),
);
// 不可写常常不是权限问题,而是文件内容让加载降级(`$schema` 认不出 / 版本更高)。
let problems = render_problems(&store);
Err(if problems.is_empty() {
error.with_hint("修好文件权限/内容后重试")
} else {
error.with_hint(problems.join(";"))
})
}
}
}
/// 数据根键的落点说明(其余键不需要:它们的落点就是数据根本身)。
fn source_note(ctx: &Ctx, key: &str) -> Option<String> {
if !is_data_root_key(key) {
return None;
}
if ctx.data_dir_forced() {
return Some(
"本次由 --data-dir 指定数据根:该键在下次不带 --data-dir 运行时生效".to_string(),
);
}
if ctx.setting_path_for(key) != ctx.launcher_config() {
return Some("该键决定数据根,落点是便携目录那份(与数据根解析同源)".to_string());
}
None
}
/// 加载问题 → 人读一行(`problems` 通道与人类可读输出共用)。
fn render_problems(store: &SettingStore) -> Vec<String> {
store.problems().iter().map(ToString::to_string).collect()
}
/// 把 `key` 的生效值写进 dump 文档(点分键 → 嵌套对象)。
fn overlay(root: &mut Value, key: &str, value: Value) {
let mut segments = key.split('.').filter(|part| !part.is_empty());
let Some(head) = segments.next() else {
return;
};
let rest: Vec<&str> = segments.collect();
let Some(map) = root.as_object_mut() else {
return;
};
if rest.is_empty() {
map.insert(head.to_string(), value);
return;
}
overlay(
map.entry(head.to_string())
.or_insert_with(|| Value::Object(Map::new())),
&rest.join("."),
value,
);
}
/// 值先按 JSON 解析(数字/布尔/对象),失败按字符串。
fn parse_value(raw: &str) -> Value {
serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
}
/// 敏感键:schema 标 `High`,或键名本身命中 agent 的敏感词表(双保险)。
fn is_sensitive(schema: &SettingSchema, key: &str) -> bool {
matches!(schema.sensitivity(key), Some(Sensitivity::High))
|| unlucky_agent::redact::is_sensitive_key(key)
}
/// 递归掩码渲染后的配置文档,返回(掩码后的文档,是否掩过)。
///
/// `to_value()` 是**嵌套对象**(点分键展开成层级),所以按路径下钻;
/// 命中敏感路径时整棵子树一起掩码,数组元素沿用父路径继续下钻。
fn mask_document(schema: &SettingSchema, document: &Value) -> (Value, bool) {
mask_at(schema, "", document)
}
fn mask_at(schema: &SettingSchema, path: &str, value: &Value) -> (Value, bool) {
if !path.is_empty() && is_sensitive(schema, path) {
return (Value::String(MASK.to_string()), true);
}
match value {
Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
let mut masked = false;
for (key, child) in map {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
let (child, child_masked) = mask_at(schema, &child_path, child);
masked |= child_masked;
out.insert(key.clone(), child);
}
(Value::Object(out), masked)
}
Value::Array(items) => {
let mut masked = false;
let redacted: Vec<Value> = items
.iter()
.map(|item| {
let (item, item_masked) = mask_at(schema, path, item);
masked |= item_masked;
item
})
.collect();
(Value::Array(redacted), masked)
}
other => (other.clone(), false),
}
}
fn render_value(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,70 @@
//! M159 的遗留更新偏好 → 主配置(`update.auto_check`)的迁移(M161)。
//!
//! 从 `commands/config.rs` 拆出:那边贴着 400 行硬线,而这块是**独立的一件事**
//! (遗留文件的桥接),与 get/set 的排版逻辑无关。
use serde_json::{json, Value};
use unlucky_core::setting::update_prefs::{self, Migration};
use crate::context::Ctx;
/// M159 的遗留更新偏好 → 主配置的迁移结果(只在**有话说**时返回)。
pub(super) struct PrefsNote {
/// 人读一行(`config get/set` 的人类可读输出里追加)
pub(super) human: String,
/// 同一件事的机器可读形状(`--json` 信封里追加)
pub(super) data: Value,
}
/// 把 `<数据根>/cache/update/prefs.json` 迁进 `launcher.json`(M161)。
///
/// 为什么 `config` 这个**读命令**也会写盘:那份遗留文件对 CLI 本来就不可见——用户在手头没 GUI
/// 的机器上 `config get update.auto_check`,旧实现永远回默认 `true`,而他的开关可能是关的。
/// 迁移是**幂等**的(源指纹闸门 + 旧文件改名 `.migrated`),有收据,且在写不进去时什么都不动
/// (见 [`unlucky_core::setting::update_prefs`])。没有旧文件时这里只是一次 `stat`。
pub(super) fn migrate_update_prefs(ctx: &Ctx) -> Option<PrefsNote> {
let layout = ctx.layout();
let paths = update_prefs::Targets {
launcher: &ctx.launcher_config(),
legacy: &layout.cache().join("update").join("prefs.json"),
receipt: &layout.config().join(update_prefs::RECEIPT_FILE),
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0);
let migration = update_prefs::migrate(paths, now);
let (human, detail) = match &migration {
Migration::Nothing => return None,
Migration::Migrated { auto_check, .. } => (
format!(
"已把旧更新偏好迁入主配置:update.auto_check = {auto_check}(收据 {})",
paths.receipt.display()
),
format!("update.auto_check = {auto_check} 已迁移"),
),
Migration::NewValueWins { auto_check, .. } => (
format!(
"旧更新偏好文件已退役(主配置里已有 update.auto_check = {auto_check},新值胜出;收据 {})",
paths.receipt.display()
),
"旧文件已退役,主配置的值未改动".to_string(),
),
Migration::Unreadable { path, reason } => (
format!("旧更新偏好读不出来({}):{reason}", path.display()),
"旧文件坏,未迁移".to_string(),
),
Migration::Refused { reason } => (
format!("旧更新偏好未迁移:{reason}"),
"主配置不可写,未迁移".to_string(),
),
};
Some(PrefsNote {
human,
data: json!({
"id": migration.as_str(),
"receipt": migration.receipt().map(|path| path.display().to_string()),
"detail": detail,
}),
})
}
+320
View File
@@ -0,0 +1,320 @@
//! [`super`] 的单测:数据根键与其余键的落点分流、dump 合并、`problems` 通道。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use super::{dump, get, set};
use crate::cli::Globals;
use crate::context::Ctx;
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-config-test-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 正确的 `$schema`(写成真前缀,加载才不会降级)。
fn schema_url() -> String {
use unlucky_core::setting::keys::launcher_version;
use unlucky_core::setting::schema::{SchemaId, SchemaRef};
SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url()
}
fn write_json(path: &Path, value: &Value) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
std::fs::write(path, value.to_string()).expect("写配置");
}
fn read_json(path: &Path) -> Value {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读 {} 失败:{e}", path.display()));
serde_json::from_slice(&bytes).expect("合法 JSON")
}
fn ctx_with(globals: Globals, exe_dir: &Path) -> Ctx {
Ctx::resolve_in(&globals, Some(exe_dir.to_path_buf())).expect("解析数据根")
}
/// 便携模式:两个落点是同一份文件,`core.data_mode` 改了立刻是生效值。
#[test]
fn portable_mode_keeps_core_keys_in_one_file() {
let temp = TempDir::new("portable");
let ctx = ctx_with(Globals::default(), temp.path());
assert_eq!(ctx.data.mode.as_str(), "portable");
assert_eq!(
ctx.setting_path_for("core.data_mode"),
ctx.launcher_config(),
"便携模式下两份路径本来就该重合"
);
set(&ctx, "core.data_mode", "standard").expect("写 core.data_mode");
let file = temp
.path()
.join(".ULL")
.join("config")
.join("launcher.json");
assert!(file.is_file(), "应落在 {}", file.display());
assert_eq!(read_json(&file)["core"]["data_mode"], json!("standard"));
let report = get(&ctx, Some("core.data_mode"), true).expect("读回来");
// `core.*` 在 schema 里标了 `AiPolicy::sensitive()`(High),读路径默认掩码成
// `<redacted>`,所以要看生效值得像用户那样加 `--show-secret`。
assert_eq!(report.data["value"], json!("standard"));
assert_eq!(
report.data["path"],
json!(file.display().to_string()),
"read 与 write 必须同一份"
);
}
/// standard 模式:数据根键走便携目录那份(与解析同源),其余键走数据根。
#[test]
fn standard_mode_routes_core_keys_to_the_portable_file() {
let temp = TempDir::new("standard");
let exe = temp.path().join("exe");
let portable = exe.join(".ULL").join("config").join("launcher.json");
let launcher_home = temp.path().join("lhome");
write_json(
&portable,
&json!({
"$schema": schema_url(),
"schemaVersion": 1,
"core": {
"data_mode": "standard",
"launcher_home": launcher_home.display().to_string(),
},
}),
);
let ctx = ctx_with(Globals::default(), &exe);
assert_eq!(ctx.data.mode.as_str(), "standard");
assert_eq!(ctx.data.launcher_home, launcher_home);
assert_ne!(
ctx.setting_path_for("core.data_mode"),
ctx.launcher_config()
);
let report = get(&ctx, Some("core.data_mode"), true).expect("读 core.data_mode");
assert_eq!(
report.data["value"],
json!("standard"),
"get 展示的必须是真正生效的值"
);
assert_eq!(report.data["path"], json!(portable.display().to_string()));
// 普通键仍写数据根。
set(&ctx, "download.concurrency", "16").expect("写 download.concurrency");
let core_file = ctx.launcher_config();
assert_eq!(read_json(&core_file)["download"]["concurrency"], json!(16));
// 数据根键写便携目录那份,且不动数据根那份的 core.*。
set(&ctx, "core.data_mode", "portable").expect("写 core.data_mode");
assert_eq!(read_json(&portable)["core"]["data_mode"], json!("portable"));
assert!(
read_json(&core_file)["core"].get("data_mode").is_none(),
"数据根那份不该被写入 core.data_mode"
);
}
/// `--data-dir` 模式:数据根由命令行钉死,core.* 仍落便携目录那份并给出提示。
#[test]
fn data_dir_flag_routes_core_keys_to_portable_and_says_so() {
let temp = TempDir::new("datadir");
let exe = temp.path().join("exe");
std::fs::create_dir_all(&exe).expect("建 exe 目录");
let data = temp.path().join("data");
let globals = Globals {
data_dir: Some(data.clone()),
..Globals::default()
};
let ctx = ctx_with(globals, &exe);
assert_eq!(ctx.data.mode.as_str(), "custom");
assert_eq!(ctx.data.launcher_home, data.join(".ULL"));
let portable = exe.join(".ULL").join("config").join("launcher.json");
set(&ctx, "core.data_mode", "standard").expect("写 core.data_mode");
assert!(portable.is_file(), "应落在 {}", portable.display());
assert!(!data
.join(".ULL")
.join("config")
.join("launcher.json")
.exists());
let report = get(&ctx, Some("core.data_mode"), true).expect("读回来");
assert_eq!(report.data["value"], json!("standard"));
assert!(
report.human.iter().any(|line| line.contains("--data-dir")),
"{:?}",
report.human
);
}
/// 全量 dump:`core.*` 取便携目录那份的生效值,并标明两份文件与数据根。
#[test]
fn dump_merges_core_keys_from_the_portable_file() {
let temp = TempDir::new("dump");
let exe = temp.path().join("exe");
let portable = exe.join(".ULL").join("config").join("launcher.json");
let launcher_home = temp.path().join("home2");
write_json(
&portable,
&json!({
"$schema": schema_url(),
"schemaVersion": 1,
"core": {
"data_mode": "standard",
"launcher_home": launcher_home.display().to_string(),
},
}),
);
// 数据根那份写一个普通键(默认值不落盘,dump 里只有文件里真有的键)。
write_json(
&launcher_home.join("config").join("launcher.json"),
&json!({
"$schema": schema_url(),
"schemaVersion": 1,
"download": { "concurrency": 4 },
}),
);
let ctx = ctx_with(Globals::default(), &exe);
let report = dump(&ctx, true).expect("全量 dump");
assert_eq!(
report.data["config"]["core"]["data_mode"],
json!("standard")
);
assert_eq!(
report.data["core_path"],
json!(portable.display().to_string())
);
assert_eq!(report.data["data_root"]["mode"], json!("standard"));
assert_eq!(
report.data["config"]["download"]["concurrency"],
json!(4),
"非 core.* 键仍取数据根那份"
);
}
/// 认不出的 `$schema` 前缀:值照读 + `problems` 里有 UnknownSchema,不再静默回落。
#[test]
fn unknown_schema_prefix_warns_and_still_shows_file_values() {
let temp = TempDir::new("badschema");
let exe = temp.path().join("exe");
let portable = exe.join(".ULL").join("config").join("launcher.json");
write_json(
&portable,
&json!({
"$schema": "https://unlucky-launcher.local/schema/launcher/1.2.0.json",
"schemaVersion": 1,
"core": { "data_mode": "portable" },
"download": { "concurrency": 32 },
}),
);
let ctx = ctx_with(Globals::default(), &exe);
let report = get(&ctx, Some("download.concurrency"), false).expect("读普通键");
assert_eq!(report.data["value"], json!(32), "值不该被回落成默认 8");
let problems = report.data["problems"].as_array().expect("problems 数组");
assert!(
problems
.iter()
.any(|problem| problem.as_str().is_some_and(|p| p.contains("$schema"))),
"{problems:?}"
);
assert!(
report.human.iter().any(|line| line.contains("$schema")),
"{:?}",
report.human
);
}
/// M161:`ull config get update.auto_check` 读的是**主配置**,而且会把 M159 遗留的
/// `cache/update/prefs.json` 先迁进来(迁移收据在 `migration` 里可查)。
///
/// 这条是 mission「`ull config get update.auto_check` 真能读到」的端到端证明:
/// 旧文件里 `false` → 迁移 → 主配置 → CLI 输出 `false`(**不是**默认的 `true`)。
#[test]
fn the_update_switch_is_migrated_and_readable_from_the_cli() {
let temp = TempDir::new("update-prefs");
let exe = temp.path().join("exe");
let ctx = ctx_with(Globals::default(), &exe);
// 旧域内文件(M159 的落点):开关是关的。
let legacy = ctx.layout().cache().join("update").join("prefs.json");
write_json(&legacy, &json!({ "auto_check": false }));
let report = get(&ctx, Some("update.auto_check"), false).expect("读 update.auto_check");
assert_eq!(
report.data["value"],
json!(false),
"旧文件里的 false 必须读到(默认值是 true,读成默认就是丢值)"
);
assert_eq!(
report.data["path"],
json!(ctx.launcher_config().display().to_string()),
"落点必须是主配置(schema API),不是更新域自己的文件"
);
assert_eq!(
report.data["migration"]["id"],
json!("migrated"),
"迁移要走一遍并如实报出来:{report:?}"
);
let receipt = report.data["migration"]["receipt"]
.as_str()
.expect("收据路径")
.to_string();
assert!(Path::new(&receipt).is_file(), "收据可查:{receipt}");
assert!(!legacy.exists(), "旧文件必须退役");
assert!(
report
.human
.iter()
.any(|line| line.contains("已把旧更新偏好迁入主配置")),
"{:?}",
report.human
);
// 第二遍:什么都不做(幂等),值仍是 false。
let again = get(&ctx, Some("update.auto_check"), false).expect("再读一遍");
assert_eq!(again.data["value"], json!(false));
assert!(
again.data.get("migration").is_none(),
"第二遍不该再报迁移:{again:?}"
);
// `channels` 里只有 stable 时,写 beta 要当面被拒(而不是静默写成「设了没生效」)。
let error = set(&ctx, "update.channel", "beta").expect_err("beta 不在允许值里");
assert!(error.to_string().contains("beta"), "{error}");
// 允许值写得进去,且读得回来。
set(&ctx, "update.channel", "stable").expect("写 stable");
let channel = get(&ctx, Some("update.channel"), false).expect("读 update.channel");
assert_eq!(channel.data["value"], json!("stable"));
}
+216
View File
@@ -0,0 +1,216 @@
//! `ull launch` 的账户选择:三轨账户 → [`AuthPlaceholders`](唯一的身份出口)。
//!
//! 选择规则(headless 没有交互,必须确定):
//! - `--account <name>`:按玩家名 / 登录名 / 条目 id 精确找(大小写不敏感);
//! - 不给 `--account`:**唯一账户自动选中**;零账户回落离线占位(`--username` 或 `Player`,
//! 与 M21 的行为一致,保证没有账户时 `ull launch` 照旧能跑);多账户报错并列出候选
//! (猜一个等于让用户拿错身份进服务器,宁可让他选)。
//!
//! 令牌策略:`--dry-run` **不刷新、不写盘**(M30 的 dry-run 纯读语义),只对过期状态给告警;
//! 真启动前对微软轨做一次 `ensure_fresh`(静默续期),刷新成功立刻回写凭据
//! (refresh_token 轮换后旧值就作废了,不能丢)。
use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials};
use unlucky_core::auth::microsoft::MicrosoftAuth;
use unlucky_core::auth::offline::offline_login;
use unlucky_core::auth::AccountStore;
use unlucky_core::download::blocking::block_on;
use unlucky_core::game::ProfileId;
use unlucky_core::launch::request::AuthPlaceholders;
use crate::context::Ctx;
use crate::error::CliError;
/// 解析结果。
pub struct Identity {
/// 喂给 launch 的占位符集合。
pub placeholders: AuthPlaceholders,
/// 选中的账户(`None` = 离线占位,没有账户)。
pub account: Option<AccountMeta>,
/// 需要展示给用户的说明(令牌过期、authlib 注入未落地等)。
pub notes: Vec<String>,
}
/// 解析本次启动的身份。
///
/// # Errors
///
/// 账户不存在/重名冲突/多账户未指定/凭据过期且刷新失败/凭据文件缺条目。
pub fn resolve(
ctx: &Ctx,
account: Option<&str>,
username: Option<&str>,
dry_run: bool,
) -> Result<Identity, CliError> {
let store = AccountStore::open(ctx.auth_data());
let chosen: Option<AccountMeta> = match account {
Some(key) => Some(
store
.find(key)
.ok_or_else(|| unknown_account(key, &store))?
.clone(),
),
None => match store.accounts() {
[] => None,
[only] => Some(only.clone()),
many => {
return Err(CliError::user(
"account_ambiguous",
format!("有 {} 个账户,需要指定用哪个", many.len()),
)
.with_hint(format!(
"%prog% launch <instance> --account <名字>;候选:{}",
names_of(&store)
)))
}
},
};
let Some(meta) = chosen else {
// 零账户:回落离线占位(保留 M21 行为)。
return Ok(offline_fallback(username));
};
if username.is_some() {
return Err(CliError::usage(format!(
"已选中账户 {},`--username` 只用于没有账户时的离线占位",
meta.display_name()
))
.with_hint("去掉 --username,或用 `%prog% accounts add offline <名字>` 建账户"));
}
let mut notes = Vec::new();
let placeholders = match meta.kind {
AccountKind::Offline => offline_login(&meta).map_err(super::map_auth_error)?,
AccountKind::Microsoft => microsoft_identity(ctx, &store, &meta, dry_run, &mut notes)?,
AccountKind::AuthlibInjector => authlib_identity(&store, &meta, &mut notes)?,
};
Ok(Identity {
placeholders,
account: Some(meta),
notes,
})
}
/// 零账户时的离线占位(uuid 随机,userType 用 HMCL 的 `legacy` 占位口径不变)。
fn offline_fallback(username: Option<&str>) -> Identity {
Identity {
placeholders: AuthPlaceholders {
player_name: username.unwrap_or("Player").to_string(),
uuid: ProfileId::generate().as_str().replace('-', ""),
user_type: "legacy".to_string(),
user_properties: "{}".to_string(),
..AuthPlaceholders::default()
},
account: None,
notes: vec![
"没有账户,按离线占位启动(%prog% accounts add offline <名字> 可建账户)".to_string(),
],
}
}
fn microsoft_identity(
ctx: &Ctx,
store: &AccountStore,
meta: &AccountMeta,
dry_run: bool,
notes: &mut Vec<String>,
) -> Result<AuthPlaceholders, CliError> {
let Some(Credentials::Microsoft(session)) = store.credentials(&meta.id) else {
return Err(CliError::user(
"account_credentials",
format!("账户 {} 的微软凭据缺失或类型不符", meta.display_name()),
)
.with_hint("重新登录:%prog% accounts add msa"));
};
if dry_run {
// dry-run 不刷新:令牌过期只提示,命令行里的 token 反正会被脱敏。
if session.is_expired(unlucky_core::auth::microsoft::now_ms()) {
notes.push("微软令牌已过期(--dry-run 不刷新,真启动会自动续期)".to_string());
}
return session.to_placeholders().map_err(super::map_auth_error);
}
let auth = MicrosoftAuth::from_env().map_err(super::map_auth_error)?;
let (refreshed, reason) =
block_on(auth.ensure_fresh(session)).map_err(super::map_auth_error)?;
if let Some(reason) = reason {
notes.push(format!("{}(已静默续期)", reason.message()));
persist_refresh(ctx, meta, &refreshed)?;
}
refreshed.to_placeholders().map_err(super::map_auth_error)
}
/// 刷新成功后立刻回写凭据(refresh_token 已轮换,旧值作废)。
fn persist_refresh(
ctx: &Ctx,
meta: &AccountMeta,
session: &unlucky_core::auth::MicrosoftSession,
) -> Result<(), CliError> {
let mut store = AccountStore::open(ctx.auth_data());
store
.update_credentials(&meta.id, Credentials::Microsoft(session.clone()))
.map_err(super::map_auth_error)?;
let mut updated = meta.clone();
// profile_name 属于可变的私有缓存,刷新后顺手把元数据里的展示名补上。
if !session.profile_name.trim().is_empty() {
updated.profile_name = session.profile_name.clone();
store.update_meta(updated).map_err(super::map_auth_error)?;
}
store.save().map_err(super::map_auth_error)
}
fn authlib_identity(
store: &AccountStore,
meta: &AccountMeta,
notes: &mut Vec<String>,
) -> Result<AuthPlaceholders, CliError> {
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&meta.id) else {
return Err(CliError::user(
"account_credentials",
format!("账户 {} 的 authlib 凭据缺失或类型不符", meta.display_name()),
));
};
if !session.has_profile() {
return Err(CliError::user(
"account_credentials",
format!("账户 {} 还没有选中角色", meta.display_name()),
)
.with_hint("多角色服务器要在图形界面里选角色"));
}
notes.push("authlib-injector 的 -javaagent 参数属后续 mission,本次启动不会注入".to_string());
session.to_placeholders().map_err(super::map_auth_error)
}
fn names_of(store: &AccountStore) -> String {
store
.accounts()
.iter()
.map(|meta| meta.display_name().to_string())
.collect::<Vec<_>>()
.join("、")
}
fn unknown_account(key: &str, store: &AccountStore) -> CliError {
let hint = if store.is_empty() {
"还没有账户:%prog% accounts add offline <名字> 或 %prog% accounts add msa".to_string()
} else {
format!("可用账户:{}", names_of(store))
};
CliError::user("not_found", format!("找不到账户 `{key}`")).with_hint(hint)
}
#[cfg(test)]
mod tests {
use super::offline_fallback;
#[test]
fn fallback_uses_username_or_player() {
let identity = offline_fallback(Some("Alex"));
assert_eq!(identity.placeholders.player_name, "Alex");
assert!(identity.account.is_none());
assert_eq!(identity.placeholders.user_type, "legacy");
assert_eq!(identity.placeholders.uuid.len(), 32);
assert_eq!(offline_fallback(None).placeholders.player_name, "Player");
}
}
+208
View File
@@ -0,0 +1,208 @@
//! `ull install <version> --loader <kind> [--loader-version <v>]`:加载器层的编排。
//!
//! ```text
//! 目标解析:<version> 位是原版版本 → 叠原版;是已装的加载器版本 id → 叠在它上面(仅 LiteLoader)
//! → LoaderSource::resolve(官方 meta:版本列表 → profile JSON / installer profile / LiteLoader 版本表)
//! → LoaderInstallPlan::build(纯计算:库清单 + loader 版本 JSON 落点)
//! → [--dry-run 到此为止,但多带一段 processors 计划]
//! → 原版已装?否 → vanilla::plan_and_install(先装原版)
//! → 需要 installer?→ 依赖库 → 选 Java → 串行跑 processors(M35)
//! → execute(loader 库并发下载 → 全量就绪才写 versions/<loader-id>/<loader-id>.json)
//! → register(实例 versionId 指向 loader 版本)
//! ```
//!
//! 「先装原版」是**硬前置**:loader 版本 JSON 只声明 `inheritsFrom`,jar 与资源都在原版那层,
//! 原版缺失时启动链必然断裂。已装则跳过(`is_version_installed` 判据 = 版本 JSON 在盘上)。
//!
//! **父版本**:`<version>` 位写成加载器版本 id(`1.12.2-forge-14.23.5.2860`)时,它就是父版本,
//! 原版版本从盘上的继承链读出来(`addon::loader::parent`)。只有 LiteLoader 接受这种叠法
//! (它靠 `--tweakClass` 追加生效);别家**具名拒绝**,不会默默改成叠原版。
//!
//! processors 段的顺序也是硬约束:**先跑 processors,再写版本 JSON**。版本清单存在是
//! "这版装完了"的判据,抢在 processors 前写会留下一个启动必崩却被报成已安装的实例。
use unlucky_core::addon::loader::{
parent, LoaderError, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderSource,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::execute;
use unlucky_core::game::loader::{InstanceLoader, LoaderKind as WideLoaderKind};
use unlucky_core::game::GameLayout;
use super::report_loader::ProcessorSection;
use super::{
build_engine, ensure_name_free, failure_error, loader_processors, map_install_error, progress,
register_instance, report_loader, vanilla, CONCURRENCY,
};
use crate::cli::InstallArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// 执行加载器安装。
///
/// # Errors
///
/// 用法错误、加载器未知/不支持、父版本缺失或组合不支持、元数据读取失败、原版装不上、
/// 库下载失败、installer processors 失败、实例登记失败。
pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Report, CliError> {
let requested = args.version.trim().to_string();
if requested.is_empty() {
return Err(CliError::usage(
"install 需要版本 id:%prog% install <version> --loader fabric",
));
}
let engine = build_engine(ctx);
let layout = ctx.layout();
let game = ctx.game();
let meta_cache = layout.meta_cache();
let now = now_ms();
let (version, parent) = resolve_target(ctx, &game, &requested, kind)?;
let release = block_on(async {
let source = LoaderSource::new(&engine, &meta_cache);
source
.resolve_on(
kind,
&version,
args.loader_version.as_deref(),
parent.as_deref(),
)
.await
})
.map_err(|error: LoaderError| map_loader_error(&error, ctx))?;
let name = args
.name
.clone()
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| release.version_id.clone());
ensure_name_free(ctx, &name, &release.version_id)?;
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &game,
release: &release,
environment: &Environment::host(),
parent_installed: game.is_version_installed(&release.parent_version_id),
})
.map_err(|error| map_loader_error(&error, ctx))?;
let processor_plan = loader_processors::plan(ctx, &release)?;
if args.dry_run {
let section = processor_plan.as_ref().map(|plan| ProcessorSection {
plan,
libraries: None,
run: None,
skipped: false,
});
return Ok(report_loader::build(
ctx, &engine, now, true, &plan, None, None, None, &name, section,
));
}
let mut vanilla_report = None;
if plan.vanilla_install_needed {
let (_, report) = vanilla::plan_and_install(
ctx,
&engine,
&version,
false,
args.progress,
args.json_progress,
)?;
if let Some(report) = &report {
if !report.is_complete() {
return Err(failure_error(report, ctx));
}
}
// **M216 r2 P2-2**:原版那段 report 不能在这里丢掉——它的 `warnings` / `manifest`
// (可能替换了共享 `.minecraft` 里 `versions/<原版>/<原版>.json`)必须进人读与 `--json`。
vanilla_report = report;
}
// installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。
let mut processor_libs = None;
let mut processor_run = None;
let mut processor_skipped = false;
if let Some(processor_plan) = &processor_plan {
if game.is_version_installed(&release.version_id) {
processor_skipped = true;
} else {
let (libraries, run) = loader_processors::execute(ctx, &engine, processor_plan, args)?;
processor_libs = Some(libraries);
processor_run = Some(run);
}
}
let sink = progress::progress_sink(args.progress, args.json_progress, plan.files().len());
let install_report = block_on(execute(&engine, &plan.install, CONCURRENCY, sink))
.map_err(|error| map_install_error(&error, ctx))?;
if !install_report.is_complete() {
return Err(failure_error(&install_report, ctx));
}
let registration = register_instance(ctx, &name, &release.version_id)?;
let section = processor_plan
.as_ref()
.map(|processor_plan| ProcessorSection {
plan: processor_plan,
libraries: processor_libs.as_ref(),
run: processor_run.as_ref(),
skipped: processor_skipped,
});
Ok(report_loader::build(
ctx,
&engine,
now,
false,
&plan,
Some(&install_report),
vanilla_report.as_ref(),
Some(&registration),
&name,
section,
))
}
/// 加载器错误 → CLI 错误(稳定码 `loader_*`)。
fn map_loader_error(error: &LoaderError, ctx: &Ctx) -> CliError {
let hint = match error.hint() {
Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()),
None => format!("游戏根:{}", ctx.game().root().display()),
};
CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint)
}
/// `<version>` 位 → (原版版本, 父版本 id)。
///
/// 两种写法(`InstallArgs.version` 的文档口径允许版本 id):
/// - 原版版本(`1.12.2`)→ 叠原版,父版本 = 原版;
/// - **已装的加载器版本 id**(`1.12.2-forge-14.23.5.2860`)→ 叠在它上面,原版版本从盘上的
/// 继承链读出来。只有 LiteLoader 接受这种叠法;别家给具名错误,不静默改成叠原版。
fn resolve_target(
ctx: &Ctx,
game: &GameLayout,
requested: &str,
kind: LoaderKind,
) -> Result<(String, Option<String>), CliError> {
if InstanceLoader::parse(requested).kind == WideLoaderKind::Vanilla {
return Ok((requested.to_string(), None));
}
if kind != LoaderKind::LiteLoader {
return Err(CliError::usage(format!(
"`{requested}` 是一个已装加载器版本 id:只有 LiteLoader 支持叠在别的加载器层上,{} 只能叠在原版上",
kind.label()
))
.with_hint(
"换成原版版本号:`%prog% install 1.20.1 --loader forge`;或先 `%prog% instances list` 看已装版本",
));
}
let mc_version =
parent::root_version_id(game, requested).map_err(|error| map_loader_error(&error, ctx))?;
Ok((mc_version, Some(requested.to_string())))
}
@@ -0,0 +1,181 @@
//! 加载器的 installer processors 段(M35):依赖库下载 → 选 Java → 串行执行。
//!
//! 只在 `LoaderSupport::RequiresInstaller` 的版本上出现(Forge 1.13~1.20.x 主流段、
//! NeoForge 1.20.5+)。其余加载器(Fabric/Quilt、Forge spec 0)走纯 json 路线,这一段整块跳过。
//!
//! ```text
//! ProcessorPlan::build(core,纯计算:变量表 + 每步命令行 + 依赖库清单)
//! → download_libraries(M27 引擎:镜像/健康度/退避/校验)
//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘)
//! → run(串行 java -cp,产物存在性硬校验、摘要软校验)
//! ```
//!
//! 顺序上与版本 JSON 的关系是**硬约束**:processors 全部成功之后才让 M27 的 `execute`
//! 写 `versions/<id>/<id>.json`——那份清单是"这版装完了"的判据,抢在 processors 前写会
//! 留下一个启动必崩却被报成"已安装"的实例。
use std::sync::Arc;
use unlucky_core::addon::loader::processors::{
self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink,
};
use unlucky_core::addon::loader::LoaderRelease;
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::InstallReport;
use super::{failure_error, progress, CONCURRENCY};
use crate::cli::InstallArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::progress_line;
/// 组执行计划;不需要跑 installer 的加载器返回 `None`。
///
/// # Errors
///
/// install profile 的 `data` 表/`processors` 形状不对时返回 `processor_plan` 错误。
pub(super) fn plan(ctx: &Ctx, release: &LoaderRelease) -> Result<Option<ProcessorPlan>, CliError> {
if !release.requires_installer() {
return Ok(None);
}
let (Some(profile), Some(installer)) = (
release.install_profile.as_ref(),
release.installer.as_deref(),
) else {
return Err(CliError::user(
"loader_metadata",
format!(
"{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径",
release.kind.label(),
release.loader_version
),
)
.with_hint("重跑一次(元数据缓存可能不完整)"));
};
let game = ctx.game();
let scratch = ctx
.layout()
.cache()
.join("processors")
.join(safe_name(&release.version_id));
let plan = ProcessorPlan::build(ProcessorPlanInput {
kind: release.kind,
mc_version: &release.mc_version,
version_id: &release.version_id,
profile,
layout: &game,
installer,
scratch: &scratch,
environment: &Environment::host(),
})
.map_err(map_processor_error)?;
Ok(Some(plan))
}
/// 下载依赖库 + 执行 processors。
///
/// # Errors
///
/// 依赖库下载失败(稳定码沿用 `install_*`)、找不到可用 Java、processor 非零退出、
/// 产物没落盘。
pub(super) fn execute(
ctx: &Ctx,
engine: &DownloadEngine,
plan: &ProcessorPlan,
args: &InstallArgs,
) -> Result<(InstallReport, ProcessorRun), CliError> {
let sink = progress::progress_sink(args.progress, args.json_progress, plan.libraries.len());
let libraries = block_on(processors::download_libraries(
engine,
&plan.libraries,
CONCURRENCY,
sink,
));
if !libraries.is_complete() {
return Err(failure_error(&libraries, ctx));
}
let managed = ctx.layout().java_managed();
let Some(java) = processors::select_java(&managed, &plan.java_requirement) else {
return Err(CliError::user(
"processor_java_missing",
format!(
"跑 installer processors 需要 {},但一个可用的 Java 都没找到",
plan.java_requirement.describe()
),
)
.with_hint(format!(
"`%prog% java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
plan.java_requirement.minimum,
managed.display()
)));
};
let sink = step_sink(args.progress, args.json_progress, plan.step_count());
let run = processors::run(plan, &java, sink).map_err(map_processor_error)?;
Ok((libraries, run))
}
/// 每步一行的实时进度(只写 stderr)。
fn step_sink(enabled: bool, json_mode: bool, total: usize) -> Option<StepSink> {
if !enabled {
return None;
}
Some(Arc::new(move |outcome: &StepOutcome| {
let record = serde_json::json!({
"event": "processor",
"index": outcome.index,
"total": total,
"jar": outcome.label,
"status": outcome.status.as_str(),
"elapsed_ms": outcome.elapsed_ms,
});
let text = format!(
"processor {}/{} {} → {}(输出 {} 个{})",
outcome.index,
total,
outcome.label,
if outcome.status.as_str() == "skipped" {
"跳过(产物已就绪)"
} else {
"执行完成"
},
outcome.outputs,
if outcome.elapsed_ms > 0 {
format!(",{} ms", outcome.elapsed_ms)
} else {
String::new()
}
);
if json_mode {
progress_line(&record.to_string());
} else {
progress_line(&text);
}
}))
}
/// processor 执行错误 → CLI 错误(稳定码 `processor_*`)。
fn map_processor_error(error: ProcessorError) -> CliError {
let cli = CliError::user(error.kind().as_str(), error.message().to_string());
match error.hint() {
Some(hint) => cli.with_hint(hint.to_string()),
None => cli,
}
}
/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉)。
fn safe_name(raw: &str) -> String {
raw.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect::<String>()
.trim_end_matches('.')
.to_string()
}
+145
View File
@@ -0,0 +1,145 @@
//! `ull install <version> [--loader <kind>] [--loader-version <v>]`:装版本本体 / 装加载器层。
//!
//! ```text
//! --loader 缺省 → vanilla:M27 原版安装(resolve → plan → execute → register)
//! --loader fabric|quilt → addon::loader:原版(已装则跳)→ loader 版本 JSON → 补 loader 库 → register
//! --loader neoforge|forge → 同上;需要 installer processors 的版本再跑 M35 执行器(依赖库 → java → 版本 JSON)
//! --loader liteloader → 官方版本表 → 从零合成 launchwrapper 清单(不跑任何外部进程)
//! ```
//!
//! CLI 只做四件事:建下载引擎、把计划/结果排版成人读与 `--json` 两种形态
//! ([`report`] / [`report_loader`])、把错误翻译成稳定错误码、往 stderr 打实时进度。
//! 所有版本/继承/库/下载逻辑都在 core(CLI 不复制任何路径或镜像规则)。
mod loader;
mod loader_processors;
mod progress;
mod report;
mod report_loader;
mod vanilla;
use std::sync::Arc;
use unlucky_core::addon::loader::LoaderKind;
use unlucky_core::download::{DownloadEngine, DownloadErrorKind};
use unlucky_core::game::install::{InstallError, InstallReport, Registration};
use unlucky_core::game::LoaderKind as WideLoaderKind;
use crate::cli::InstallArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// 文件级并发上限:资源是海量小文件,16 路足够压满带宽又不至于打爆镜像。
pub(super) const CONCURRENCY: usize = 16;
/// 执行 `install`:按 `--loader` 分发。
///
/// "认不出" 与 "认得但装不了" 是两件事,错误码也分开(`loader_unknown` / `loader_unsupported`):
/// `optifine` 属于后者——core 的识别词表里有它(`ull help instances` 也这么写),只是安装管线
/// 没接(要跑外部 `optifine.Patcher` + 原地改 zip)。两者混成一句"未知加载器"会让用户以为
/// 是自己的版本 id 写错了。
///
/// # Errors
///
/// 用法错误、加载器未知/认得但装不了、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。
pub fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
match args
.loader
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
None | Some("vanilla") | Some("none") => vanilla::run(ctx, args),
Some(raw) => match LoaderKind::parse(raw) {
Some(kind) => loader::run(ctx, args, kind),
None => Err(match WideLoaderKind::parse(raw) {
Some(WideLoaderKind::OptiFine) => CliError::user(
"loader_unsupported",
"optifine 认得出但还没接入安装管线(要跑外部 Patcher 并原地改 zip)",
)
.with_hint(
"先用官方安装器装好,再 `%prog% instances list` 确认它已在;或用 fabric / quilt / neoforge / forge / liteloader",
),
_ => CliError::user("loader_unknown", format!("未知加载器 `{raw}`")).with_hint(
"--loader 取值:vanilla / fabric / quilt / neoforge / forge / liteloader(optifine 认得出但装不了)",
),
}),
},
}
}
/// 登记实例(幂等):`profiles.json` + `config/instances/<id>.json`。
///
/// 原版安装与加载器安装共用:前者 `version_id` = 原版 id,后者 = loader 版本 id
/// (实例的 `versionId` 必须指向 loader 版本,否则启动会跑成原版)。
pub(super) fn register_instance(
ctx: &Ctx,
name: &str,
version_id: &str,
) -> Result<Registration, CliError> {
let created_at =
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
unlucky_core::game::install::instance::register(
&ctx.layout(),
name,
version_id,
Some(&created_at),
)
.map_err(|error| CliError::user("instance_register", format!("登记实例失败:{error}")))
}
/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,装配参数来自设置(并发 / 重试 / gh-proxy,
/// 读数口径见 [`crate::download`])——不再用裸 `EngineConfig::default()`(M203)。
pub(crate) fn build_engine(ctx: &Ctx) -> Arc<DownloadEngine> {
crate::download::engine(ctx)
}
/// 同名但绑定别的版本 → 冲突;同名同版本视为重装(复用实例,允许)。
pub(super) fn ensure_name_free(ctx: &Ctx, name: &str, version: &str) -> Result<(), CliError> {
let profiles = super::instances::read_profiles(ctx)?;
let conflict = profiles
.profiles
.iter()
.find(|profile| profile.name == name && profile.version_id != version);
match conflict {
Some(profile) => Err(CliError::user(
"instance_exists",
format!("同名实例已存在:{name}(绑定版本 {})", profile.version_id),
)
.with_hint("用 --name 换个名字,或 `%prog% instances list` 看看现有实例")),
None => Ok(()),
}
}
/// 安装期错误 → CLI 错误(保留稳定错误码与提示)。
pub(super) fn map_install_error(error: &InstallError, ctx: &Ctx) -> CliError {
let hint = match error.hint() {
Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()),
None => format!("游戏根:{}", ctx.game().root().display()),
};
CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint)
}
/// 有文件失败 → 按"最严重的失败类别"给稳定码,并列出前几条明细。
pub(super) fn failure_error(report: &InstallReport, ctx: &Ctx) -> CliError {
let code = match report.failure_kind() {
Some(DownloadErrorKind::Integrity) => "install_integrity",
Some(DownloadErrorKind::Io) => "install_io",
_ => "install_network",
};
let mut message = format!(
"{} 个文件没下成(共 {} 个)",
report.failed(),
report.files.len()
);
for outcome in report.failures(3) {
if let Some(failure) = &outcome.failure {
message.push_str(&format!(";{}:{}", outcome.name, failure.message));
}
}
CliError::user(code, message).with_hint(format!(
"重跑 `%prog% install` 会续传并跳过已就绪文件;游戏根 {}",
ctx.game().root().display()
))
}
+303
View File
@@ -0,0 +1,303 @@
//! stderr 实时进度(原版安装 / 加载器库 / processor 依赖库三段共用)。
//!
//! **M210/D11 的教训**:进度行的数字必须是**已经发生的事实**(每个文件的 [`FileStatus`]),
//! 不能拿「处理到第几个文件」冒充「下载了几个」。旧实现在回调里只累加一个 `done` 就打印
//! `下载 {done}/{total}`,于是重装同版本(3961 个文件全部已就绪、一个字节都没下)时会连打
//! `下载 32/3961 … 3961/3961`——进度条在跑,下载量是 0,用户在读一句假话。
//!
//! 现在分三个计数(下载 / 跳过 / 失败),人读行的措辞随计数走:下载数为 0 时**不出现「下载」
//! 二字**,改说「已就绪 N/M(跳过 N 个)」。
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use unlucky_core::game::install::{FileOutcome, FileStatus, ProgressSink};
use crate::output::progress_line;
/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。
const PROGRESS_EVERY: usize = 32;
/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。
const MAX_PROGRESS_FAILURES: usize = 5;
/// 一次进度快照:每个字段都是**真实结局**的计数。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Snapshot {
/// 已处理文件数(含跳过与失败)。
pub done: usize,
/// 计划文件总数。
pub total: usize,
/// 本次真的下载并落盘的文件数。
pub downloaded: usize,
/// 已存在且校验通过、本次跳过的文件数。
pub skipped: usize,
/// 失败文件数。
pub failed: usize,
}
impl Snapshot {
/// 人读进度行。
///
/// 下载数为 0 时**不提「下载」**:本次一个字节都没下,再写「下载 0/N」也只是换个说法的假进度。
pub(crate) fn text(&self) -> String {
let failure = if self.failed > 0 {
format!(" / 失败 {}", self.failed)
} else {
String::new()
};
if self.downloaded == 0 {
format!(
"已就绪 {}/{}(跳过 {} 个{failure})",
self.done, self.total, self.skipped
)
} else {
format!(
"下载 {} 个 / 跳过 {} 个(进度 {}/{}{failure})",
self.downloaded, self.skipped, self.done, self.total
)
}
}
/// `--json` 模式的进度事件(字段名稳定,`done`/`total` 保持旧名以兼容脚本)。
fn json(&self) -> serde_json::Value {
serde_json::json!({
"event": "progress",
"done": self.done,
"total": self.total,
"downloaded": self.downloaded,
"skipped": self.skipped,
"failed": self.failed,
})
}
}
/// 计数:回调来自多个下载线程,全部原子累加。
#[derive(Debug, Default)]
struct Tally {
done: AtomicUsize,
downloaded: AtomicUsize,
skipped: AtomicUsize,
failed: AtomicUsize,
failure_lines: AtomicUsize,
}
impl Tally {
/// 记一个结局,返回记完之后的快照。
fn observe(&self, outcome: &FileOutcome, total: usize) -> Snapshot {
let done = self.done.fetch_add(1, Ordering::Relaxed) + 1;
match outcome.status {
FileStatus::Downloaded => {
self.downloaded.fetch_add(1, Ordering::Relaxed);
}
FileStatus::Skipped => {
self.skipped.fetch_add(1, Ordering::Relaxed);
}
FileStatus::Failed => {
self.failed.fetch_add(1, Ordering::Relaxed);
}
}
Snapshot {
done,
total,
downloaded: self.downloaded.load(Ordering::Relaxed),
skipped: self.skipped.load(Ordering::Relaxed),
failed: self.failed.load(Ordering::Relaxed),
}
}
}
/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。
///
/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流),
/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。
///
/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。
pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option<ProgressSink> {
sink_with(enabled, json, total, Arc::new(progress_line))
}
/// 可注入输出端的构造器(测试据此抓行;生产只有一个调用者 [`progress_sink`])。
fn sink_with(
enabled: bool,
json: bool,
total: usize,
out: Arc<dyn Fn(&str) + Send + Sync>,
) -> Option<ProgressSink> {
if !enabled {
return None;
}
let tally = Arc::new(Tally::default());
Some(Arc::new(move |outcome: &FileOutcome| {
let snapshot = tally.observe(outcome, total);
if let Some(failure) = &outcome.failure {
let seen = tally.failure_lines.fetch_add(1, Ordering::Relaxed);
if seen < MAX_PROGRESS_FAILURES {
emit(
json,
serde_json::json!({
"event": "file_failed",
"done": snapshot.done,
"total": total,
"name": outcome.name,
"role": outcome.role.as_str(),
"kind": failure.kind.as_str(),
"message": failure.message,
}),
&format!(
"失败 {}/{} {}:{}",
snapshot.done, total, outcome.name, failure.message
),
&out,
);
} else if seen == MAX_PROGRESS_FAILURES {
emit(
json,
serde_json::json!({
"event": "progress_truncated",
"suppressed_after": MAX_PROGRESS_FAILURES,
}),
"(更多失败不再逐条打印,见最终错误汇总)",
&out,
);
}
return;
}
if snapshot.done.is_multiple_of(PROGRESS_EVERY) || snapshot.done == total {
emit(json, snapshot.json(), &snapshot.text(), &out);
}
}))
}
/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。
fn emit(
json_mode: bool,
record: serde_json::Value,
text: &str,
out: &Arc<dyn Fn(&str) + Send + Sync>,
) {
if json_mode {
let line = record.to_string();
out(&line);
} else {
out(text);
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use unlucky_core::download::DownloadErrorKind;
use unlucky_core::game::install::{FileFailure, FileRole};
use super::*;
/// 测试用的输出端:一个可注入的 sink + 它收到的行。
type Recorder = (Arc<dyn Fn(&str) + Send + Sync>, Arc<Mutex<Vec<String>>>);
/// 抓行的输出端。
fn recorder() -> Recorder {
let lines = Arc::new(Mutex::new(Vec::new()));
let sink_lines = Arc::clone(&lines);
let out: Arc<dyn Fn(&str) + Send + Sync> =
Arc::new(move |line: &str| sink_lines.lock().expect("锁").push(line.to_string()));
(out, lines)
}
fn outcome(name: &str, status: FileStatus) -> FileOutcome {
FileOutcome {
role: FileRole::Asset,
name: name.to_string(),
dest: std::path::PathBuf::from(format!("assets/{name}")),
status,
bytes: 1234,
source: Some("cache".to_string()),
failure: None,
}
}
/// **D11 用例**:全部已存在(重装同版本)⇒ 进度行里不出现「下载」,下载计数为 0。
///
/// 抓得住:把「处理序号」当下载量打印的旧实现(它会打出 `下载 32/96` 这类行);
/// 以及任何「下载数 > 0 却说没下载」的表述。抓不住:行里出现的其它夸张词(措辞审查),
/// 也不管单行是否真的落到 stderr(那是 [`crate::output::progress_line`] 的事)。
#[test]
fn all_skipped_progress_never_claims_downloads() {
const TOTAL: usize = 96; // 3 的倍数,必然跨过 PROGRESS_EVERY
let (out, lines) = recorder();
let sink = sink_with(true, false, TOTAL, out).expect("进度开关打开时必须有 sink");
for index in 0..TOTAL {
sink(&outcome(&format!("asset-{index}"), FileStatus::Skipped));
}
let lines = lines.lock().expect("锁").clone();
assert!(!lines.is_empty(), "应当有进度行");
for line in &lines {
assert!(
!line.contains("下载"),
"全部跳过时进度行不许提「下载」:{line}"
);
}
let last = lines.last().expect("至少一行");
assert!(
last.contains(&format!("已就绪 {TOTAL}/{TOTAL}"))
&& last.contains(&format!("跳过 {TOTAL}")),
"全跳过应当说「已就绪 N/N(跳过 N 个)」:{last}"
);
}
/// 同一场景的 `--json` 形态:`downloaded` 必须是 0(脚本据此判断「其实没下」)。
#[test]
fn all_skipped_json_progress_reports_zero_downloads() {
const TOTAL: usize = 32;
let (out, lines) = recorder();
let sink = sink_with(true, true, TOTAL, out).expect("进度开关打开时必须有 sink");
for index in 0..TOTAL {
sink(&outcome(&format!("lib-{index}"), FileStatus::Skipped));
}
let records: Vec<serde_json::Value> = lines
.lock()
.expect("锁")
.iter()
.map(|line| serde_json::from_str(line).expect("JSONL"))
.collect();
let progress = records
.iter()
.find(|record| record["event"] == "progress")
.expect("应当有 progress 事件");
assert_eq!(progress["downloaded"], 0);
assert_eq!(progress["skipped"], TOTAL);
assert_eq!(progress["done"], TOTAL);
assert_eq!(progress["total"], TOTAL);
}
/// 真的有下载时,计数与措辞都跟着事实走(避免「一刀切不打印」这种假修复)。
#[test]
fn mixed_outcomes_report_real_counts() {
let (out, lines) = recorder();
let sink = sink_with(true, false, 4, out).expect("sink");
sink(&outcome("client.jar", FileStatus::Downloaded));
let failed = FileOutcome {
failure: Some(FileFailure {
kind: DownloadErrorKind::Network,
message: "连接被重置".to_string(),
}),
..outcome("b", FileStatus::Failed)
};
sink(&failed);
sink(&outcome("c", FileStatus::Skipped));
sink(&outcome("d", FileStatus::Skipped));
let lines = lines.lock().expect("锁").clone();
// 失败有自己的行(`失败 2/4 b:…`),进度行不拿它冒充下载量。
assert!(
lines.iter().any(|line| line.starts_with("失败 2/4 b")),
"{lines:?}"
);
let last = lines.last().expect("至少一行");
// done == total 时无条件打最后一行。
assert!(last.contains("下载 1 个"), "{last}");
assert!(last.contains("跳过 2 个"), "{last}");
assert!(last.contains("失败 1"), "{last}");
assert!(last.contains("进度 4/4"), "{last}");
}
}
+265
View File
@@ -0,0 +1,265 @@
//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷(stderr 实时进度见 [`super::progress`])。
//!
//! 拆出来是因为 `install` 的流程(规划 → 下载 → 登记)与排版彼此独立:
//! 前者要能读懂,后者要能被脚本断言。`--dry-run` 与真实安装共用同一份排版,
//! 差别只是多出逐文件清单与源选择。
use serde_json::json;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::install::{FileOutcome, InstallPlan, InstallReport, Registration};
use crate::context::Ctx;
use crate::output::Report;
/// 排版一次安装结果。
#[allow(clippy::too_many_arguments)]
pub fn build(
ctx: &Ctx,
engine: &DownloadEngine,
now: u64,
dry_run: bool,
plan: &InstallPlan,
report: Option<&InstallReport>,
registration: Option<&Registration>,
name: &str,
version: &str,
) -> Report {
let samples = if dry_run {
plan.source_samples(engine, now)
} else {
Vec::new()
};
let mut data = json!({
"dry_run": dry_run,
"version_id": plan.version_id,
"jar_id": plan.jar_id,
"game_root": plan.game_root.display().to_string(),
"plan": plan.summary_json(),
"result": report.map(|report| json!({
"downloaded": report.downloaded(),
"skipped": report.skipped(),
"failed": report.failed(),
"bytes": report.bytes(),
"version_json": report.version_json.as_ref().map(|path| path.display().to_string()),
"warnings": report.warnings,
"files": report.files.iter().map(outcome_json).collect::<Vec<_>>(),
})),
"instance": registration.map(|registration| json!({
"id": registration.profile.id.as_str(),
"name": registration.profile.name,
"version_id": registration.profile.version_id,
"created": registration.created,
"config_path": registration.config_path.display().to_string(),
"profiles_path": ctx.profiles_config().display().to_string(),
})),
});
if dry_run {
data["files"] = json!(plan.files_json());
data["sources"] = json!(samples);
}
Report::new(
data,
human_lines(dry_run, plan, report, registration, &samples, name, version),
)
}
/// 单个文件的执行结果(`--json` 的 `result.files`;脚本据此分流"哪几个没下成")。
fn outcome_json(outcome: &FileOutcome) -> serde_json::Value {
json!({
"role": outcome.role.as_str(),
"name": outcome.name,
"dest": outcome.dest.display().to_string(),
"status": outcome.status.as_str(),
"bytes": outcome.bytes,
"source": outcome.source,
"error": outcome.failure.as_ref().map(|failure| json!({
"kind": failure.kind.as_str(),
"message": failure.message,
})),
})
}
fn human_lines(
dry_run: bool,
plan: &InstallPlan,
report: Option<&InstallReport>,
registration: Option<&Registration>,
samples: &[serde_json::Value],
name: &str,
version: &str,
) -> Vec<String> {
let mut human = Vec::new();
let (total_bytes, unknown) = plan.total_bytes();
human.push(if dry_run {
format!("安装计划:{}(jar {})", plan.version_id, plan.jar_id)
} else {
format!("已安装 {}(jar {})", plan.version_id, plan.jar_id)
});
for (role, (count, bytes)) in plan.by_role() {
human.push(format!(
" {:<8} {} 个 / {}",
role.label(),
count,
human_bytes(bytes)
));
}
human.push(format!(
" 合计 {} 个文件 / {}{}(游戏根 {})",
plan.file_count(),
human_bytes(total_bytes),
if unknown > 0 {
format!(" + {unknown} 个大小未知")
} else {
String::new()
},
plan.game_root.display()
));
for sample in samples {
let sources: Vec<&str> = sample["candidates"]
.as_array()
.map(|list| {
list.iter()
.filter_map(|candidate| candidate["source"].as_str())
.collect()
})
.unwrap_or_default();
human.push(format!(
" 源选择 {} → {}",
sample["label"].as_str().unwrap_or_default(),
sources.join(" → ")
));
}
// **M216 P2-2**:有 report 就报**运行期**告警——它开头就是 `plan.warnings` 的副本,加上执行期
// 才知道的事(「已替换版本清单 … 旧的那份备份在 …」)。此前这里只遍历 `plan.warnings`,
// 于是「我们动了别人 `.minecraft` 里那份清单」这句话在 `ull install` 的人读输出里**从没出现过**
// (只有 `--json` 的 `result.warnings` 有)——「不静默破坏」就落不了地。
// 没有 report(`--dry-run`)时退回计划期告警。
let warnings = report.map_or(plan.warnings.as_slice(), |report| {
report.warnings.as_slice()
});
for warning in warnings {
human.push(format!("告警: {warning}"));
}
if let Some(manifest) = report.and_then(|report| report.manifest.as_ref()) {
if let Some(backup) = manifest.backup() {
human.push(format!(
"版本清单已替换(旧的那份备份在 {})",
backup.display()
));
}
}
if let Some(report) = report {
human.push(format!(
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
report.downloaded(),
report.skipped(),
report.failed(),
human_bytes(report.bytes())
));
if let Some(path) = &report.version_json {
human.push(format!("版本清单: {}", path.display()));
}
}
match registration {
Some(registration) => human.push(format!(
"已登记实例 {}({})→ 版本 {}({})",
name,
registration.profile.id.as_str(),
version,
if registration.created {
"新建"
} else {
"已存在,复用"
}
)),
None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()),
}
human
}
/// 字节 → 人读(MB/GB 一位小数,KB 取整;不做假精度)。
pub fn human_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{} KB", bytes / KB)
} else {
format!("{bytes} B")
}
}
#[cfg(test)]
mod tests {
use super::human_bytes;
use super::*;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{ManifestWrite, PlanInput};
use unlucky_core::game::layout::GameLayout;
use unlucky_core::game::manifest::VersionJson;
/// **M216 P2-2**:清单被替换这件事必须出现在**人读**行里(以前只进 `--json`,
/// 复审 r1 抓的就是「对玩家仍然静默」)。
///
/// 抓得住:`human_lines` 退回只遍历 `plan.warnings`(那时找不到「已替换版本清单」⇒ 红,
/// 正是修前状态);`manifest` 的备份路径没渲出来。抓不住:真实终端排版与颜色、
/// 以及 `ull install` 的整条命令链(本用例直接调排版函数,不落盘、不联网)。
#[test]
fn a_replaced_manifest_is_said_out_loud_in_the_human_output() {
let root = std::env::temp_dir().join(format!("ull216-report-{}", std::process::id()));
let layout = GameLayout::new(&root);
let resolved = VersionJson::from_json_str(
r#"{"id":"1.20.1","mainClass":"net.minecraft.client.main.Main",
"downloads":{"client":{"sha1":"aa","size":1,"url":"https://example.invalid/c.jar"}}}"#,
)
.expect("清单应可解析");
let plan = InstallPlan::build(PlanInput {
layout: &layout,
version_id: "1.20.1",
resolved: &resolved,
asset_index: None,
environment: &Environment::host(),
})
.expect("组计划应成功");
let backup = root.join("versions/1.20.1/1.20.1.json.ullbak-4242");
let mut warnings = plan.warnings.clone();
warnings.push("已替换版本清单 <dest>(旧的那份备份在 <backup>)".to_string());
let report = InstallReport {
files: Vec::new(),
version_json: Some(plan.version_json_path.clone()),
manifest: Some(ManifestWrite::Replaced {
backup: backup.clone(),
}),
warnings,
};
let text = human_lines(false, &plan, Some(&report), None, &[], "ignored", "1.20.1").join(
"
",
);
assert!(
text.contains("已替换版本清单"),
"运行期告警没进人读输出:{text}"
);
assert!(
text.contains(&backup.display().to_string()),
"备份路径没进人读输出:{text}"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn formats_sizes_without_fake_precision() {
assert_eq!(human_bytes(0), "0 B");
assert_eq!(human_bytes(1023), "1023 B");
assert_eq!(human_bytes(1024), "1 KB");
assert_eq!(human_bytes(1024 * 1024), "1.0 MB");
assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GB");
}
}
@@ -0,0 +1,399 @@
//! 加载器安装的呈现形态(人类可读行 + `--json` 载荷)。
//!
//! 与 [`super::report`] 的分工:那边是"原版安装"(客户端 jar + 资源 + 库),这边是
//! "叠在原版上的 loader 层"(只有库)。共用同一套进度回调与字节格式化。
use serde_json::{json, Value};
use unlucky_core::addon::loader::processors::{ProcessorPlan, ProcessorRun};
use unlucky_core::addon::loader::LoaderInstallPlan;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::install::{InstallReport, Registration};
use super::report::human_bytes;
use crate::context::Ctx;
use crate::output::Report;
/// processors 段(M35)的报告输入:计划 + 依赖库下载结果 + 执行结果。
///
/// `--dry-run` 只有 `plan`;幂等跳过时 `libraries`/`run` 都是 `None` 且 `skipped = true`。
pub struct ProcessorSection<'a> {
/// 执行计划(dry-run 也带上:将执行的 processor 清单 + 所需 Java)。
pub plan: &'a ProcessorPlan,
/// processor 依赖库的下载结果。
pub libraries: Option<&'a InstallReport>,
/// 执行结果。
pub run: Option<&'a ProcessorRun>,
/// 整段跳过(版本 JSON 已在盘上 = processors 早已跑完)。
pub skipped: bool,
}
/// 排版一次加载器安装结果。
///
/// 参数多是因为载荷本身就多:计划(loader 版本 + 库)、processors 段、执行结果、实例登记、
/// `--dry-run` 的源选择样本。拆成结构体会让调用方多写十行,不如保持扁平。
#[allow(clippy::too_many_arguments)]
pub fn build(
ctx: &Ctx,
engine: &DownloadEngine,
now: u64,
dry_run: bool,
plan: &LoaderInstallPlan,
report: Option<&InstallReport>,
vanilla: Option<&InstallReport>,
registration: Option<&Registration>,
name: &str,
processors: Option<ProcessorSection<'_>>,
) -> Report {
let samples = if dry_run {
plan.install.source_samples(engine, now)
} else {
Vec::new()
};
let mut data = json!({
"dry_run": dry_run,
"loader": plan.release.to_json(),
"vanilla_install_needed": plan.vanilla_install_needed,
"game_root": plan.install.game_root.display().to_string(),
"plan": plan.summary_json(),
"result": report.map(|report| json!({
"downloaded": report.downloaded(),
"skipped": report.skipped(),
"failed": report.failed(),
"bytes": report.bytes(),
"version_json": report.version_json.as_ref().map(|path| path.display().to_string()),
"warnings": report.warnings,
})),
"instance": registration.map(|registration| json!({
"id": registration.profile.id.as_str(),
"name": registration.profile.name,
"version_id": registration.profile.version_id,
"created": registration.created,
"config_path": registration.config_path.display().to_string(),
"profiles_path": ctx.profiles_config().display().to_string(),
})),
});
if dry_run {
data["files"] = json!(plan.install.files_json());
data["sources"] = json!(samples);
}
if let Some(section) = &processors {
data["processors"] = processor_json(section);
}
if let Some(block) = vanilla_json(vanilla) {
data["vanilla"] = block;
}
Report::new(
data,
human_lines(
dry_run,
plan,
report,
vanilla,
registration,
&samples,
name,
processors.as_ref(),
),
)
}
/// processors 段的 JSON 载荷(计划 + 依赖库结果 + 逐步骤结果)。
fn processor_json(section: &ProcessorSection<'_>) -> serde_json::Value {
let mut node = section.plan.to_json();
if let Some(object) = node.as_object_mut() {
object.insert("skipped".to_string(), json!(section.skipped));
object.insert(
"libraries_result".to_string(),
json!(section.libraries.map(|report| json!({
"downloaded": report.downloaded(),
"skipped": report.skipped(),
"failed": report.failed(),
"bytes": report.bytes(),
}))),
);
object.insert(
"run".to_string(),
json!(section.run.map(ProcessorRun::to_json)),
);
}
node
}
/// `--json` 的 `vanilla` 段:先装原版那一步的告警 + 清单结局(`None` = 这次没先装原版)。
///
/// **M216 r2 P2-2**:原版 report 在 `is_complete()` 之后就被丢了 ⇒ 「替换了共享 `.minecraft`
/// 里的原版清单」在加载器安装的 JSON 里没有痕迹。
fn vanilla_json(vanilla: Option<&InstallReport>) -> Option<Value> {
let report = vanilla?;
Some(json!({
"warnings": report.warnings,
"version_json": report.version_json.as_ref().map(|path| path.display().to_string()),
"manifest": report.manifest.as_ref().map(|write| json!({
"kind": write.as_str(),
"backup": write.backup().map(|path| path.display().to_string()),
})),
}))
}
/// 人读行里「先装原版」那一段(**M216 r2 P2-2**,与 [`vanilla_json`] 同源):以前
/// `human_lines` 只遍历 `plan.warnings()`,原版那份 report 的告警与「清单被替换」都没说。
fn vanilla_lines(vanilla: Option<&InstallReport>) -> Vec<String> {
let Some(report) = vanilla else {
return Vec::new();
};
let mut lines: Vec<String> = report
.warnings
.iter()
.map(|warning| format!("告警(原版): {warning}"))
.collect();
if let Some(backup) = report.manifest.as_ref().and_then(|write| write.backup()) {
lines.push(format!(
"原版版本清单已替换(旧的那份备份在 {})",
backup.display()
));
}
lines
}
fn human_lines(
dry_run: bool,
plan: &LoaderInstallPlan,
report: Option<&InstallReport>,
vanilla: Option<&InstallReport>,
registration: Option<&Registration>,
samples: &[serde_json::Value],
name: &str,
processors: Option<&ProcessorSection<'_>>,
) -> Vec<String> {
let release = &plan.release;
let mut human = Vec::new();
human.push(format!(
"{} {}《{} {}》(继承原版 {},主类 {})",
if dry_run {
"加载器安装计划"
} else {
"已安装加载器"
},
release.kind.label(),
release.mc_version,
release.loader_version,
release.mc_version,
release.version_json.main_class.as_deref().unwrap_or("?"),
));
human.push(format!(
" 版本 id {} → {}",
release.version_id,
plan.install.version_json_path.display()
));
human.push(format!(
" 原版 {}",
if plan.vanilla_install_needed {
"未安装:执行时先装原版"
} else {
"已安装:跳过"
}
));
let (total_bytes, unknown) = plan.install.total_bytes();
human.push(format!(
" loader 库 {} 个 / {}{}",
plan.install.file_count(),
human_bytes(total_bytes),
if unknown > 0 {
format!(" + {unknown} 个大小未知")
} else {
String::new()
}
));
for sample in samples {
let sources: Vec<&str> = sample["candidates"]
.as_array()
.map(|list| {
list.iter()
.filter_map(|candidate| candidate["source"].as_str())
.collect()
})
.unwrap_or_default();
human.push(format!(
" 源选择 {} → {}",
sample["label"].as_str().unwrap_or_default(),
sources.join(" → ")
));
}
for warning in plan.warnings() {
human.push(format!("告警: {warning}"));
}
human.extend(vanilla_lines(vanilla));
if let Some(section) = processors {
human.extend(processor_lines(section));
}
if let Some(report) = report {
human.push(format!(
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
report.downloaded(),
report.skipped(),
report.failed(),
human_bytes(report.bytes())
));
if let Some(path) = &report.version_json {
human.push(format!("loader 版本清单: {}", path.display()));
}
}
match registration {
Some(registration) => human.push(format!(
"已登记实例 {}({})→ 版本 {}({})",
name,
registration.profile.id.as_str(),
registration.profile.version_id,
if registration.created {
"新建"
} else {
"已存在,复用"
}
)),
None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()),
}
human
}
/// processors 段的人读行(计划 / 依赖库 / 逐步骤 / 告警)。
fn processor_lines(section: &ProcessorSection<'_>) -> Vec<String> {
let plan = section.plan;
let mut lines = Vec::new();
lines.push(format!(
" installer processors {} 步,需要 {}(installer {})",
plan.step_count(),
plan.java_requirement.describe(),
plan.installer.display()
));
if section.skipped {
lines.push(" 版本清单已存在:processors 整段跳过(幂等)".to_string());
}
if let Some(libraries) = section.libraries {
lines.push(format!(
" processor 依赖库 {} 个:下载 {} / 跳过 {} / 失败 {}",
plan.libraries.len(),
libraries.downloaded(),
libraries.skipped(),
libraries.failed()
));
}
if let Some(run) = section.run {
lines.push(format!(
" Java {}({}){}",
run.java
.major
.map_or_else(|| "未知".to_string(), |major| major.to_string()),
run.java.origin.as_str(),
run.java.binary.display()
));
let total = run.steps.len();
for step in &run.steps {
lines.push(format!(
" [{}/{}] {} → {}{}",
step.index,
total,
step.label,
if step.status.as_str() == "skipped" {
"跳过(产物已就绪)".to_string()
} else {
format!("执行 {} ms", step.elapsed_ms)
},
if step.outputs > 0 {
format!("(产物 {} 个)", step.outputs)
} else {
String::new()
}
));
}
for warning in &run.warnings {
lines.push(format!(" 告警: {warning}"));
}
}
for warning in &plan.warnings {
lines.push(format!(" 告警: {warning}"));
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use unlucky_core::addon::loader::{
LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderSupport,
};
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::ManifestWrite;
use unlucky_core::game::layout::GameLayout;
use unlucky_core::game::manifest::VersionJson;
/// 一份最小的 Fabric 加载器计划(零网络:只算路径,不落盘)。
fn loader_plan(root: &std::path::Path) -> LoaderInstallPlan {
let layout = GameLayout::new(root);
let release = LoaderRelease {
kind: LoaderKind::Fabric,
mc_version: "1.20.1".to_string(),
parent_version_id: "1.20.1".to_string(),
loader_version: "0.15.11".to_string(),
version_id: "1.20.1-fabric-0.15.11".to_string(),
version_json: VersionJson::from_json_str(
r#"{"id":"1.20.1-fabric-0.15.11","mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient","inheritsFrom":"1.20.1","libraries":[]}"#,
)
.expect("loader 清单应可解析"),
support: LoaderSupport::JsonRoute,
installer: None,
install_profile: None,
warnings: Vec::new(),
};
LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &release,
environment: &Environment::host(),
parent_installed: true,
})
.expect("计划应可组")
}
/// **M216 r2 P2-2**:装加载器时「先装原版」那一步替换了共享 `.minecraft` 里的原版清单,
/// 这句话必须出现在**加载器安装**的人读输出里(此前只出现在纯原版安装的输出里)。
///
/// 抓得住:`human_lines` 只遍历 `plan.warnings()`(= r1 状态);备份路径没渲出来;
/// `--json` 的 `vanilla` 段没了。抓不住:真实 `ull install --loader …` 整条链路(要网络),
/// 以及「原版 report 是否真被 `loader.rs` 传进来」那步接线(见报告)。
#[test]
fn a_replaced_vanilla_manifest_is_said_out_loud_in_the_loader_output() {
let root = std::env::temp_dir().join(format!("ull216-loader-{}", std::process::id()));
let plan = loader_plan(&root);
let backup = PathBuf::from("/g/.minecraft/versions/1.20.1/1.20.1.json.ullbak-99");
let vanilla = InstallReport {
files: Vec::new(),
version_json: Some(PathBuf::from("/g/.minecraft/versions/1.20.1/1.20.1.json")),
manifest: Some(ManifestWrite::Replaced {
backup: backup.clone(),
}),
warnings: vec!["已替换版本清单 /g/.minecraft/versions/1.20.1/1.20.1.json".to_string()],
};
let text = human_lines(false, &plan, None, Some(&vanilla), None, &[], "n", None).join("\n");
assert!(
text.contains("已替换版本清单"),
"原版那段的告警没进人读输出:{text}"
);
assert!(
text.contains(&backup.display().to_string()),
"原版清单的备份路径没进人读输出:{text}"
);
// 没先装原版时不许凭空多说话。
let plain = human_lines(false, &plan, None, None, None, &[], "n", None).join("\n");
assert!(!plain.contains("原版版本清单已替换"), "{plain}");
// `--json` 的 `vanilla` 段(结构化,脚本可读)。
let block = vanilla_json(Some(&vanilla)).expect("应当有 vanilla 段");
assert_eq!(block["manifest"]["kind"], Value::String("replaced".into()));
assert_eq!(
block["manifest"]["backup"].as_str(),
Some(backup.to_string_lossy().as_ref())
);
assert!(vanilla_json(None).is_none());
std::fs::remove_dir_all(&root).ok();
}
}
+160
View File
@@ -0,0 +1,160 @@
//! `ull install <version>`(无加载器):M27 原版安装流程的宿主。
//!
//! ```text
//! RemoteSource::resolve(版本清单缓存 → 版本 JSON → 继承链)
//! → RemoteSource::asset_index(资源索引,顺便拿字节给下面预取)
//! → InstallPlan::build(纯计算:文件清单 / 总大小 / 角色)
//! → [--dry-run 到此为止]
//! → execute(跳过已就绪 → 引擎并发下载 → 原子写版本 JSON)
//! → register(profiles.json + config/instances/<id>.json)
//! ```
//!
//! 异步整段包在一次 `block_on` 里:CLI 不依赖 tokio(D18 依赖树纪律),
//! reqwest 的连接池也不会跨运行时而失效。
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{
execute, InstallError, InstallErrorKind, InstallPlan, InstallReport, PlanInput, RemoteSource,
};
use super::{
build_engine, ensure_name_free, failure_error, map_install_error, progress, report, CONCURRENCY,
};
use crate::cli::InstallArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// 执行 `install`(原版)。
///
/// # Errors
///
/// 用法错误、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。
pub(super) fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
let version = args.version.trim().to_string();
if version.is_empty() {
return Err(CliError::usage(
"install 需要版本 id:%prog% install <version>",
));
}
let name = args
.name
.clone()
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| version.clone());
ensure_name_free(ctx, &name, &version)?;
let engine = build_engine(ctx);
let now = now_ms();
let (plan, report) = plan_and_install(
ctx,
&engine,
&version,
args.dry_run,
args.progress,
args.json_progress,
)?;
if let Some(report) = &report {
if !report.is_complete() {
return Err(failure_error(report, ctx));
}
}
let registration = if args.dry_run {
None
} else {
Some(super::register_instance(ctx, &name, &version)?)
};
Ok(report::build(
ctx,
&engine,
now,
args.dry_run,
&plan,
report.as_ref(),
registration.as_ref(),
&name,
&version,
))
}
/// 规划 + 执行原版安装(`--dry-run` 只规划),供加载器安装当"前置步骤"复用。
///
/// # Errors
///
/// 版本不存在、网络失败、清单装不全、写盘失败。
pub(super) fn plan_and_install(
ctx: &Ctx,
engine: &DownloadEngine,
version: &str,
dry_run: bool,
progress: bool,
json_progress: bool,
) -> Result<(InstallPlan, Option<InstallReport>), CliError> {
let game = ctx.game();
let meta_cache = ctx.layout().meta_cache();
let now = now_ms();
block_on(async {
let source = RemoteSource::new(engine, &game, &meta_cache, now);
let resolved = source.resolve(version).await?;
let fetched = match resolved
.asset_index
.as_ref()
.filter(|index| index.url.is_some())
{
Some(reference) => {
let (index, bytes) = source.asset_index(reference).await?;
Some((index, game.asset_index(&reference.id), bytes))
}
None => None,
};
let mut plan = InstallPlan::build(PlanInput {
layout: &game,
version_id: version,
resolved: &resolved,
asset_index: fetched.as_ref().map(|(index, _, _)| index),
environment: &Environment::host(),
})?;
if let Some((_, dest, bytes)) = fetched {
plan.prefetch(dest, bytes);
}
if dry_run {
return Ok::<Attempt, InstallError>(Attempt { plan, report: None });
}
// 计划里没有客户端 jar(旧版清单缺 downloads.client)→ 拒绝"装出个空壳"。
if !plan.is_installable() {
let reasons = if plan.warnings.is_empty() {
"清单缺少客户端 jar 的下载信息".to_string()
} else {
plan.warnings.join(";")
};
return Err(InstallError::new(
InstallErrorKind::Metadata,
format!("版本 {version} 装不全:{reasons}"),
)
.with_hint(
"旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `%prog% launch`",
));
}
let sink = progress::progress_sink(progress, json_progress, plan.file_count());
let report = execute(engine, &plan, CONCURRENCY, sink).await?;
Ok(Attempt {
plan,
report: Some(report),
})
})
.map(|attempt| (attempt.plan, attempt.report))
.map_err(|error: InstallError| map_install_error(&error, ctx))
}
/// 一次安装尝试的产物(`--dry-run` 只有计划)。
struct Attempt {
plan: InstallPlan,
report: Option<InstallReport>,
}
+269
View File
@@ -0,0 +1,269 @@
//! `instances launch-args <instance>`(M157):实例级启动参数的读/写。
//!
//! 五项的**含义与回落规则**都在 core([`unlucky_core::game::launch_overrides`]),本命令只做
//! 三件事:找实例、把命令行选项拼成 [`LaunchOverridesPatch`]、把生效值印出来。
//!
//! ```text
//! ull instances launch-args 生存 # 读:实例层 + 生效值 + 后端门槛
//! ull instances launch-args 生存 --jvm "-XX:+UseG1GC" --backend vulkan
//! ull instances launch-args 生存 --memory 8192 --game "--fullscreen"
//! ull instances launch-args 生存 --clear # 五项全部回「跟随全局」
//! ```
//!
//! 判定「版本够不够 Vulkan」用 core 的组合判据
//! [`unlucky_core::launch::overrides::backend_allowed`](继承解析后的库表 + 版本门槛),
//! 与 GUI 灰化、拼装链的告警是同一个函数——CLI 不另写一条。
//!
//! 全局层(`java.extra_args` / `java.default_memory_mb` / `java.selected_path`)在这里读一次
//! 设置文档填 [`GlobalLaunchDefaults`];presenter 的 host 侧有一份同形的读法(两个 crate 各自
//! 依赖 core,没法共享那三行 IO),字段口径以 core 的回落表为准。
use serde_json::json;
use unlucky_core::game::install::instance::{
read_instance_config, write_instance_config, InstanceConfig,
};
use unlucky_core::game::launch_overrides::{
resolve, GlobalLaunchDefaults, GraphicsBackend, LaunchOverridesPatch, ResolvedLaunch,
};
use unlucky_core::game::{self, VersionJson};
use unlucky_core::launch::game_args::GRAPHICS_BACKEND_MIN_VERSION;
use unlucky_core::launch::game_version::GameVersion;
use unlucky_core::launch::overrides::backend_allowed;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::InstanceLaunchArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
use super::instances::read_profiles;
/// `ull instances launch-args`。
///
/// # Errors
///
/// 实例不存在、值非法(内存越界 / 未知后端)、实例文件读写失败时返回用户错误。
pub fn run(ctx: &Ctx, args: &InstanceLaunchArgs) -> Result<Report, CliError> {
let profiles = read_profiles(ctx)?;
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance)
.cloned()
.ok_or_else(|| {
CliError::user("not_found", format!("找不到实例 `{}`", args.instance))
.with_hint("用 `%prog% instances list` 看已登记的实例")
})?;
let id = profile.id.as_str().to_string();
let layout = ctx.layout();
let mut config = read_instance_config(&layout, &id)
.map_err(|error| CliError::user("instance_config", format!("读实例设置失败:{error}")))?
.unwrap_or_else(|| InstanceConfig::new(&id, &profile.version_id));
let mut updated = false;
if !args.is_read_only() {
config.launch = config
.launch
.patched(&patch_from(args))
.map_err(CliError::usage)?;
write_instance_config(&layout, &config).map_err(|error| {
CliError::user("instance_config", format!("写实例设置失败:{error}"))
})?;
updated = true;
}
let effective = resolve(Some(&config.launch), &global_defaults(ctx));
let (allowed, mc_version) = backend_gate(ctx, &profile.version_id);
let layer = &config.launch;
let data = json!({
"instance_id": id,
"name": profile.name,
"version_id": profile.version_id,
"updated": updated,
"launch": {
"jvm_args": layer.jvm_args.clone().unwrap_or_default(),
"game_args": layer.game_args.clone().unwrap_or_default(),
"memory_mb": layer.memory_mb,
"java_path": layer.java_path.as_ref().map(|path| path.display().to_string()),
"graphics_backend": layer.graphics_backend.unwrap_or_default().as_str(),
},
"effective": {
"jvm_args": effective.instance_jvm_args.clone().unwrap_or_default(),
"global_jvm_args": effective.global_jvm_args.clone().unwrap_or_default(),
"game_args": effective.game_args.clone().unwrap_or_default(),
"memory_mb": effective.memory_mb,
"java_path": effective.java_path.as_ref().map(|path| path.display().to_string()),
"graphics_backend": effective.graphics_backend.as_str(),
"graphics_backend_allowed": allowed,
"graphics_backend_min_version": GRAPHICS_BACKEND_MIN_VERSION,
"mc_version": mc_version,
},
"graphics_backend_options": GraphicsBackend::IDS,
});
let mut human = vec![format!(
"{}({} · {}){}",
profile.name,
profile.version_id,
id,
if updated { " ← 已保存" } else { "" }
)];
human.push(format!(
" JVM 参数(实例): {}",
layer
.jvm_args
.clone()
.unwrap_or_else(|| "(跟随全局)".to_string())
));
human.push(format!(
" 游戏参数(实例): {}",
layer
.game_args
.clone()
.unwrap_or_else(|| "(无)".to_string())
));
human.push(format!(
" 最大内存: {} MiB{}",
effective.memory_mb,
match layer.memory_mb {
Some(_) => "(实例覆盖)",
None => "(跟随全局 / 出厂默认)",
}
));
human.push(format!(
" Java: {}",
effective
.java_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "(自动挑:托管 > JAVA_HOME > PATH)".to_string())
));
human.push(format!(
" 图形后端: {}{}",
effective.graphics_backend.as_str(),
if effective.graphics_backend.is_default() {
"(不下发 --graphicsBackend)".to_string()
} else if allowed {
format!("({} 可下发)", mc_version)
} else {
format!("(版本 {mc_version} 不支持,不会下发;需 MC {GRAPHICS_BACKEND_MIN_VERSION}+)")
}
));
Ok(Report::new(data, human))
}
/// 命令行选项 → core 的文本补丁(`--clear` 先清五项,随后给出的项覆盖)。
fn patch_from(args: &InstanceLaunchArgs) -> LaunchOverridesPatch {
let mut patch = if args.clear {
LaunchOverridesPatch::clear_all()
} else {
LaunchOverridesPatch::default()
};
if let Some(value) = &args.jvm_args {
patch.jvm_args = Some(value.clone());
}
if let Some(value) = &args.game_args {
patch.game_args = Some(value.clone());
}
if let Some(value) = &args.memory_mb {
patch.memory_mb = Some(value.clone());
}
if let Some(value) = &args.java {
patch.java_path = Some(value.clone());
}
if let Some(value) = &args.backend {
patch.graphics_backend = Some(value.clone());
}
patch
}
/// 全局层(三个设置键)。
pub(crate) fn global_defaults(ctx: &Ctx) -> GlobalLaunchDefaults {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
let text = |key: &str| -> Option<String> {
store
.doc()
.effective_str(key)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
GlobalLaunchDefaults {
jvm_args: text("java.extra_args"),
game_args: None,
memory_mb: store
.effective("java.default_memory_mb")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok()),
java_path: text("java.selected_path").map(std::path::PathBuf::from),
graphics_backend: None,
}
}
/// 三级回落后的生效值(启动装配用;`instance_id` 为空 = 没有实例档案,只用全局层)。
///
/// 与 presenter 的 `host::launch_args::resolved` 同义:那边给 GUI 用,这边给 `ull launch` 用,
/// 规则都在 core 的 [`resolve`],两处只剩「读哪几份文件」的差别。
pub(crate) fn resolved(ctx: &Ctx, instance_id: &str) -> ResolvedLaunch {
let overrides = if instance_id.is_empty() {
None
} else {
read_instance_config(&ctx.layout(), instance_id)
.ok()
.flatten()
.map(|config| config.launch)
};
resolve(overrides.as_ref(), &global_defaults(ctx))
}
/// 版本能不能用 `--graphicsBackend`,以及判定依据的游戏版本(继承链根)。
fn backend_gate(ctx: &Ctx, version_id: &str) -> (bool, String) {
let game_layout = ctx.game();
let Ok(text) = std::fs::read_to_string(game_layout.version_json(version_id)) else {
return (false, version_id.to_string());
};
let Ok(manifest) = VersionJson::from_json_str(&text) else {
return (false, version_id.to_string());
};
let mc_version = root_version_id(&game_layout, &manifest);
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(game_layout.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
let resolved = game::resolve(&source, &manifest).unwrap_or(manifest);
let allowed = backend_allowed(&GameVersion::parse(&mc_version), &resolved);
(allowed, mc_version)
}
/// 继承链根(原版层)的 id——能力位与版本门限都以它为准(loader 层的 id 带加载器后缀)。
///
/// `pub(crate)`:`snapshot::prepare` 的装配(`SnapshotInput.game_version`)也要这一份口径,
/// 门槛判定(本模块的 `backend_gate`)与拼装必须用**同一个**根版本,否则会出现
/// 「UI 说可用、命令行里没有这个参数」的静默偏差。
pub(crate) fn root_version_id(
layout: &unlucky_core::game::GameLayout,
manifest: &VersionJson,
) -> String {
let mut current = manifest.id.clone().unwrap_or_default();
let mut parent = manifest.inherits_from.clone().filter(|id| !id.is_empty());
for _ in 0..16 {
let Some(next) = parent else { break };
let Ok(text) = std::fs::read_to_string(layout.version_json(&next)) else {
return next;
};
let Ok(json) = VersionJson::from_json_str(&text) else {
return next;
};
current = json.id.clone().unwrap_or(next);
parent = json.inherits_from.clone().filter(|id| !id.is_empty());
}
current
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,212 @@
//! [`super`] 的单测:走真入口 [`crate::run`],断言用户/脚本拿到的那份输出。
//!
//! 口径与 `instances/tests.rs` 一致:临时 `--data-dir` + 真 `profiles.json`,只看 stdout 的
//! JSON 信封(`--json`)与退出码。回落规则的真值表在 core 的
//! `game::launch_overrides` / `launch::overrides` 测试里,这里只验 CLI 有没有如实搬运。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use unlucky_core::platform::layout::{LauncherLayout, LAUNCHER_HOME_DIR};
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-instance-launch-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn arg(&self) -> String {
self.0.display().to_string()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 写 `.ULL/config/profiles.json` 并返回第一个实例的 id。
fn write_profiles(data_root: &Path, name: &str, version_id: &str) -> String {
let dir = data_root.join(LAUNCHER_HOME_DIR).join("config");
std::fs::create_dir_all(&dir).expect("建配置目录");
let id = "11111111-2222-4333-8444-555555555555".to_string();
let document = json!({
"schema_version": 1,
"profiles": [{ "id": id, "name": name, "version_id": version_id }],
});
std::fs::write(
dir.join("profiles.json"),
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
)
.expect("写 profiles.json");
id
}
/// 写 `<data>/.minecraft/versions/<id>/<id>.json`(库表可带 vulkan)。
fn write_version(data_root: &Path, id: &str, vulkan: bool) {
let path = data_root
.join(".minecraft")
.join("versions")
.join(id)
.join(format!("{id}.json"));
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
let libs = if vulkan {
r#"[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]"#
} else {
"[]"
};
std::fs::write(&path, format!(r#"{{"id":"{id}","libraries":{libs}}}"#)).expect("写版本 JSON");
}
/// 跑一次 CLI,返回(退出码, stdout)。
fn run_cli(args: &[&str]) -> (i32, String) {
let owned: Vec<String> = args.iter().map(|arg| (*arg).to_string()).collect();
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
let code = crate::run(&owned, &mut out, &mut err);
(code, String::from_utf8_lossy(&out).into_owned())
}
/// 跑 `instances launch-args … --json`,返回 `data` 载荷。
fn launch_args(dir: &TempDir, rest: &[&str]) -> Value {
let root = dir.arg();
let mut args = vec!["instances", "launch-args", "--json", "--data-dir", &root];
args.extend_from_slice(rest);
let (code, stdout) = run_cli(&args);
assert_eq!(code, 0, "{stdout}");
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["ok"], true, "{stdout}");
value["data"].clone()
}
#[test]
fn read_reports_instance_layer_and_effective_values() {
let dir = TempDir::new("read");
let id = write_profiles(dir.path(), "生存", "1.20.4");
let data = launch_args(&dir, &[&id]);
assert_eq!(data["instance_id"], id.as_str());
assert_eq!(data["updated"], false);
assert_eq!(data["launch"]["graphics_backend"], "default");
// 没配内存 ⇒ 生效值是出厂默认 2048(三级回落的最后一级)。
assert_eq!(data["effective"]["memory_mb"], 2048);
assert_eq!(data["effective"]["graphics_backend_allowed"], false);
assert_eq!(
data["graphics_backend_options"],
json!(["default", "opengl", "vulkan"])
);
}
#[test]
fn write_from_flags_then_read_back() {
let dir = TempDir::new("write");
let id = write_profiles(dir.path(), "生存", "26.2-snapshot-2");
write_version(dir.path(), "26.2-snapshot-2", true);
let data = launch_args(
&dir,
&[
&id,
"--jvm=-XX:+UseG1GC",
// 值以 `--` 开头时必须用 `--key=value` 形式(`--game --fullscreen` 会被解析器
// 当成「缺少取值」——那是全项目 CLI 的既有口径,见 help 里的提示)。
"--game=--fullscreen",
"--memory",
"6144",
"--backend",
"vulkan",
],
);
assert_eq!(data["updated"], true);
assert_eq!(data["launch"]["jvm_args"], "-XX:+UseG1GC");
assert_eq!(data["launch"]["memory_mb"], 6144);
assert_eq!(data["launch"]["graphics_backend"], "vulkan");
// 26.2-snapshot-2 过了门槛(判定来自 core 的能力位 + 版本门限)。
assert_eq!(data["effective"]["graphics_backend_allowed"], true);
// 再读一次:落盘之后依然在(`--json` 是事实上的对外契约)。
let again = launch_args(&dir, &[&id]);
assert_eq!(again["updated"], false);
assert_eq!(again["launch"]["jvm_args"], "-XX:+UseG1GC");
assert_eq!(again["launch"]["game_args"], "--fullscreen");
}
#[test]
fn clear_restores_follow_the_global() {
let dir = TempDir::new("clear");
let id = write_profiles(dir.path(), "生存", "1.20.4");
launch_args(&dir, &[&id, "--memory", "4096", "--backend", "opengl"]);
let cleared = launch_args(&dir, &[&id, "--clear"]);
assert_eq!(cleared["launch"]["memory_mb"], Value::Null);
assert_eq!(cleared["launch"]["graphics_backend"], "default");
assert_eq!(cleared["effective"]["memory_mb"], 2048);
}
#[test]
fn invalid_values_fail_with_a_usage_error() {
let dir = TempDir::new("invalid");
let id = write_profiles(dir.path(), "生存", "1.20.4");
let root = dir.arg();
for (flag, value) in [
("--backend", "vulkan2"),
("--memory", "4g"),
("--memory", "1"),
] {
let (code, stdout) = run_cli(&[
"instances",
"launch-args",
"--json",
"--data-dir",
&root,
&id,
flag,
value,
]);
assert_ne!(code, 0, "{flag} {value} 应当失败:{stdout}");
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["error"]["code"], "usage", "{stdout}");
}
// 失败的写入不许留下半截设置文件。
let layout = LauncherLayout::new(dir.path().join(LAUNCHER_HOME_DIR));
assert!(
unlucky_core::game::install::instance::read_instance_config(&layout, &id)
.expect("读")
.is_none(),
"非法补丁不该落盘"
);
}
#[test]
fn unknown_instance_is_a_user_error() {
let dir = TempDir::new("missing");
let root = dir.arg();
let (code, stdout) = run_cli(&[
"instances",
"launch-args",
"--json",
"--data-dir",
&root,
"nope",
]);
assert_ne!(code, 0);
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["error"]["code"], "not_found", "{stdout}");
}
+182
View File
@@ -0,0 +1,182 @@
//! `instances list`:实例与已安装版本的总览。
//!
//! 安装/登记在 [`super::install`];这里只读(`profiles.json` + `versions/` 的存在性)。
//! 读 `profiles.json` 的实现委托给 `core::game::install::instance`,避免 CLI 自带一份
//! 解析逻辑(同一份文件两处解析迟早会漂移)。
//!
//! # `--json` 里的 `loader` 字段(M124)
//!
//! 每一行实例带一个 `loader` 对象:`{"kind": <稳定 id>, "version": <版本段>}`。它是
//! **`version_id` 的解析结果**,解析本身不在这里——[`unlucky_core::game::InstanceLoader::parse`]
//! 是全仓唯一的 id → 加载器入口(M122 收敛)。种类 → 稳定 id 的词表也不在本 crate 的代码里:
//! [`unlucky_core::game::LoaderKind::as_str`] 是生产代码里 id 的**源头表**(M126 收敛),这里
//! 直接调它,**连翻译层都没有**——词表与 id 匹配一样,两处各算一遍就会漂(M120 的「刚装的
//! Fabric 显示成原版」)。生产代码里唯一的逐字副本是 `cli::help` 里 `ull help instances` 的
//! 词表行(会打印给用户),由本模块的 `help_vocabulary_tests::help_lists_every_stable_id`
//! 按 `ALL` 遍历守着。
//!
//! ## 约定一:`loader` 恒存在,原版是 `{"kind":"vanilla","version":""}`
//!
//! 原版实例**不会**缺这个键,也**不会**是 `null`——脚本不必先判空,`kind` 恒是七个闭集值
//! 之一(`vanilla` / `fabric` / `quilt` / `forge` / `neoforge` / `optifine` / `liteloader`)。
//! 与 `version` 的关系:原版与「id 里没写加载器版本」的两家第三方(`1.12.2-LiteLoader`)
//! 拿到空串,不是缺失。
//!
//! ## 约定二:`kind` 是**识别**口径,不是**安装**口径
//!
//! 「认得出」是七个,「装得了」(`ull install --loader`)是六个——差的那一个是 `optifine`:
//! 它要跑外部 Patcher 并原地改 zip,报 `loader_unsupported`,只能由别的启动器(PCL / HMCL)
//! 装好后继承进来。`liteloader` 自 M222 起**可以**装
//! (`install --loader liteloader`,取值表见 `ull help install`),所以
//! `loader.kind == "liteloader"` 既可能是继承来的,也可能是 ull 自己装的。看到
//! `loader.kind == "optifine"` 的意思是「这个实例的加载器是 OptiFine」,
//! **不是**「ull 能给你装 OptiFine」。
//!
//! ## 约定三:`vanilla` 的含义是「id 里没有加载器痕迹」
//!
//! [`unlucky_core::game::InstanceLoader::parse`] 只看版本 id 的字符串。把加载器信息只写进
//! 版本 JSON、id 里一点痕迹都没有的实例(整包导入时被重命名成 `我的整合包` 那种)会**诚实地**
//! 返回 `vanilla`——这是「认不出」,不等于「一定没装加载器」。要权威结论得读 `libraries[]`,
//! 不在本命令的能力范围内。
//!
//! ## 向后兼容
//!
//! 这次只**加**一个键:`data.instances[]` 的其余七个键(`id` / `name` / `version_id` /
//! `version_present` / `game_dir` / `java_path` / `last_played_at`)一个没动,人类可读输出
//! 逐字不变(`loader` 只进 JSON)。`--json` 是事实上的对外契约,字段说明同步在
//! `ull help instances`([`crate::cli::help::topic`])。
use std::path::PathBuf;
use serde_json::{json, Value};
use unlucky_core::game::install::instance::read_profiles as read_profiles_from_layout;
use unlucky_core::game::{InstanceLoader, ProfileList};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// `ull instances list`。
///
/// # Errors
///
/// `profiles.json` 读不了或解析失败时返回用户错误。
pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
let profiles = read_profiles(ctx)?;
let game = ctx.game();
let installed = game.installed_versions().unwrap_or_default();
let mut rows: Vec<Value> = Vec::new();
let mut body: Vec<String> = Vec::new();
for profile in &profiles.profiles {
let present = game.is_version_installed(&profile.version_id);
body.push(format!(
" {} [{}] {} {}",
profile.name,
profile.version_id,
if present { "已安装" } else { "载荷缺失" },
profile.id.as_str()
));
rows.push(json!({
"id": profile.id.as_str(),
"name": profile.name,
"version_id": profile.version_id,
"version_present": present,
"game_dir": option_path(profile.game_dir.as_ref()),
"java_path": option_path(profile.java_path.as_ref()),
"last_played_at": profile.last_played_at,
"loader": loader_json(&profile.version_id),
}));
}
let data = json!({
"data_root": ctx.data.launcher_home.display().to_string(),
"game_root": ctx.data.game_home.display().to_string(),
"instances": rows,
"installed_versions": installed,
});
let mut human = vec![format!(
"实例 {} 个(数据根 {})",
profiles.profiles.len(),
ctx.data.launcher_home.display()
)];
if body.is_empty() {
human.push(" (空)用 `%prog% install <version>` 登记第一个实例".to_string());
}
human.extend(body);
if installed.is_empty() {
human.push("已安装版本: (无)".to_string());
} else {
human.push(format!("已安装版本: {}", installed.join(", ")));
}
Ok(Report::new(data, human))
}
/// 读实例清单(文件不存在 = 空列表,不算错误)。
///
/// # Errors
///
/// 读盘失败或 JSON 非法时返回用户错误。
pub fn read_profiles(ctx: &Ctx) -> Result<ProfileList, CliError> {
let path = ctx.profiles_config();
read_profiles_from_layout(&ctx.layout()).map_err(|error| {
CliError::user(
"profiles_parse",
format!("读实例清单 {} 失败:{error}", path.display()),
)
})
}
fn option_path(path: Option<&PathBuf>) -> Option<String> {
path.map(|path| path.display().to_string())
}
/// 版本 id → `loader` 对象(`{"kind": …, "version": …}`)。
///
/// 解析走 [`InstanceLoader::parse`](全仓唯一入口);种类 → 稳定 id 走
/// [`unlucky_core::game::LoaderKind::as_str`](词表的源头,M126)。这里既**不写 id
/// 匹配**,也**不写词表**:词表的源头只在 core(副本见模块文档)。原版同样给出对象
/// (`kind = vanilla`、`version = ""`),不缺键、不为 `null`——见模块文档的「约定一」。
fn loader_json(version_id: &str) -> Value {
let parsed = InstanceLoader::parse(version_id);
json!({
"kind": parsed.kind.as_str(),
"version": parsed.version,
})
}
pub mod adopt;
mod adopt_shared;
pub mod scan;
#[cfg(test)]
mod tests;
/// `ull help instances` 的词表行是**对外文档契约**,不许成为第二个说假话的地方(r2)。
///
/// 单列在这里而不是塞进 `instances/tests.rs`:那几个断言钉的是本模块文档承诺的词表,与命令
/// 行为无关(`tests.rs` 的职责是「跑真入口看输出」),而且这样 help 的契约与被承诺的那段
/// 文档就在同一个文件里。
#[cfg(test)]
mod help_vocabulary_tests {
use unlucky_core::game::LoaderKind;
/// help 的词表必须覆盖**每一支**稳定 id(含原版与两家只继承、不安装的)。
///
/// 抓得住:help 的词表行被改字或删项(`neoforge` → `neo_forge`);**新增一家加载器而忘了
/// 更新 help**——下面按 [`LoaderKind::ALL`] 遍历而不是手抄七项,所以新分支漏写必红。
/// 抓不住:措辞与排版(只做子串断言);「kind 是识别口径不是安装口径」那段语义说明是否
/// 仍然准确,只能靠人读。
#[test]
fn help_lists_every_stable_id() {
let topic = crate::cli::help::topic("instances").expect("help instances 存在");
for kind in LoaderKind::ALL {
assert!(
topic.contains(kind.as_str()),
"help instances 的词表行漏了 `{}`:\n{topic}",
kind.as_str()
);
}
}
}
+118
View File
@@ -0,0 +1,118 @@
//! `ull instances adopt`:把盘上已有的版本登记成实例(M221)。
//!
//! **只读外部、只写自己**:写盘面只有 `<数据根>/.ULL/config/` 下的两份配置(+ 选中缺省运行目录时
//! 建那个空目录),`versions/` `libraries/` `assets/` 一个字节都不写。判据、落点与话术全在
//! [`unlucky_core::game::adopt`];本模块只做「参数 → core 调用 → 人读/JSON 两条输出」。
//!
//! `--dry-run` 走 `adopt::plan`(零写盘),所以「先看一眼」不会改任何东西,也不会建目录。
use serde_json::{json, Value};
use unlucky_core::game::adopt::{self, AdoptRequest};
use unlucky_core::game::GameLayout;
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::platform::layout::LauncherLayout;
use crate::cli::model::InstancesAdoptArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
use super::adopt_shared::{candidate_json, map_error};
/// `ull instances adopt <version-id> [--name N] [--isolation …] [--game-dir DIR] [--dry-run]`。
///
/// # Errors
///
/// 版本不在盘上 / 候选不合格 / 名字冲突 / 运行目录不可用 / 读配置失败,全部由
/// [`unlucky_core::game::adopt::AdoptError`] 映射成稳定错误码(`adopt_*`)。
pub fn run(ctx: &Ctx, args: &InstancesAdoptArgs) -> Result<Report, CliError> {
let request = AdoptRequest {
version_id: args.version_id.clone(),
name: args.name.clone(),
target: args.target.clone(),
created_at: Some(format_rfc3339_millis(now())),
};
let (game, launcher) = (ctx.game(), ctx.layout());
if args.dry_run {
return dry_run(&game, &launcher, &request);
}
let outcome = adopt::adopt(&game, &launcher, &request).map_err(map_error)?;
// 人读行先拼(只借用),再把字段移进 JSON(两条出口的内容同源、顺序不影响事实)。
let mut human = vec![format!(
"已登记实例 `{}`(id {})→ 版本 {}",
outcome.name, outcome.instance_id, outcome.version_id
)];
human.push(format!(
" 隔离档 {}(运行目录来源 {}),运行目录 {}",
outcome.isolation.as_str(),
outcome.target.as_str(),
outcome.run_dir.display()
));
human.push(format!(" 实例设置 {}", outcome.config_path.display()));
if outcome.dir_created {
human.push(" 已新建该运行目录(空目录;玩家数据由游戏自己往里写)".to_string());
}
human.push(
" 只写了本启动器的配置:versions/ libraries/ assets/ 一个字节都没动(不搬文件)"
.to_string(),
);
human.extend(outcome.notes.iter().map(|note| format!(" 提示:{note}")));
let version_id = outcome.version_id.clone();
let data = json!({
"dry_run": false,
"version_id": version_id,
"instance": json!({
"id": outcome.instance_id,
"name": outcome.name,
"version_id": version_id,
"isolation": outcome.isolation.as_str(),
"run_dir": outcome.run_dir.display().to_string(),
"run_dir_source": outcome.target.as_str(),
"config_path": outcome.config_path.display().to_string(),
"created": outcome.created,
"dir_created": outcome.dir_created,
}),
"notes": outcome.notes,
});
Ok(Report::new(data, human))
}
/// `--dry-run`:只算落点与判据,不写盘(连缺省运行目录都不建)。
fn dry_run(
game: &GameLayout,
launcher: &LauncherLayout,
request: &AdoptRequest,
) -> Result<Report, CliError> {
let plan = adopt::plan(game, launcher, request).map_err(map_error)?;
let mut human = vec![format!(
"检查通过:版本 {} 可登记(--dry-run:没有写任何文件)",
plan.candidate.version_id
)];
human.push(format!(
" 实例名 {},隔离档 {}(运行目录来源 {})",
plan.name,
plan.isolation.as_str(),
plan.target.as_str()
));
human.push(format!(" 运行目录 {}", plan.run_dir.display()));
human.extend(plan.notes.iter().map(|note| format!(" 提示:{note}")));
let version_id = plan.candidate.version_id.clone();
let data = json!({
"dry_run": true,
"version_id": version_id,
"candidate": candidate_json(&plan.candidate),
"instance": json!({
"id": Value::Null,
"name": plan.name,
"version_id": version_id,
"isolation": plan.isolation.as_str(),
"run_dir": plan.run_dir.display().to_string(),
"run_dir_source": plan.target.as_str(),
"config_path": Value::Null,
"created": false,
"dir_created": false,
}),
"notes": plan.notes,
});
Ok(Report::new(data, human))
}
@@ -0,0 +1,84 @@
//! `instances scan` / `instances adopt` 共用的 JSON 与错误投影。
//!
//! 两条命令输出同一种候选形状(`candidate` 对象),错误也走同一份映射——分两份写迟早漂。
use serde_json::{json, Value};
use unlucky_core::game::adopt::{AdoptCandidate, AdoptError, AdoptProblem};
use crate::error::CliError;
/// 候选 → `--json` 里的 `candidate` 对象。
pub(super) fn candidate_json(candidate: &AdoptCandidate) -> Value {
json!({
"version_id": candidate.version_id,
"version_dir": candidate.version_dir.display().to_string(),
"manifest_path": candidate.manifest_path.display().to_string(),
"jar_present": candidate.jar_present,
"manifest_id": candidate.manifest_id,
"inherits": candidate.inherits,
"registered": candidate.registered.iter().map(|item| json!({
"id": item.id,
"name": item.name,
})).collect::<Vec<Value>>(),
"adoptable": candidate.adoptable(),
"problems": problems_json(&candidate.problems),
})
}
/// 问题清单 → 结构化数组(`code` 稳定、`message` 人读)。
pub(super) fn problems_json(problems: &[AdoptProblem]) -> Value {
Value::Array(
problems
.iter()
.map(|problem| {
json!({
"code": problem.code(),
"message": problem.to_string(),
})
})
.collect(),
)
}
/// `AdoptError` → CLI 错误:稳定 code + 逐条问题进 `detail`(脚本不必解析散文)。
pub(super) fn map_error(error: AdoptError) -> CliError {
let code = error.code();
let message = error.to_string();
let cli = CliError::user(code, message);
let hint = match &error {
AdoptError::DuplicateName { .. } => {
Some("换个展示名:%prog% instances adopt <version-id> --name <新名字>")
}
AdoptError::NotAdoptable { .. } => Some(
"这些版本要么清单坏了、要么缺父版本、要么越界:先在原来的启动器里把它修好,\
或 `%prog% install <version>` 让本启动器自己装一份",
),
AdoptError::AlreadyRegistered { .. } => {
Some("用 `%prog% instances list` 看已有实例;要第二个实例请换 --name")
}
AdoptError::TargetNotEmpty { .. } => {
Some("换一个名字(缺省目录按名字派生),或用 --isolation custom --game-dir <空目录>")
}
AdoptError::UnknownVersion { .. } => Some("先看有哪些可用:%prog% instances scan"),
AdoptError::NoFreeRunDir { .. } => Some(
"换一个 --name(缺省目录按名字派生),或用 --isolation custom --game-dir <空目录>",
),
AdoptError::WriteSurfaceInGameTree { .. } => Some(
"数据目录(.ULL / --data-dir)和游戏树重叠了:把它指到游戏树之外的目录再登记 (整体迁移到别处是允许的,默认登记不会把实例数据写进别人的 .minecraft)",
),
AdoptError::ConfigSurfaceUnsafe { .. } => Some(
"数据根里的 config/ 或 config/instances/ 被链到别处了:先把它恢复成真实目录再登记 (整体迁移请把整个 .ULL 目录一起搬走,别只链其中一层)",
),
AdoptError::VersionsDirUnreadable { .. } => Some(
"游戏根/versions 入口存在但读不出来:检查它是不是断链的符号链接/目录联接, 或把它指回真实目录后再扫",
),
_ => None,
};
let cli = match hint {
Some(hint) => cli.with_hint(hint),
None => cli,
};
cli.with_detail(json!({
"problems": problems_json(error.problems()),
}))
}
+91
View File
@@ -0,0 +1,91 @@
//! `ull instances scan`:列出当前游戏根 `versions/` 里**已经铺好**的版本,逐项说清能不能登记(M221)。
//!
//! 只读:不写任何文件、不改任何 mtime。用途是「我盘上已经有别的启动器装好的版本,看看 ULL 认不认」,
//! 以及 `instances adopt` 的前置检查(同一套判据,见 [`unlucky_core::game::adopt::scan`])。
use serde_json::json;
use unlucky_core::game::adopt;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
use super::adopt_shared::candidate_json;
/// `ull instances scan`。
///
/// # Errors
///
/// `versions/` 或 `profiles.json` 读不了时返回用户错误(**不降级成空列表**)。
pub fn run(ctx: &Ctx) -> Result<Report, CliError> {
let scan = adopt::scan(&ctx.game(), &ctx.layout()).map_err(super::adopt_shared::map_error)?;
let ready = scan
.candidates
.iter()
.filter(|candidate| candidate.adoptable())
.count();
let registered = scan
.candidates
.iter()
.filter(|candidate| candidate.is_registered())
.count();
let candidates: Vec<serde_json::Value> = scan.candidates.iter().map(candidate_json).collect();
let data = json!({
"game_root": ctx.data.game_home.display().to_string(),
"versions_dir": scan.versions_dir.display().to_string(),
"counts": {
"total": scan.candidates.len(),
"adoptable": ready,
"registered": registered,
},
"candidates": candidates,
});
let mut human = vec![format!(
"游戏根 {}:versions/ 里 {} 个版本,{} 个可登记",
scan.versions_dir.display(),
scan.candidates.len(),
ready
)];
if scan.candidates.is_empty() {
human.push(
" (空)这个游戏根下还没有任何版本;`%prog% install <version>` 可以自己装一份"
.to_string(),
);
}
for candidate in &scan.candidates {
if candidate.adoptable() {
let state = if candidate.is_registered() {
"已登记"
} else {
"可登记"
};
human.push(format!(
" ✓ {} [{state}]{}",
candidate.version_id,
chain_suffix(candidate)
));
} else {
human.push(format!(" ✗ {}", candidate.version_id));
for problem in &candidate.problems {
human.push(format!(" 原因[{0}] {problem}", problem.code()));
}
}
}
if ready > 0 {
human.push(
"登记一个:%prog% instances adopt <version-id>(缺省落 ULL 数据区的独立运行目录)"
.to_string(),
);
}
Ok(Report::new(data, human))
}
/// 人读行尾的父链提示(`1.12.2-forge-…` 后面标出它继承的是谁)。
fn chain_suffix(candidate: &unlucky_core::game::AdoptCandidate) -> String {
match candidate.inherits.len() {
0 | 1 => String::new(),
_ => format!(" 继承 {}", candidate.inherits[1..].join(" -> ")),
}
}
+251
View File
@@ -0,0 +1,251 @@
//! [`super`] 的单测:`instances list --json` 的 `loader` 字段 + 人类可读输出不变。
//!
//! 走的是真入口 [`crate::run`](`--data-dir` 钉在临时目录里),断言的是**用户/脚本拿到的那份
//! 输出**,不是内部函数返回值——这样 JSON 信封(`ok` / `data` / `warnings`)与人类行都在射程内。
//! 解析端本身(id → 加载器)的真值表在 `core::game::loader` 的测试里,这里只验「CLI 有没有把它
//! 如实搬到输出面上」,不重复抄一份 id 真值。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::Value;
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-instances-test-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn arg(&self) -> String {
self.0.display().to_string()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 写 `.ULL/config/profiles.json`(实例名 → 版本 id;id 用 UUID 形状,互不重复)。
fn write_profiles(data_root: &Path, profiles: &[(&str, &str)]) {
let dir = data_root.join(".ULL").join("config");
std::fs::create_dir_all(&dir).expect("建配置目录");
let entries: Vec<Value> = profiles
.iter()
.enumerate()
.map(|(index, (name, version))| {
serde_json::json!({
"id": format!("11111111-2222-4333-8444-{:012}", index + 1),
"name": name,
"version_id": version,
})
})
.collect();
let document = serde_json::json!({ "schema_version": 1, "profiles": entries });
std::fs::write(
dir.join("profiles.json"),
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
)
.expect("写 profiles.json");
}
/// 跑一次 CLI,返回(退出码, stdout, stderr)。
fn run_cli(args: &[&str]) -> (i32, String, String) {
let owned: Vec<String> = args.iter().map(|arg| (*arg).to_string()).collect();
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
let code = crate::run(&owned, &mut out, &mut err);
(
code,
String::from_utf8_lossy(&out).into_owned(),
String::from_utf8_lossy(&err).into_owned(),
)
}
/// 跑 `instances list --json` 并返回 `data.instances` 数组。
fn list_instances(dir: &TempDir) -> Vec<Value> {
let (code, stdout, stderr) =
run_cli(&["instances", "list", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "stderr: {stderr}");
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
assert_eq!(value["ok"], true, "{stdout}");
value["data"]["instances"]
.as_array()
.expect("instances 数组")
.clone()
}
/// 四家自家 id + 原版 + 两家第三方前缀:JSON 里的 `loader.kind` / `loader.version`。
///
/// 样本与 `core::game::loader` 的真值表**同源**(自家样本取自 `LoaderKind::version_id` 的
/// `<mc>-<kind>-<loader>` 口径,第三方样本取自 PCL 的真实 id 形状),但断言只钉在输出面上。
///
/// 抓得住:CLI 没调 `InstanceLoader::parse`(自己另算一遍或干脆不派生);种类 → 稳定 id 的
/// 词表被改(`neoforge` 写成 `neo_forge`);版本段被截断/连标记一起搬;`optifine` /
/// `liteloader` 被漏成 `vanilla`(这两家的 id 是第三方前缀形状,没有 `<mc>-<kind>-<ver>` 段,
/// 只按自家四种形状解析的实现在这两行会红)。
/// 抓不住:解析规则本身退化(那是 core 的单测射程);`version_id` 大小写/空格的边角;
/// 真实实例里 `libraries[]` 与 id 不一致的情形(本命令只看 id)。
#[test]
fn json_rows_expose_loader_kind_and_version() {
let dir = TempDir::new("loader");
let cases: [(&str, &str, &str); 7] = [
("Fabric 实例", "1.20.4-fabric-0.16.5", "fabric"),
("Quilt 实例", "1.20.4-quilt-0.27.0", "quilt"),
("Forge 实例", "1.20.1-forge-47.2.0", "forge"),
("NeoForge 实例", "1.21.1-neoforge-21.1.80", "neoforge"),
("原版实例", "1.20.1", "vanilla"),
("OptiFine 实例", "1.13.2-OptiFine_HD_U_E6", "optifine"),
("LiteLoader 实例", "1.12.2-LiteLoader", "liteloader"),
];
let expected_versions = ["0.16.5", "0.27.0", "47.2.0", "21.1.80", "", "HD_U_E6", ""];
let profiles: Vec<(&str, &str)> = cases
.iter()
.map(|(name, version_id, _)| (*name, *version_id))
.collect();
write_profiles(dir.path(), &profiles);
let rows = list_instances(&dir);
assert_eq!(rows.len(), cases.len(), "{rows:?}");
for (index, (name, version_id, kind)) in cases.iter().enumerate() {
let row = &rows[index];
assert_eq!(row["name"], *name, "{row}");
assert_eq!(row["version_id"], *version_id, "{row}");
assert_eq!(
row["loader"]["kind"], *kind,
"`{version_id}` 的 loader.kind 应是 `{kind}`:{row}"
);
assert_eq!(
row["loader"]["version"], expected_versions[index],
"`{version_id}` 的 loader.version:{row}"
);
}
}
/// 反向锚:原版的 `loader` 是 `{"kind":"vanilla","version":""}`,**不缺键、不为 `null`**。
///
/// 这条钉住的是模块文档里的「约定一」:脚本可以无条件读 `row.loader.kind`。顺带把「原版 id
/// 不许被认成加载器」也验一遍——反面是 core 那边 `plain_version_ids_stay_vanilla` 的职责,
/// 这里验的是它有没有被 CLI 的翻译层改坏(例如把空版本当成「没解析出东西」而塞 `null`)。
///
/// 抓得住:有人把原版改回「省略 `loader` 键」或「给 `null`」(两种都会让 `row["loader"]["kind"]`
/// 变成 `null`);`loader` 对象里被塞进多余的键(下面的键集合断言)。
/// 抓不住:`vanilla` 与「id 里没痕迹的整合包」的区分——**用户视角两者本来就不可区分**,
/// 见模块文档「约定三」,只能靠读 `libraries[]`。
#[test]
fn vanilla_rows_keep_a_loader_object_never_null() {
let dir = TempDir::new("vanilla");
write_profiles(
dir.path(),
&[
("原版", "1.20.1"),
("快照", "23w45a"),
("预发布", "1.20.4-pre1"),
],
);
let rows = list_instances(&dir);
assert_eq!(rows.len(), 3, "{rows:?}");
for row in &rows {
let loader = row.get("loader").expect("原版也必须带 loader 键");
assert!(!loader.is_null(), "loader 不许是 null:{row}");
assert_eq!(loader["kind"], "vanilla", "{row}");
assert_eq!(loader["version"], "", "{row}");
let mut keys: Vec<&str> = loader
.as_object()
.expect("loader 是对象")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(keys, ["kind", "version"], "loader 只有这两个键:{row}");
}
}
/// 向后兼容:人类可读输出逐字不变,`--json` 的旧字段一个没少。
///
/// 抓得住:把 `loader` 顺手塞进人类行(`instances list` 的人读格式被改);旧七个 JSON 键
/// 被删或改名(`last_played_at` 这类「看着没用」的最容易被顺手清掉)。
/// 抓不住:人类行的**空白/顺序**之外的美化(例如多打一行汇总)——下面按整段逐行比对,
/// 所以大部分改动会红,但只在两行之间加空行这类不涉及内容的变化仍可能被容忍(本测试用的是
/// 定长片段列表,多出来的行一定红;只有同位置改写才需要人看)。
#[test]
fn human_output_and_old_json_fields_are_unchanged() {
let dir = TempDir::new("compat");
write_profiles(dir.path(), &[("生存服", "1.13.2-OptiFine_HD_U_E6")]);
let (code, stdout, stderr) = run_cli(&["instances", "list", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "stderr: {stderr}");
let launcher_home = dir.path().join(".ULL");
let expected = format!(
"实例 1 个(数据根 {})\n \
生存服 [1.13.2-OptiFine_HD_U_E6] 载荷缺失 \
11111111-2222-4333-8444-000000000001\n\
已安装版本: (无)\n",
launcher_home.display()
);
assert_eq!(stdout, expected, "人类可读输出应逐字不变");
assert!(
!stdout.contains("loader") && !stdout.contains("OptiFine 版本"),
"人读输出里不该出现加载器派生列:{stdout}"
);
let rows = list_instances(&dir);
let row = &rows[0];
for key in [
"id",
"name",
"version_id",
"version_present",
"game_dir",
"java_path",
"last_played_at",
] {
assert!(row.get(key).is_some(), "旧键 `{key}` 不许删:{row}");
}
// 顺手钉住新键的值,避免「旧键都在、新键是空对象」这种假绿。
assert_eq!(row["loader"]["kind"], "optifine", "{row}");
}
/// `--json` 是事实上的对外契约:字段约定必须能在 `ull help instances` 里读到。
///
/// 抓得住:`loader` 字段被加进输出、契约说明被从 help 里删掉(只留在源码注释里等于没写——
/// 用户读不到);「识别口径 ≠ 安装口径」这条警告被顺手删掉(`optifine` 装不了、`liteloader`
/// 自 M222 起能装,两件事都只在 help 里说,JSON 契约里看不出来)。
/// 抓不住:措辞本身——下面只按键词(`loader` / `kind` / `version` / `vanilla` / `optifine` /
/// `install --loader`)检查,换个说法仍会绿;help 的排版正确性也不在射程内。
#[test]
fn help_documents_the_loader_field_contract() {
let topic = crate::cli::help::topic("instances").expect("help instances");
for needle in [
"loader",
"kind",
"version",
"vanilla",
"optifine",
"install --loader",
] {
assert!(
topic.contains(needle),
"help instances 缺 `{needle}`:\n{topic}"
);
}
}
+346
View File
@@ -0,0 +1,346 @@
//! Java 运行时:`ull java list` / `install` / `vendors`(D13 v1 + D23 v2)。
//!
//! 四件事:建下载引擎、把 core 的结果排成人读与 `--json` 两种形态、把错误翻成稳定码、
//! 从设置里取 JDK 的落点与发行版优先级。探测/下载/解压/映射/回退链全在 `core::game::java`。
//!
//! D23 之后的三个用户可见变化:
//!
//! 1. **落点可选**:`java.installRoot` 设了就装到那里;否则 Standard 数据模式装到
//! `%APPDATA%\Java\<发行方>\<版本>\`(mac/Linux 见 `game::java::location`),
//! Portable 仍在数据根 `java-managed/`(v1 布局不变);
//! 2. **发行版可选**:`--vendor temurin|oracle|microsoft|zulu` 钉死一家;不给就按
//! `java.vendorPriority` → 地区默认序跑回退链,降级原因进 `--json` 的 `chain` 块;
//! 3. **`java vendors`** 一眼看清四家的可用性、镜像与当前顺序。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::{json, Value};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::{DownloadEngine, RegionProfile};
use unlucky_core::game::java::{
self, location, vendor, InstallRequest, JavaOrigin, JavaRuntime, JdkVendor, LocationMode,
DEFAULT_MAJOR,
};
use unlucky_core::launch::request::JavaSelection;
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
pub use super::java_launch::{resolve_for_launch, LaunchJava};
pub use super::java_vendors::vendors;
/// 可自动安装的最老大版本(再老的 Temurin 没有 GA 产物)。
const MIN_INSTALLABLE_MAJOR: u32 = 8;
/// 装配用的 Java 选择(`--java` > `JAVA_HOME` > PATH 上的 `java`)。
///
/// `snapshot::prepare` 调它拿 [`JavaSelection`];真正的来源优先级在
/// [`resolve_for_launch`] 里求值(那一步还会看托管目录与版本要求)。
#[must_use]
pub fn selection(explicit: Option<&Path>) -> JavaSelection {
let (binary, origin) = match explicit {
Some(path) => (path.to_path_buf(), JavaOrigin::Instance),
None => match env_java() {
Some(binary) => (binary, JavaOrigin::JavaHome),
None => (PathBuf::from(java::java_binary_name()), JavaOrigin::Path),
},
};
let runtime = java::probe(&binary, origin);
JavaSelection::new(binary, runtime.major.unwrap_or(DEFAULT_MAJOR), runtime.bits)
}
/// 托管 JDK 可能存在的全部安装根(Portable 数据根 + Standard/Custom 落点,D23 后不止一个)。
#[must_use]
pub fn managed_roots(ctx: &Ctx) -> Vec<PathBuf> {
let settings = JavaSettings::load(ctx);
location::scan_roots(
ctx.data.mode,
&ctx.layout(),
settings.install_root.as_deref(),
ctx.platform,
&ProcessEnv,
)
}
/// `ull java list`:探测托管目录 / `JAVA_HOME` / `PATH` / 实例覆盖,并显示各 JDK 的落点。
///
/// # Errors
///
/// 实例清单读不了时返回用户错误(其余探测失败只体现为缺项)。
pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
let settings = JavaSettings::load(ctx);
let roots = managed_roots(ctx);
// 托管 → JAVA_HOME → PATH → 标准安装位置(Program Files 系 / 官方启动器 runtime / 注册表),
// 同一路径只列一次且保留优先级高者(`discover::dedupe` 负责)。
let mut candidates = java::candidates_in_roots(&roots);
let profiles = super::instances::read_profiles(ctx)?;
for profile in &profiles.profiles {
if let Some(java_path) = &profile.java_path {
candidates.push(java::probe(java_path, JavaOrigin::Instance));
}
}
let candidates = java::discover::dedupe(candidates);
let candidates: Vec<JavaRuntime> = candidates
.into_iter()
.filter(|candidate| candidate.major.is_some())
.collect();
let mode = settings.location_mode(ctx);
let installed_root = settings.location(ctx).map(|root| root.root).ok();
let data = json!({
// 兼容 v1 的 `managed_root`(首个扫描根),同时给出多根清单(D23 之后不止一个根)。
"managed_root": roots.first().map(|root| root.display().to_string()),
"managed_roots": roots.iter().map(|root| root.display().to_string()).collect::<Vec<_>>(),
"install": {
"mode": mode.as_str(),
"root": installed_root.as_ref().map(|root| root.display().to_string()),
"override": settings.install_root.as_ref().map(|root| root.display().to_string()),
},
"candidates": candidates.iter().map(candidate_json).collect::<Vec<_>>(),
});
let mut human = vec![format!("Java {} 个", candidates.len())];
if candidates.is_empty() {
human.push(
"(没找到可用 Java)用 `%prog% java install 21` 装一个,或设 JAVA_HOME / `%prog% launch --java <path>`"
.to_string(),
);
}
human.push(format!(
"落点模式:{}",
describe_mode(mode, settings.install_root.as_deref())
));
for root in &roots {
human.push(format!(" 扫描根:{}", root.display()));
}
for candidate in &candidates {
human.push(format!(
" {} {} [{}]",
candidate
.version
.clone()
.unwrap_or_else(|| "版本未知".to_string()),
candidate.binary.display(),
source_label(candidate)
));
}
Ok(Report::new(data, human))
}
/// `ull java install <major> [--vendor X]`:回退链查版本 → 下载 → 解压 → `java -version` 验证。
///
/// 幂等:同一个归档装过就直接复用(不联网、不解压)。下载走 `core::download` 引擎,
/// 国内环境按 D7 的地区策略优先院校/国内镜像。
///
/// # Errors
///
/// 大版本不合法、平台无产物、所有发行版都失败(网络/校验/解压/跑不起来)时返回用户错误。
pub fn install(ctx: &Ctx, major: u32, vendor: Option<JdkVendor>) -> Result<Report, CliError> {
if !(MIN_INSTALLABLE_MAJOR..=99).contains(&major) {
return Err(CliError::usage(format!(
"大版本号看起来不对:{major}(可用 8 / 11 / 17 / 21 / 25 这类 GA 大版本)"
)));
}
let settings = JavaSettings::load(ctx);
let engine = build_engine(ctx);
let install_root = settings.location(ctx)?;
let request = match vendor {
Some(vendor) => {
InstallRequest::with_vendor(major, vendor).with_priority(settings.priority.clone())
}
None => InstallRequest::new(major).with_priority(settings.priority.clone()),
};
let installed =
block_on(java::install(&engine, &install_root, request)).map_err(map_java_error)?;
let data = json!({
"major": installed.major,
"vendor": installed.vendor.id(),
"release": installed.release.release_name,
"version": installed.release.version,
"semver": installed.release.semver,
"archive": installed.release.archive.name,
"sha256": installed.release.archive.sha256,
"size": installed.release.archive.size,
"root": installed.root.display().to_string(),
"install_mode": installed.mode.as_str(),
"binary": installed.binary().display().to_string(),
"reused": installed.reused(),
"source": installed.downloaded,
"bytes": installed.bytes,
"files": installed.files,
"extracted": installed.extracted,
"chain": installed.chain.to_json(),
"runtime": candidate_json(&installed.runtime),
});
let mut human = Vec::new();
if installed.reused() {
human.push(format!(
"Java {}({})已就绪({}),未走网络",
installed.major,
installed.vendor.id(),
installed.root.display()
));
} else {
human.push(format!(
"已安装 Java {}({},来源 {},{} 字节 / {} 个文件)",
installed.major,
installed.release.summary(),
installed.downloaded.as_deref().unwrap_or("?"),
installed.bytes,
installed.files
));
}
human.push(format!("落点:{}", installed.location_line()));
human.push(format!("可执行文件:{}", installed.binary().display()));
if let Some(version) = &installed.runtime.version {
human.push(format!("探测:{version}"));
}
for note in installed.chain.degradations() {
human.push(format!("降级:{note}"));
}
human.push("`%prog% launch` 会自动优先用它".to_string());
Ok(Report::new(data, human))
}
/// 设置里与 JDK 有关的两项(`java.installRoot` / `java.vendorPriority`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct JavaSettings {
pub(crate) install_root: Option<PathBuf>,
pub(crate) priority: Vec<JdkVendor>,
}
impl JavaSettings {
/// 读 `<数据根>/.ULL/config/launcher.json`(坏文件退化成默认值,不阻断命令)。
pub(crate) fn load(ctx: &Ctx) -> Self {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
let install_root = store
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
let priority = store
.effective("java.vendorPriority")
.and_then(Value::as_array)
.map(|items| {
let texts: Vec<String> = items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect();
vendor::parse_priority(&texts)
})
.unwrap_or_default();
Self {
install_root,
priority,
}
}
/// 落点模式(只描述,不建目录)。
fn location_mode(&self, ctx: &Ctx) -> LocationMode {
if self.install_root.is_some() {
LocationMode::Custom
} else if ctx.data.mode == unlucky_core::platform::data_dir::DataMode::Standard {
LocationMode::Standard
} else {
LocationMode::Portable
}
}
/// 真正要装到哪(解析失败 → 用户错误,带可照着做的提示)。
fn location(&self, ctx: &Ctx) -> Result<java::JavaInstallRoot, CliError> {
java::JavaInstallRoot::resolve(
ctx.data.mode,
&ctx.layout(),
self.install_root.as_deref(),
ctx.platform,
&ProcessEnv,
)
.map_err(map_java_error)
}
}
/// 落点模式 → 人读一行(含「没设覆盖键时默认落在哪」的解释)。
fn describe_mode(mode: LocationMode, override_root: Option<&Path>) -> String {
match mode {
LocationMode::Custom => format!(
"custom(java.installRoot = {})",
override_root.unwrap_or(Path::new("?")).display()
),
LocationMode::Standard => {
"standard(%APPDATA%\\Java\\<发行方>\\<版本> 等系统标准目录)".to_string()
}
LocationMode::Portable => "portable(<数据根>/.ULL/data/java-managed)".to_string(),
}
}
/// 一个运行时的 `--json` 形状。
fn candidate_json(runtime: &JavaRuntime) -> Value {
json!({
"binary": runtime.binary.display().to_string(),
"source": runtime.origin.as_str(),
"source_category": runtime.origin.category(),
"version": runtime.version,
"major": runtime.major,
"bits": runtime.bits,
"vendor": runtime.vendor,
"install_root": install_root_of(runtime),
})
}
/// 托管 JDK 的实际目录(`bin/java` 的上两级);非托管来源为 `None`。
fn install_root_of(runtime: &JavaRuntime) -> Option<String> {
if runtime.origin != JavaOrigin::Managed {
return None;
}
runtime
.binary
.parent()
.and_then(Path::parent)
.map(|dir| dir.display().to_string())
}
/// 建下载引擎:装配参数来自设置(并发 / 重试 / gh-proxy,读数口径见 [`crate::download`]),
/// 引擎本体与 `install` / `pack` 共用同一个(core 按缓存根共享)。
pub(crate) fn build_engine(ctx: &Ctx) -> Arc<DownloadEngine> {
crate::download::engine(ctx)
}
/// JDK 下载/解压错误 → CLI 错误(保留稳定码与提示)。
fn map_java_error(error: java::JavaError) -> CliError {
let cli = CliError::user(error.code(), error.message().to_string());
match error.hint() {
Some(hint) => cli.with_hint(hint.to_string()),
None => cli,
}
}
/// `JAVA_HOME/bin/java`(存在才返回)。
fn env_java() -> Option<PathBuf> {
let home = std::env::var_os("JAVA_HOME")?;
let binary = PathBuf::from(home)
.join("bin")
.join(java::java_binary_name());
binary.is_file().then_some(binary)
}
/// 运行时 → 展示标签(托管项带上 vendor)。
fn source_label(runtime: &JavaRuntime) -> String {
match &runtime.vendor {
Some(vendor) => format!("{} · {vendor}", runtime.origin.as_str()),
None => runtime.origin.as_str().to_string(),
}
}
/// 当前地区档案(`java vendors` 展示「国内/国外」口径;也给 GUI 复用)。
#[must_use]
pub fn region_profile(ctx: &Ctx) -> RegionProfile {
build_engine(ctx).region().clone()
}
+252
View File
@@ -0,0 +1,252 @@
//! 启动期的 Java 解析(`ull launch` 用):实例覆盖 > 托管匹配 > 系统。
//!
//! 从 `commands/java.rs` 拆出来(那边还要放 list/install/vendors,超 400 行硬上限)。
//! 这里的职责是三条:①按 D13 的优先链挑一个 Java;②把选中的那个**钉成路径**交给装配
//! (D4:校验过的就是启动用的,不许装配期再重选一遍);③把结果整理成 `--json` 块与告警行。
//! 真正「谁优先」的判定在 `core::game::java::probe` / `select`(纯函数),本文件只做装配。
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use unlucky_core::game::java::{self, JavaMismatch, JavaOrigin, JavaRequirement, JavaRuntime};
use crate::context::Ctx;
/// 启动期的 Java 解析结果。
#[derive(Debug, Clone)]
pub struct LaunchJava {
/// 交给 `PlanInput.java` 的**钉死路径**:D4 校验过的那个 java。
///
/// 五个来源一视同仁(M220 修的就是这条):以前只有 `managed` 会钉住,
/// `JAVA_HOME` / `PATH` / 标准安装位置的命中会被 `snapshot::prepare` 的
/// `selection()` 按环境**再挑一遍**——于是 D4 判过的那个不是真启动的那个。
/// `None` 只可能是「一个候选都没探到」,此时只能让 `snapshot::prepare` 兜底。
pub override_path: Option<PathBuf>,
/// 实际选中的运行时。
pub runtime: Option<JavaRuntime>,
/// 目标版本对 Java 的要求。
pub requirement: JavaRequirement,
/// 不符说明(`None` = 满足要求,或压根没探测到 Java)。
///
/// `--dry-run` 只把它当告警打出来;真启动时先过 [`unlucky_core::game::java::select::decide`]:
/// 不符且没人显式指定 Java 就直接拦下、不 spawn(D4)。
pub mismatch: Option<JavaMismatch>,
}
impl LaunchJava {
/// `--json` 的 `java` 块(app 层 GUI 弹窗 P1 后直接吃这份数据)。
///
/// `launched` 是装配之后**真正要执行的** java 路径(`plan.request.java.binary`)。
/// 它作为参数传进来而不是在这里自己算:读的人不该去猜「`binary` 是不是真跑的那个」,
/// 所以两个事实并排给出(`binary` = D4 校验过的,`launch_binary` = 装配落地的)。
#[must_use]
pub fn to_json(&self, launched: &Path) -> Value {
let runtime = self.runtime.as_ref();
json!({
"binary": runtime.map(|runtime| runtime.binary.display().to_string()),
"launch_binary": launched.display().to_string(),
"major": runtime.and_then(|runtime| runtime.major),
"bits": runtime.map(|runtime| runtime.bits),
"source": runtime.map(|runtime| runtime.origin.as_str()),
"vendor": runtime.and_then(|runtime| runtime.vendor.clone()),
"required": self.requirement.describe(),
"required_major": self.requirement.minimum,
"requirement_source": self.requirement.source.as_str(),
"mismatch": self.mismatch.map(|mismatch| json!({
"code": mismatch.code(),
"message": mismatch.message(),
})),
})
}
/// 告警行(dry-run 与真启动都输出)。
///
/// `launched` 是装配之后真正要执行的 java 路径,用来兜一条「钉路径失效」的自检。
///
/// 三类:①用户指定的 Java 探测不出来(路径写错了,静默退回默认版本会很难查);
/// ②Java 与版本要求不符——`--dry-run` 到此为止只提示;真启动会先过 `select::decide`,
/// 只有「用户自己指定了 Java」时才会带着这条告警继续起(D4);
/// ③装配落地的不是 D4 校验过的那个(钉路径没生效,M220 的回归闸)。
#[must_use]
pub fn warnings(&self, launched: &Path) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(runtime) = &self.runtime {
if runtime.major.is_none() {
warnings.push(format!(
"探测不到 {} 的 `java -version` 输出(路径可能不对)",
runtime.binary.display()
));
}
}
if let Some(mismatch) = self.mismatch {
warnings.push(mismatch.message());
}
if let Some(unpinned) = self.pin_mismatch(launched) {
warnings.push(unpinned);
}
warnings
}
/// 装配落地的 java 与 D4 校验过的那个是否一致(不一致要当场喊出来)。
fn pin_mismatch(&self, launched: &Path) -> Option<String> {
match (&self.runtime, self.override_path.as_deref()) {
(Some(_), Some(validated)) if validated == launched => None,
(Some(_), Some(validated)) => Some(format!(
"装配用的 Java({})与校验过的({})不一致:这次启动没有走 D4 判过的那个",
launched.display(),
validated.display()
)),
// 选出了一个 java 却没钉住路径 ⇒ 装配期会按环境再挑一遍,D4 判的就不是真跑的那个。
(Some(runtime), None) => Some(format!(
"选中了 {}({})却没钉住路径,装配期按环境重选成了 {}:这一份没有经过 D4 校验",
runtime.binary.display(),
runtime.origin.as_str(),
launched.display()
)),
(None, _) => Some(format!(
"没探测到任何 Java,装配期按环境兜底用了 {}(这一份没有经过 D4 校验)",
launched.display()
)),
}
}
/// 人读一行:Java 版本 + 来源 + **具体可执行文件** + 该版本的要求。
///
/// 路径必须写出来(M220):五个来源都钉路径之后,「用的是哪个 java」不能再靠猜
/// ——同机装两个 21 的时候,只有路径能把两个 Jar 头日志对上的问题说清。
#[must_use]
pub fn summary(&self) -> String {
let (version, origin) = match &self.runtime {
Some(runtime) => (
runtime
.major
.map_or_else(|| "未知".to_string(), |major| major.to_string()),
format!("{} · {}", runtime.origin.as_str(), runtime.binary.display()),
),
None => ("未知".to_string(), "未探测到".to_string()),
};
format!(
"Java {version}({origin})· 该版本要求 {}",
self.requirement.describe()
)
}
}
/// 解析启动用的 Java:实例覆盖 > 托管匹配 > 系统。
///
/// 候选收集有代价(托管扫盘、`java -version` 子进程),所以顺序是「先便宜的、不满足才扫盘」:
/// 托管 + `PATH`/`JAVA_HOME` 不满足要求时,才把标准安装位置(Program Files 系、
/// 官方启动器的 `runtime/`、注册表)并进来重挑一次。
#[must_use]
pub fn resolve_for_launch(ctx: &Ctx, version_id: &str, explicit: Option<&Path>) -> LaunchJava {
let requirement = java::requirement_for_version(&ctx.game(), version_id);
let Some(path) = explicit else {
// 托管区可能不止一个根(Portable 与 Standard/Custom 并存,D23)。
let roots = super::java::managed_roots(ctx);
let mut candidates = java::discover_managed_roots(&roots);
candidates.extend(java::system_candidates());
let planned = plan_java(&candidates, &requirement);
if planned.mismatch.is_none() {
return planned;
}
candidates.extend(java::standard_candidates());
return plan_java(&java::discover::dedupe(candidates), &requirement);
};
// 用户自己指定了 Java:只探测这一个,不参与打分(D13:明确的选择不推翻)。
plan_java(&[java::probe(path, JavaOrigin::Instance)], &requirement)
}
/// 纯装配:从候选里挑一个,并把它**钉成路径**(候选可注入,测试不必真装 JDK)。
#[must_use]
pub fn plan_java(candidates: &[JavaRuntime], requirement: &JavaRequirement) -> LaunchJava {
let resolution = java::resolve(candidates, requirement);
// D4:`override_path` 就是 D4 校验过的那个运行时——五种来源一个口径,
// 不分「托管要钉、环境变量可以放它再挑一次」。
let override_path = resolution
.runtime
.as_ref()
.map(|runtime| runtime.binary.clone());
LaunchJava {
override_path,
runtime: resolution.runtime,
requirement: requirement.clone(),
mismatch: resolution.mismatch,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use unlucky_core::game::java::RequirementSource;
use super::{plan_java, JavaOrigin, JavaRequirement, JavaRuntime};
fn runtime(major: u32, origin: JavaOrigin) -> JavaRuntime {
JavaRuntime {
binary: PathBuf::from(format!("/jdks/{}/{major}/bin/java", origin.as_str())),
origin,
version: Some(format!("openjdk version \"{major}.0.1\"")),
major: Some(major),
bits: 64,
vendor: None,
}
}
/// 五种来源都必须把选中项的路径钉住(M220 的核心不变量)。
///
/// 抓得住:退回「只有 `managed` 才钉路径」——那时 `JAVA_HOME` / `PATH` / `system` /
/// `instance` 四种来源的 `override_path` 会变成 `None`,装配期按环境再挑一遍,
/// D4 校验过的那个就不是真启动的那个了。
#[test]
fn every_origin_pins_the_selected_binary() {
for origin in [
JavaOrigin::Instance,
JavaOrigin::Managed,
JavaOrigin::JavaHome,
JavaOrigin::Path,
JavaOrigin::System,
] {
let chosen = runtime(21, origin);
let planned = plan_java(
std::slice::from_ref(&chosen),
&JavaRequirement::new(17, None, RequirementSource::Table),
);
assert_eq!(planned.runtime.as_ref(), Some(&chosen), "{origin:?}");
assert_eq!(
planned.override_path,
Some(chosen.binary.clone()),
"{origin:?} 的命中必须钉成路径,不能留给装配期重选"
);
}
}
/// 钉的是**选中那个**,不是候选里第一个/来源排名最高的那个。
///
/// 抓得住:把 `override_path` 写成 `candidates.first()`,或者钉住一个与
/// `runtime` 不同的运行时(D4 判 A 却启动 B)。
#[test]
fn the_pinned_binary_is_the_one_that_satisfies_the_requirement() {
let legacy = runtime(8, JavaOrigin::JavaHome);
let modern = runtime(21, JavaOrigin::Managed);
let planned = plan_java(
&[modern, legacy.clone()],
&JavaRequirement::new(8, Some(8), RequirementSource::Table),
);
assert_eq!(planned.runtime.map(|runtime| runtime.major), Some(Some(8)));
assert_eq!(planned.override_path, Some(legacy.binary));
assert!(planned.mismatch.is_none());
}
/// 一个候选都没有时不编造路径(`None` = 让装配期按环境兜底),也不误报不符。
#[test]
fn no_candidate_pins_nothing() {
let planned = plan_java(
&[],
&JavaRequirement::new(21, None, RequirementSource::Table),
);
assert!(planned.runtime.is_none());
assert_eq!(planned.override_path, None);
assert!(planned.mismatch.is_none());
}
}
+149
View File
@@ -0,0 +1,149 @@
//! `ull java vendors`:四家发行版的可用性 / 镜像 / 当前优先级顺序(D23)。
//!
//! 从 `commands/java.rs` 拆出来(那边 list/install 已经占满一屏)。这里只读不写:
//! 不联网、不改配置,把 `mirrors.json` 的表与设置里的优先级排成一张人读 + `--json` 的对照表。
//!
//! 存在的意义是**可观测**:用户问「国内为什么默认走 Oracle 而不是微软」时,命令要能直接答
//! (顺序 + 每家的镜像与实测吞吐),而不是让人翻文档。
use serde_json::{json, Value};
use unlucky_core::download::MirrorTable;
use unlucky_core::game::java::{termux, vendor, JdkVendor};
use super::java::{region_profile, JavaSettings};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// `ull java vendors`。
///
/// # Errors
///
/// 设置文件坏掉时退化为默认值;命令本身不产生用户错误。
pub fn vendors(ctx: &Ctx) -> Result<Report, CliError> {
let settings = JavaSettings::load(ctx);
let domestic = region_profile(ctx).is_domestic();
let mut order = vendor::order(&settings.priority, domestic);
// Termux 上系统包是一条真实可用的发行版:不进默认回退序,但展示时要让人看见。
if termux::available() && !order.contains(&JdkVendor::Termux) {
order.insert(0, JdkVendor::Termux);
}
let table = MirrorTable::builtin();
let rows: Vec<Value> = order
.iter()
.enumerate()
.map(|(index, item)| vendor_json(&table, *item, index))
.collect();
let priority_source = if settings.priority.is_empty() {
"region_default"
} else {
"java.vendorPriority"
};
let data = json!({
"region": if domestic { "domestic" } else { "global" },
"priority_source": priority_source,
"order": vendor::ids(&order),
"vendors": rows,
});
let mut human = vec![format!(
"发行版顺序({},来源 {}):{}",
if domestic { "国内" } else { "国外" },
if settings.priority.is_empty() {
"地区默认序"
} else {
"java.vendorPriority"
},
vendor::ids(&order).join(" → ")
)];
for row in &rows {
human.push(format!(
" {} {} {} 支持 Java {}",
row["rank"].as_u64().unwrap_or(0),
row["vendor"].as_str().unwrap_or("?"),
row["display"].as_str().unwrap_or(""),
join_numbers(&row["majors"], "/")
));
for source in row["sources"].as_array().into_iter().flatten() {
if let Some(text) = source.as_str() {
human.push(format!(" 源:{text}"));
}
}
}
Ok(Report::new(data, human))
}
/// 一家的 `--json` 形状(源清单按家取,缺表时为空数组而不是报错)。
fn vendor_json(table: &MirrorTable, item: JdkVendor, index: usize) -> Value {
let sources: Vec<String> = match item {
JdkVendor::Temurin => table
.adoptium
.iter()
.filter(|mirror| mirror.enabled)
.map(|mirror| {
format!(
"{}{}",
mirror.name,
if mirror.latest_only {
"(只留最新补丁)"
} else {
""
}
)
})
.chain(std::iter::once(
"GitHub 官方(国内套 gh-proxy)".to_string(),
))
.collect(),
JdkVendor::Oracle => table
.oracle_mirrors()
.map(|mirror| match mirror.speed.trim() {
"" => format!("{} {}", mirror.name, mirror.base()),
speed => format!("{} {} {}", mirror.name, mirror.base(), speed),
})
.collect(),
JdkVendor::Microsoft => table
.microsoft_openjdk()
.map(|microsoft| {
let speed = if microsoft.measured_speed.trim().is_empty() {
"实测约 400 KB/s".to_string()
} else {
microsoft.measured_speed.clone()
};
vec![format!(
"aka.ms → VS CDN({speed});无国内镜像、无 GitHub 资产"
)]
})
.unwrap_or_default(),
JdkVendor::Zulu => table
.zulu()
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
.unwrap_or_default(),
JdkVendor::Termux => termux::MIRRORS
.iter()
.map(|mirror| format!("{mirror}(系统包:pkg install openjdk-<major>)"))
.collect(),
};
json!({
"rank": index + 1,
"vendor": item.id(),
"display": item.display(),
"majors": item.hint_majors(),
"supported": (8..=99u32).filter(|major| item.supports(*major)).collect::<Vec<_>>(),
"sources": sources,
})
}
/// JSON 数字数组 → `21/17/11` 这种分隔串。
fn join_numbers(value: &Value, separator: &str) -> String {
value
.as_array()
.map(|items| {
items
.iter()
.filter_map(Value::as_u64)
.map(|number| number.to_string())
.collect::<Vec<_>>()
.join(separator)
})
.unwrap_or_default()
}
+298
View File
@@ -0,0 +1,298 @@
//! `ull launch <instance> [--dry-run]`(D18:`--dry-run` 只打印组装后的命令行)。
//!
//! D20 的隔离档位在**这里**落成 `--gameDir`:本命令按覆盖链
//! (临时参数 > 实例 `isolation` > 全局 `instances.mode`)派生出运行目录,
//! 交给 [`snapshot::prepare`] 装进 `LaunchDirs.game_dir`(占位符 `${game_directory}`
//! 与子进程 CWD 都取这个值),并在启动时做一次切换检测(只探测、不迁移)。
use serde_json::json;
use unlucky_core::game::java::select;
use unlucky_core::game::ProfileList;
use unlucky_core::launch::{LaunchError, Launcher, LauncherConfig};
use crate::cli::LaunchArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
use super::identity::{self, Identity};
use super::snapshot::{self, PlanInput};
// 隔离档位(D20)的派生与切换检测搬到独立模块:本文件贴着 400 行红线(M220)。
// 用 `#[path]` 而不是在 `commands/mod.rs` 里登记,是为了这次改动只碰本文件 + 新文件。
#[path = "launch_isolation.rs"]
mod launch_isolation;
use launch_isolation::resolve_isolation;
/// 执行 `launch`。
///
/// # Errors
///
/// 实例/版本不存在、版本 JSON 不可用、命令行组装失败、进程起不来时返回用户错误。
pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
let profiles = super::instances::read_profiles(ctx)?;
let game = ctx.game();
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance);
let (input, instance_found) = match profile {
Some(profile) => (
PlanInput {
version_id: profile.version_id.clone(),
instance_id: profile.id.as_str().to_string(),
instance_name: profile.name.clone(),
game_dir: profile.game_dir.clone(),
java: args.java.clone().or_else(|| profile.java_path.clone()),
username: args.username.clone(),
max_memory: args.max_memory,
..PlanInput::default()
},
true,
),
None => {
// 也接受「直接给版本 id」:没有实例档案时按版本跑一次(脚本化常见)。
if !game.is_version_installed(&args.instance) {
return Err(CliError::user(
"not_found",
format!(
"既没有实例 `{}`,也没有版本 `{}`",
args.instance, args.instance
),
)
.with_hint(instance_hint(&profiles)));
}
(
PlanInput {
version_id: args.instance.clone(),
instance_name: args.instance.clone(),
java: args.java.clone(),
username: args.username.clone(),
max_memory: args.max_memory,
..PlanInput::default()
},
false,
)
}
};
// D13 选 Java:命中就把路径钉死交给 `snapshot::prepare`(五个来源一个口径,M220)。
// 顺序是有意的(M220 / nit3):D4 的拦截排在身份解析**之前**——解析身份会联网刷新微软轨
// 令牌(有副作用,还会顺手改写凭据),而 D4 判下来必被拦的启动根本用不到身份。
// 这一步只看版本 id 与「谁指定了 Java」,不依赖身份解析的结果,所以拆得开。
//
// 「谁指定了 Java」必须连实例设置/全局设置里的 `javaPath` 一起算:装配期
// (`snapshot::prepare` 的 `effective.java_path`)用的就是那一份,D4 校验的却只认
// `--java` / 档案 `javaPath` 的话,用户配过的 JDK 会被自动挑的结果顶掉。
let configured_java = super::instance_launch::resolved(ctx, &input.instance_id).java_path;
let chosen_java = input.java.clone().or(configured_java);
unlucky_core::launch::launcher_log::set_dir(ctx.logs_dir());
let explicit_java = chosen_java.is_some();
let java = super::java::resolve_for_launch(ctx, &input.version_id, chosen_java.as_deref());
if !args.dry_run {
if let Some(mismatch) = select::decide(java.mismatch, explicit_java).block() {
unlucky_core::launch::launcher_log::record("launch", &mismatch.message());
// 提示里补上「挑中的是哪个 java」(M220):不符时用户第一个问题就是「它到底用了我哪个
// JDK」,以前只能自己猜;这条文案只加不减。
return Err(
CliError::user("java_mismatch", mismatch.message()).with_hint(format!(
"{};挑中的是 {}",
mismatch.advice(),
java.summary()
)),
);
}
}
// 实例存在性先确认、Java 先判,再解析身份:微软轨解析身份会联网刷新令牌(有副作用),
// 不该在一个注定 not_found 或注定被 D4 拦下的启动上白跑一遍、更不该顺手改写凭据。
let identity = identity::resolve(
ctx,
args.account.as_deref(),
args.username.as_deref(),
args.dry_run,
)?;
let input = PlanInput {
auth: Some(identity.placeholders.clone()),
// `override_path` 已经是 D4 校验过的那个 java,装配期只照用、不再挑。
java: java.override_path.clone(),
..input
};
let isolation = resolve_isolation(ctx, profile, &input, args.dry_run)?;
let input = PlanInput {
// 派生结果就是 `--gameDir` 的取值(覆盖链已求值完)。
game_dir: Some(isolation.game_dir.clone()),
..input
};
// M188/M211:命令行这一路也把启动器日志写进 `<data>/logs/launcher.log`
// (落点与格式的真相都在 core 的 `launcher_log`,GUI 写的是同一份)。
unlucky_core::launch::launcher_log::record_start(
&input.instance_id,
&isolation.game_dir,
java.runtime
.as_ref()
.map(|runtime| (runtime.binary.as_path(), runtime.major.unwrap_or_default())),
input.max_memory,
);
let plan = snapshot::prepare(ctx, &input)?;
let config = LauncherConfig::new(ctx.sessions_root())
.with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION"))
.with_watchdog_env();
// D6:两个看门狗时长的**生效值**落 launcher.log —— 卡死之后第一个要看的就是「等了多久才放弃」。
let watchdog = config.watchdog_summary();
unlucky_core::launch::launcher_log::record("launch", &watchdog);
let launcher = Launcher::new(config);
let mut human = Vec::new();
human.push(watchdog);
human.push(format!(
"隔离档位 {}(来源 {})· 运行目录 {}",
isolation.isolation.as_str(),
isolation.source.as_str(),
isolation.game_dir.display()
));
if let Some(warning) = &isolation.warning {
human.push(format!("提示: {warning}"));
}
human.push(identity_line(&identity));
for note in &identity.notes {
human.push(format!("提示: {note}"));
}
human.push(java.summary());
for warning in java.warnings(&plan.request.java.binary) {
human.push(format!("告警: {warning}"));
}
if args.dry_run {
let dry = launcher
.dry_run(&plan.request)
.map_err(|error| map_launch_error(&error, ctx))?;
let mut lines = vec![dry.line().to_string()];
lines.push(format!(
"(--dry-run:未启动进程 · classpath {} 项 · 编码 {})",
dry.classpath_entries, dry.encoding
));
if let Some(natives) = &dry.natives {
lines.push(format!("natives:{}", natives.summary()));
}
let mut warnings = dry.warnings.clone();
warnings.extend(java.warnings(&plan.request.java.binary));
// 人读行里 Java 告警已经在上面打过一次,这里只补 `dry_run` 自己新发现的。
for warning in &dry.warnings {
lines.push(format!("告警: {warning}"));
}
human.extend(lines);
let natives = dry.natives.as_ref().map(|natives| natives.to_json());
let data = json!({
"dry_run": true,
"instance_found": instance_found,
"version_id": plan.version_id,
"isolation": isolation.isolation.as_str(),
"isolation_source": isolation.source.as_str(),
"game_dir": isolation.game_dir.display().to_string(),
"mode_change_warning": isolation.warning,
"account": account_json(&identity),
"java": java.to_json(&plan.request.java.binary),
"program": dry.program.display().to_string(),
"argv": dry.argv,
"display": dry.display,
"classpath_entries": dry.classpath_entries,
"encoding": dry.encoding,
"warnings": warnings,
"natives": natives,
"adapter_warnings": plan.warnings,
});
return Ok(Report::new(data, human));
}
let handle = launcher
.launch(&plan.request)
.map_err(|error| map_launch_error(&error, ctx))?;
unlucky_core::launch::launcher_log::record_end(&input.instance_id, &handle);
let data = json!({
"dry_run": false,
"instance_found": instance_found,
"isolation": isolation.isolation.as_str(),
"isolation_source": isolation.source.as_str(),
"game_dir": isolation.game_dir.display().to_string(),
"mode_change_warning": isolation.warning,
"account": account_json(&identity),
"java": java.to_json(&plan.request.java.binary),
"warnings": java.warnings(&plan.request.java.binary),
"session_id": handle.session_id,
"log_path": handle.log_path.display().to_string(),
"exit_code": handle.exit.code,
"exit_type": handle.exit.exit_type.as_str(),
"uptime_ms": handle.uptime_ms,
"summary": handle.exit.summary(),
"artifacts": handle.artifacts.iter().map(|artifact| artifact.path.display().to_string()).collect::<Vec<_>>(),
"adapter_warnings": plan.warnings,
});
human.push(format!(
"会话 {} 结束:{}",
handle.session_id,
handle.exit.summary()
));
human.push(format!("事件流: {}", handle.log_path.display()));
Ok(Report::new(data, human))
}
fn map_launch_error(error: &LaunchError, ctx: &Ctx) -> CliError {
// D6:看门狗超时是一个**可区分的**结局(脚本要能单独处理),且自带现场与可调项,
// 不该被塞上「版本本体目录」这种无关提示。
if let LaunchError::Watchdog { .. } = error {
return CliError::user("launch_timeout", error.to_string());
}
let hint = format!("版本本体目录:{}", ctx.game().versions_dir().display());
CliError::user("launch", error.to_string()).with_hint(hint)
}
fn instance_hint(profiles: &ProfileList) -> String {
if profiles.profiles.is_empty() {
"用 `%prog% instances list` 查看实例,或 `%prog% install <version>` 登记一个".to_string()
} else {
let names: Vec<&str> = profiles
.profiles
.iter()
.map(|profile| profile.name.as_str())
.collect();
format!("可用实例:{}", names.join("、"))
}
}
/// 人读一行:本次用谁的身份启动。
fn identity_line(identity: &Identity) -> String {
match &identity.account {
Some(meta) => format!(
"账户 {}({})· UUID {}",
meta.display_name(),
meta.kind.label(),
identity.placeholders.uuid
),
None => format!(
"账户 离线占位 {}(未登记账户)",
identity.placeholders.player_name
),
}
}
/// `--json` 里的身份块(**只有可公开字段**,token 永远不出现)。
fn account_json(identity: &Identity) -> serde_json::Value {
match &identity.account {
Some(meta) => json!({
"name": meta.display_name(),
"kind": meta.kind.as_str(),
"profile_id": identity.placeholders.uuid,
"account_id": meta.id.as_str(),
"registered": true,
}),
None => json!({
"name": identity.placeholders.player_name,
"kind": "offline-placeholder",
"profile_id": identity.placeholders.uuid,
"registered": false,
}),
}
}
+131
View File
@@ -0,0 +1,131 @@
//! `ull launch` 的隔离档位派生(D20):运行目录 + 切换检测。
//!
//! 从 `commands/launch.rs` 原样搬出(那边贴着 400 行红线,M220 的 Java 钉路径改动要往里加行)。
//! **搬动是纯挪**:函数体、注释、判定顺序一字未改,只补了本模块的 import 与 `pub(super)`
//! (`launch.rs` 里的 `#[path = "launch_isolation.rs"] mod launch_isolation;` 挂载)。
use std::path::PathBuf;
use unlucky_core::game::install::instance::{
read_instance_config, write_instance_config, InstanceConfig, Isolation,
};
use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode};
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::context::Ctx;
use crate::error::CliError;
use super::snapshot::PlanInput;
/// 隔离派生的结果(运行目录 + 生效档位 + 提示)。
pub(super) struct IsolationPlan {
/// 生效运行目录(喂 `--gameDir`)。
pub(super) game_dir: PathBuf,
/// 生效档位。
pub(super) isolation: Isolation,
/// 档位来自哪一层(`temporary` / `instance` / `global`)。
pub(super) source: unlucky_core::game::IsolationSource,
/// 切换检测提示(两侧都有内容时;`None` = 不必提示)。
pub(super) warning: Option<String>,
}
/// 按覆盖链派生运行目录,并在启动时做一次切换检测。
///
/// 覆盖链:临时参数(`ull launch` 暂无该 flag,留 `None`) > 实例 `isolation` > 全局 `instances.mode`。
/// 切换检测只探测 `mods/saves/config` 的条目数并写进实例文件的 `lastModeChange`,
/// **不迁移任何数据**(D20 定案);提示只展示一次(`warned` 落盘)。
///
/// 遗留实例(`profiles.json` 有档案、`config/instances/<id>.json` 还没有——M27 之前的形态)
/// 会在**首次真启动**时补建基线文件:否则这批「最可能撞上存档消失」的用户永远拿不到提示。
/// `--dry-run` 仍然一个字节都不写。
pub(super) fn resolve_isolation(
ctx: &Ctx,
profile: Option<&unlucky_core::game::InstanceProfile>,
input: &PlanInput,
dry_run: bool,
) -> Result<IsolationPlan, CliError> {
let global_mode = global_mode(ctx);
let mut config = match profile {
Some(profile) => {
read_instance_config(&ctx.layout(), profile.id.as_str()).map_err(|error| {
CliError::user(
"instance_config",
format!("读实例设置 {} 失败:{error}", profile.id.as_str()),
)
})?
}
None => None,
};
// 补建基线:有档案没有设置文件 → 先按最小配置在内存里建一份(落盘看下面的 dirty)。
let bootstrap = config.is_none() && profile.is_some();
if bootstrap {
config = Some(InstanceConfig::new(
input.instance_id.clone(),
input.version_id.clone(),
));
}
// 读兼容:M27 之前的档案只在 profiles.json 里存 `game_dir`;这种「显式指定目录」的历史语义
// 等价于 `custom`,不要静默退回全局共享。补建基线时把这条映射**写进文件**,
// 让实例文件成为唯一真相(profiles.json 的 `game_dir` 是遗留字段)。
if let Some(config) = config.as_mut() {
if config.isolation.is_none() {
if let Some(dir) = profile
.and_then(|profile| profile.game_dir.clone())
.filter(|dir| !dir.as_os_str().is_empty())
{
config.isolation = Some(Isolation::Custom);
config.game_dir = Some(dir);
}
}
}
let derivation = derive_game_dir(
&ctx.game(),
&input.version_id,
global_mode,
config.as_ref(),
None,
)
.map_err(|error| CliError::user("isolation", error.to_string()))?;
// 切换检测:生效档位与记录里的 `to` 不同 → 记一次(探测双侧,只记事实)。
let at = format_rfc3339_millis(now());
let mut dirty = false;
let warning = match config.as_mut() {
Some(config) => {
let changed = observe_mode(config, &ctx.game(), &at, derivation.isolation)
.map_err(|error| CliError::user("isolation_probe", error.to_string()))?;
let warning = config.take_mode_change_warning();
dirty = bootstrap || changed || warning.is_some();
warning.map(|warning| warning.message())
}
None => None,
};
// `--dry-run` 不落盘(否则「只看一眼」也会吃掉一次性提示、改写实例状态)。
if !dry_run && dirty {
if let Some(config) = config.as_ref() {
write_instance_config(&ctx.layout(), config).map_err(|error| {
CliError::user("instance_config", format!("写实例设置失败:{error}"))
})?;
}
}
Ok(IsolationPlan {
game_dir: derivation.game_dir,
isolation: derivation.isolation,
source: derivation.source,
warning,
})
}
/// 全局默认隔离模式(`launcher.json` 的 `instances.mode`,缺键/坏值一律按 D20 默认 `isolated`)。
fn global_mode(ctx: &Ctx) -> IsolationMode {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
store
.doc()
.effective_str("instances.mode")
.and_then(IsolationMode::parse)
.unwrap_or_default()
}
+68
View File
@@ -0,0 +1,68 @@
//! 元命令:`--version` / 无子命令 / `help`。
use serde_json::json;
use crate::cli::{help, program};
use crate::os;
use crate::output::Report;
/// 启动器名(`${launcher_name}` 与 `--version` 输出共用)。
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": invoked_as,
"package": env!("CARGO_PKG_NAME"),
"version": env!("CARGO_PKG_VERSION"),
"os": os::to_json(&info),
});
let mut human = vec![
format!(
"{invoked_as} {}({})",
env!("CARGO_PKG_VERSION"),
env!("CARGO_PKG_NAME")
),
format!("os: {}", os::summary_line(&info)),
];
human.extend(os::notes(&info));
Report::new(data, human)
}
/// 无子命令:版本摘要 + 帮助(用户原话「无子命令时 --version + --help」)。
#[must_use]
pub fn welcome() -> Report {
let version_report = version();
let mut human = version_report.human;
let text = general_help();
human.push(String::new());
human.extend(text.lines().map(str::to_string));
let mut data = version_report.data;
data["help"] = json!(text);
Report::new(data, human)
}
/// 帮助(`ull help <命令>` 给该命令,未知主题退回总帮助)。
#[must_use]
pub fn help(topic: Option<&str>) -> Report {
let text = match topic.and_then(help::topic) {
Some(text) => program::fill(text),
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::fill(help::general())
}
+241
View File
@@ -0,0 +1,241 @@
//! 命令分发:命令模型 → 具体实现,并统一挂上环境提示。
pub mod accounts;
pub mod agent;
pub mod config;
pub mod identity;
pub mod install;
pub mod instance_launch;
pub mod instances;
pub mod java;
pub mod java_launch;
pub mod java_vendors;
pub mod launch;
pub mod meta;
pub mod pack;
pub mod sessions;
pub mod snapshot;
pub mod update;
use crate::cli::{Command, Globals, Invocation};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{Outcome, Report};
/// 执行一条已解析的命令。
///
/// # Errors
///
/// 用法/数据根/目标不存在等用户错误,或序列化失配等内部错误。
pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
match &invocation.command {
// 这三条不需要数据根(`--version` 在不达基线的机器上也要能跑,用于诊断)。
Command::Welcome => Ok(Outcome::new(meta::welcome())),
Command::Version => Ok(Outcome::new(meta::version())),
Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))),
Command::InstancesList => with_ctx(&invocation.globals, instances::list),
Command::InstancesLaunchArgs(args) => {
with_ctx(&invocation.globals, |ctx| instance_launch::run(ctx, args))
}
Command::InstancesScan => with_ctx(&invocation.globals, instances::scan::run),
Command::InstancesAdopt(args) => {
with_ctx(&invocation.globals, |ctx| instances::adopt::run(ctx, args))
}
Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)),
Command::PackImport(args) => {
with_ctx_outcome(&invocation.globals, |ctx| pack::run(ctx, args))
}
Command::Launch(args) => with_ctx(&invocation.globals, |ctx| launch::run(ctx, args)),
Command::SessionsList { limit } => {
with_ctx(&invocation.globals, |ctx| sessions::list(ctx, *limit))
}
Command::Logs(args) => with_ctx(&invocation.globals, |ctx| sessions::logs(ctx, args)),
Command::Diag(args) => with_ctx(&invocation.globals, |ctx| sessions::diag(ctx, args)),
Command::ConfigGet(args) => with_ctx(&invocation.globals, |ctx| {
config::get(ctx, args.key.as_deref(), args.show_secret)
}),
Command::ConfigSet(args) => with_ctx(&invocation.globals, |ctx| {
config::set(ctx, &args.key, &args.value)
}),
Command::JavaList => with_ctx(&invocation.globals, java::list),
Command::JavaInstall { major, vendor } => with_ctx(&invocation.globals, |ctx| {
java::install(ctx, *major, *vendor)
}),
Command::JavaVendors => with_ctx(&invocation.globals, java::vendors),
Command::UpdateCheck(args) => {
with_ctx_outcome(&invocation.globals, |ctx| update::check(ctx, args))
}
Command::UpdateApply(args) => {
with_ctx_outcome(&invocation.globals, |ctx| update::apply(ctx, args))
}
Command::AccountsList => with_ctx(&invocation.globals, accounts::list),
Command::AccountsAdd(args) => with_ctx(&invocation.globals, |ctx| accounts::add(ctx, args)),
Command::AccountsRemove { name } => {
with_ctx(&invocation.globals, |ctx| accounts::remove(ctx, name))
}
// `AgentServe` 由 `runner::run` 提前分流(stdio 形态下 stdout 就是协议流,
// 不能走「先返回 Report 再打印」的路径);这里只兜住「有人直接调 commands::run」。
Command::AgentServe(_) => Err(CliError::usage(
"agent serve 必须在进程入口直接运行(runner 会提前分流)",
)),
Command::AgentConfig { port } => {
with_ctx(&invocation.globals, |ctx| agent::config_report(ctx, *port))
}
}
}
/// `core::auth` 的错误 → CLI 错误(保留稳定机读码;文案已是人话,这里只补「下一步」)。
///
/// 集中一处的原因:账户相关命令(`accounts *`)与 `launch` 的身份解析要为**同一批**
/// 错误给出一致的码与提示——两处各写一份必然会漂。
pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError {
use unlucky_core::auth::body;
use unlucky_core::auth::AuthError;
let hint = match &error {
AuthError::Canceled => Some("登录被取消,可以重跑一次".to_string()),
AuthError::Timeout { .. } => {
Some("重新执行 `%prog% accounts add msa` 拿新的用户码".to_string())
}
AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()),
// M117:id 缺失是「没配置」,不是「没实现」。hint 里给**能照抄的命令**(两条路:
// 运行期一次性的、构建期烘进二进制的),别只丢一个变量名让用户自己猜。
// M149:官方发布构建已把 id 烘进二进制(真值走仓库 Secret,不进 git 文件),所以能走到这个
// hint 的只可能是自建且没注入 id 的二进制——文案与 `cli/help.rs` 对齐:官方构建已内置。
AuthError::Unsupported(_) => Some(format!(
"官方构建已内置微软登录所需的客户端 id,装完即可用;自建构建得自己给:\
运行期 `{env}=<id> %prog% accounts add msa`(优先于内置值);\
或构建时注入 `{env}=<id> cargo build` 让二进制自带。",
env = unlucky_core::auth::microsoft::CLIENT_ID_ENV
)),
AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()),
AuthError::NotFound { .. } => Some("用 `%prog% accounts list` 查看账户名".to_string()),
AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" => {
Some("去 minecraft.net 检查该账号的 Java 版拥有权与档案".to_string())
}
AuthError::Xbox { .. } => {
Some("去 xbox.com / 家庭组设置里解决该账号的 Xbox 限制".to_string())
}
// M38 的两类分流落到 hint 行:拦截页(HTML/纯文本/空体)先查网络,
// JSON 拒绝按步骤查许可与区域。`code()` 仍是 "denied"(M31 兼容),
// 细分结论由 `detail_code()` 带在 hint 里,排障脚本照样能机读分流。
AuthError::Response(detail) if detail.is_interception() => Some(format!(
"检查网络或代理(关掉/换掉代理后再试,必要时用镜像方案);诊断码 {}",
detail.detail_code()
)),
AuthError::Response(detail) => Some(format!(
"{};诊断码 {}",
body::advice(&detail.step, detail.kind),
detail.detail_code()
)),
_ => None,
};
let cli = CliError::user(error.code(), error.message());
match hint {
Some(hint) => cli.with_hint(hint),
None => cli,
}
}
/// GUI / CLI 共用的**启动日志一行**:`[auth] msa client_id=configured|missing source=env|build|none`。
///
/// M117:判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,这里只做出面——
/// `app-iced` 不直接依赖 `unlucky-core`,而 `unlucky-cli` 已经是它的依赖(同一个二进制既跑
/// GUI 也跑 `ull` 子命令)。所以「谁打印」不在 UI 侧重算一遍判据,免得两份口径漂。
///
/// 用途:日后看启动日志能一眼分辨「没配置」与「配了但登录失败」,不用再猜。
#[must_use]
pub fn msa_client_id_log_line() -> String {
unlucky_core::auth::microsoft::client_id_status().log_line()
}
/// 需要数据根的命令:解析上下文 → D19 支持级别闸门 → 执行 → 挂环境提示。
fn with_ctx<F>(globals: &Globals, action: F) -> Result<Outcome, CliError>
where
F: FnOnce(&Ctx) -> Result<Report, CliError>,
{
let ctx = Ctx::resolve(globals)?;
ctx.ensure_supported()?;
let report = action(&ctx)?;
Ok(Outcome::with_warnings(report, ctx.notes()))
}
/// 同 [`with_ctx`],但命令自己组装 [`Outcome`](需要指定退出码或多态结果时用)。
///
/// 只有 `pack import` 走这条路:它的三态要 0 / 3 / 1 三个退出码,`Report` 表达不了。
fn with_ctx_outcome<F>(globals: &Globals, action: F) -> Result<Outcome, CliError>
where
F: FnOnce(&Ctx) -> Result<Outcome, CliError>,
{
let ctx = Ctx::resolve(globals)?;
ctx.ensure_supported()?;
let mut outcome = action(&ctx)?;
outcome.warnings.extend(
ctx.notes()
.into_iter()
.map(|line| crate::cli::program::fill(&line)),
);
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::map_auth_error;
use unlucky_core::auth::{AuthError, BodyKind, ResponseDetail};
fn response(step: &str, kind: BodyKind) -> AuthError {
AuthError::Response(Box::new(ResponseDetail {
step: step.to_string(),
what: "Minecraft 登录被拒".to_string(),
status: 403,
content_type: None,
kind,
server_message: None,
digest: String::new(),
}))
}
#[test]
fn response_hints_split_network_from_license_and_carry_detail_code() {
let blocked = map_auth_error(response("login_with_xbox", BodyKind::Html));
assert_eq!(blocked.code(), "denied", "code() 仍是 M31 的 denied");
let hint = blocked.hint().expect("拦截页要有 hint").to_string();
assert!(hint.contains("代理"), "{hint}");
assert!(hint.contains("login_with_xbox_non_json"), "{hint}");
let denied = map_auth_error(response("login_with_xbox", BodyKind::Json));
let hint = denied.hint().expect("JSON 拒绝要有 hint").to_string();
assert!(hint.contains("许可"), "{hint}");
assert!(!hint.contains("代理"), "{hint}");
assert!(hint.contains("login_with_xbox_403"), "{hint}");
}
#[test]
fn startup_log_line_is_one_machine_readable_line() {
// M117:这行是「没配置 vs 配了但登录失败」的唯一机读判据,形状不能漂。
let line = super::msa_client_id_log_line();
assert!(line.starts_with("[auth] msa client_id="), "{line}");
assert!(line.contains(" source="), "{line}");
assert!(!line.contains('\n'), "必须正好一行:{line}");
let source = line.rsplit("source=").next().unwrap_or_default();
assert!(matches!(source, "env" | "build" | "none"), "{line}");
}
#[test]
fn missing_client_id_hint_carries_a_runnable_command() {
// M117:用户实跑看到的「本期未支持」是错的表述,而且 hint 只丢了变量名。
// 判据:文案说「未配置」、不说「未支持/未实现」,并给出能照抄的命令(含变量名)。
let cli = map_auth_error(AuthError::Unsupported(
unlucky_core::auth::microsoft::MISSING_CLIENT_ID_MESSAGE.to_string(),
));
let message = cli.message();
assert_eq!(cli.code(), "unsupported", "机读码保持兼容");
assert!(message.contains("客户端 id"), "{message}");
assert!(!message.contains("未支持"), "{message}");
assert!(!message.contains("未实现"), "{message}");
let hint = cli.hint().expect("缺配置要有 hint").to_string();
assert!(hint.contains("ULL_MSA_CLIENT_ID=<id>"), "{hint}");
assert!(hint.contains("cargo build"), "{hint}");
assert!(hint.contains("accounts add msa"), "{hint}");
}
}
+400
View File
@@ -0,0 +1,400 @@
//! `pack import <file>`:整合包导入(M111)。
//!
//! 逻辑**全在** `core::addon::import`:本文件只做参数落地、引擎接线、两种输出。坚持这一层很薄,
//! 是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`,不是这里的打印逻辑。
//!
//! # 三态退出码(脚本契约)
//!
//! | `data.status` | 退出码 | 含义 |
//! |---------------|--------|------|
//! | `success` | `0` | 全部落地、无冲突 |
//! | `partial` | `3` | 有缺文件或有保留用户文件的冲突(实例可用但不完整) |
//! | `failed` | `1` | 致命失败,**已整包回滚** |
//! | `cancelled` | `130` | 用户取消(管线在检查点停手并回滚,**不是失败**) |
//!
//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(Ctrl-C 直接杀进程),
//! 但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时要能把它与 `failed` 分开。
//!
//! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、数据根不可用)。
use std::path::Path;
use std::sync::{Arc, Mutex};
use serde_json::json;
use unlucky_core::addon::import::{
import_pack, ConflictChoice, ConflictPolicy, DownloadEngineFetcher, FileAction, ImportProgress,
ImportReport, ImportRequest, ImportStatus,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::install::pack_dir;
use crate::cli::PackImportArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{Outcome, Report};
/// `ull pack import`。
///
/// # Errors
///
/// 整合包路径不存在(用户错误)。导入本身的失败**不是** `Err`:它走三态报告 + 退出码。
pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
if !args.path.exists() {
return Err(CliError::user(
"not_found",
format!("整合包不存在:{}", args.path.display()),
)
.with_hint("给 .zip / .mrpack 文件,或 MultiMC·Prism 的实例目录"));
}
let layout = ctx.layout();
// **M216 ②**:缺省落点 = `<data>/instances/<实例名>`(与 GUI 同源,判据在 core 的
// `pack_dir::import_target`),不再是共享的 `.minecraft` 根。
let (target, instance_name) = pack_dir::import_target(
&layout,
&args.path,
args.target.as_deref(),
args.name.as_deref(),
)
.map_err(|reason| {
CliError::user("target_exists", reason).with_hint(
"加 --name 换个实例名,或显式给 --target(--overwrite 只管文件冲突,绕过不了这条预检)",
)
})?;
let stages: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&stages);
let sink: unlucky_core::addon::ImportProgressSink =
Arc::new(move |progress: &ImportProgress| {
if let Ok(mut seen) = recorder.lock() {
if seen.last().copied() != Some(progress.stage.as_str()) {
seen.push(progress.stage.as_str());
}
}
});
let engine = build_engine(ctx);
let fetcher = DownloadEngineFetcher::new(&engine);
let mut request = ImportRequest::new(&args.path, &target)
.with_fetcher(&fetcher)
.with_progress(sink);
if args.overwrite {
request = request.with_conflict_policy(ConflictPolicy::Overwrite);
}
if !args.no_register {
// 登记名与目录名同源(GUI 也是这样:`instance_name` 缺省用包名)。
request = request.with_registration(&layout, Some(&instance_name));
}
let report = block_on(import_pack(&request));
// **§5**:缺省建出来的实例要绑到它的内容目录(判据/文案在 core,与 GUI 同一份),否则
// 「导入成功」而那批 mods/config 永远不会被那条实例的启动用到。
let auto = args.target.is_none() && !args.no_register;
let binding = pack_dir::bind_created_instance(&layout, &target, auto, &report);
let seen = stages.lock().map(|seen| seen.clone()).unwrap_or_default();
Ok(outcome(
ctx,
&report,
&target,
&seen,
args,
binding.as_ref(),
))
}
/// 组装 `Outcome`(三态 → 退出码 + 两种输出)。
fn outcome(
ctx: &Ctx,
report: &ImportReport,
target: &Path,
stages: &[&'static str],
args: &PackImportArgs,
binding: Option<&Result<String, String>>,
) -> Outcome {
let mut data = serde_json::to_value(report).unwrap_or_else(|_| {
json!({
"status": report.status.as_str(),
"error": {"code": "serialize", "message": "报告序列化失败"},
})
});
if let Some(object) = data.as_object_mut() {
object.insert("target".to_string(), json!(target.display().to_string()));
object.insert("stages".to_string(), json!(stages));
object.insert("status_key".to_string(), json!(report.status.message_key()));
object.insert("installed_files".to_string(), json!(report.installed()));
object.insert(
"target_is_default".to_string(),
json!(args.target.is_none()),
);
}
let mut human: Vec<String> = Vec::new();
human.push(format!(
"整合包 {}({})→ {}{}",
report.pack_name.as_deref().unwrap_or("<未读成>"),
report.format.as_deref().unwrap_or("-"),
target.display(),
if args.target.is_none() {
"(缺省 = 新实例目录 <数据根>/instances/)"
} else {
""
}
));
if !stages.is_empty() {
human.push(format!("阶段:{}", stages.join(" → ")));
}
if let Some(binding) = binding {
// **M216 r2**:`--json` 里给**结构化成败**(r1 只印人读行,脚本看不到成败)。
if let Some(object) = data.as_object_mut() {
object.insert("instance_bound".to_string(), json!(binding.is_ok()));
}
human.push(match binding {
Ok(note) | Err(note) => note.clone(),
});
}
human.push(format!(
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
status_label(report),
count_action(report, FileAction::Installed),
count_action(report, FileAction::Replaced),
count_action(report, FileAction::SkippedIdentical),
count_action(report, FileAction::KeptUser),
report.conflicts.len(),
report.missing.len(),
));
if let Some(loader) = &report.loader {
human.push(format!(
"加载器:{} {}(版本 id {})",
loader.kind.label(),
loader.loader_version,
loader.version_id
));
if let Some(version_id) = &report.version_id {
if !ctx.game().is_version_installed(version_id) {
human.push(format!(
"提示:{version_id} 还没安装,启动前先跑 `%prog% install` 装它"
));
}
}
}
if let Some(memory) = report.max_memory_mb {
human.push(format!("内存:{memory} MB(来自 instance.cfg)"));
}
for component in &report.rejected_components {
human.push(format!(
"未支持的 component:{} {}({})",
component.uid,
component.version.as_deref().unwrap_or("-"),
component.message_key
));
}
for conflict in &report.conflicts {
human.push(format!(
"冲突:{}({},已{})",
conflict.path,
conflict.message_key,
match conflict.choice {
ConflictChoice::KeepUser => "保留你的文件",
ConflictChoice::Overwrite => "按包覆盖",
}
));
}
for missing in &report.missing {
human.push(format!(
"缺文件:{}({}:{})",
missing.display, missing.message_key, missing.detail
));
}
if let Some(registered) = &report.registered {
human.push(format!(
"已登记实例 {}({}){}",
registered.name,
registered.version_id,
if registered.created {
""
} else {
"·复用已有档案"
}
));
}
if let Some(error) = &report.error {
human.push(format!("错误:{}({})", error.message(), error.code()));
if let Some(hint) = error.hint() {
human.push(format!("提示:{hint}"));
}
human.push(rollback_line(report));
}
Outcome::new(Report { data, human }).with_exit_code(report.status.exit_code())
}
/// 状态的人读名。**失败要按「动没动过目标目录」分叉**(见 [`rollback_line`])。
fn status_label(report: &ImportReport) -> &'static str {
match rollback_stage(report) {
Some(RollbackStage::NotStarted) => "失败(未写入:目标目录没被动过)",
Some(RollbackStage::RolledBack) => "失败(已回滚)",
Some(RollbackStage::Incomplete) => "失败(回滚不完整)",
None => match report.status {
ImportStatus::Success => "成功",
// 取消**不是失败**:不说"失败",也不说"部分成功"(那会让人以为装了一半)。
// 但回滚不完整时也不许说"与导入前一致"——与失败路径同一套诚实口径。
ImportStatus::Cancelled => {
if report.rolled_back {
"已取消(目标目录与导入前一致)"
} else {
"已取消(回滚不完整,目标目录可能与导入前不同)"
}
}
_ => "部分成功",
},
}
}
/// 「这次失败到底动没动过目标目录」(三档)。
///
/// 与 `presenter` 的 `host::addon::pack::convert::failure_stage` **同义**——CLI 不依赖 presenter
/// 层(`unlucky-cli` 只依赖 core + agent),所以这套判据在这里各写一份。已登记 finding:
/// 更干净的做法是把三档挪进 `core::addon::import::ImportReport`(那儿有 `rollback` /
/// `rolled_back` 两个机器位),两处都改成读它。
///
/// 为什么要三档而不是一个 `rolled_back` 布尔:core 的 `rolled_back` 在「回滚完整完成」与
/// **「从未写过目标目录」**两种情形下都是 `true`。只按它出词,预检失败会被说成「已整包回滚:目标
/// 目录与导入前一致」——那句话在**一个字节都没写**时是假的(M161 复审的 P1 同族问题)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RollbackStage {
/// 预检/装配阶段就失败:目标目录一个字节都没写
NotStarted,
/// 写过,回滚**完整**完成
RolledBack,
/// 写过,回滚有失败项(目标目录可能与导入前不同)
Incomplete,
}
/// 三档判据(与 presenter 那份逐条对应)。
fn rollback_stage(report: &ImportReport) -> Option<RollbackStage> {
if report.status != ImportStatus::Failed {
return None;
}
Some(match &report.rollback {
Some(outcome) if outcome.is_complete() => RollbackStage::RolledBack,
Some(_) => RollbackStage::Incomplete,
None if report.rolled_back => RollbackStage::NotStarted,
None => RollbackStage::Incomplete,
})
}
/// 失败收尾那一段人读文案(**三档三句**,不许共用)。
fn rollback_line(report: &ImportReport) -> String {
match rollback_stage(report) {
Some(RollbackStage::NotStarted) => {
"预检未通过:目标目录一个字节都没写(不需要回滚)".to_string()
}
Some(RollbackStage::RolledBack) => "已整包回滚:目标目录与导入前一致".to_string(),
Some(RollbackStage::Incomplete) => match &report.rollback {
Some(rollback) => format!(
"回滚未完整:{} 项没回去(详见 --json 的 rollback.failed)",
rollback.failure_count()
),
None => "回滚状态不明:目标目录可能与导入前不同(详见 --json)".to_string(),
},
None => String::new(),
}
}
fn count_action(report: &ImportReport, action: FileAction) -> usize {
report
.files
.iter()
.filter(|file| file.action == action)
.count()
}
/// 与 `install` / `java` 共用同一套引擎装配(镜像 / 地区 / 健康度 / 内容缓存),
/// 参数来自设置:读数口径见 [`crate::download`]。
pub(crate) fn build_engine(ctx: &Ctx) -> Arc<DownloadEngine> {
crate::download::engine(ctx)
}
#[cfg(test)]
mod tests {
//! `pack import` 失败收尾的三档文案(M161 复审「不计入但该记」第一条:CLI 也曾把
//! 预检失败说成「已整包回滚」)。
//!
//! 抓得住:三档塌成两句(预检 → 已回滚);`is_complete()` 被丢掉;`status != Failed` 也出话。
use std::path::PathBuf;
use unlucky_core::addon::import::{ImportError, ImportReport, ImportStatus, RollbackOutcome};
use super::*;
fn failed() -> ImportReport {
let mut report = ImportReport::empty(PathBuf::from("C:/tmp/.minecraft"));
report.fail(ImportError::integrity("sha1 mismatch"));
report
}
/// 三档三句,**互不相同**(同族的谎就是这个 mission 要消灭的那类)。
#[test]
fn the_three_rollback_stages_get_three_different_sentences() {
// ① 预检没过:一个字节都没写。
let mut preflight = failed();
preflight.rolled_back = true;
assert_eq!(rollback_stage(&preflight), Some(RollbackStage::NotStarted));
let not_started = rollback_line(&preflight);
assert!(not_started.contains("没写"), "{not_started}");
assert!(
!not_started.contains("回滚:目标目录"),
"预检失败不许说「已回滚」:{not_started}"
);
assert_eq!(status_label(&preflight), "失败(未写入:目标目录没被动过)");
// ② 写过又完整回滚。
let mut rolled_back = failed();
rolled_back.rolled_back = true;
rolled_back.rollback = Some(RollbackOutcome {
attempted: 2,
failed: Vec::new(),
});
assert_eq!(
rollback_stage(&rolled_back),
Some(RollbackStage::RolledBack)
);
assert!(rollback_line(&rolled_back).contains("已整包回滚"));
assert_eq!(status_label(&rolled_back), "失败(已回滚)");
// ③ 回滚没做完。
let mut incomplete = failed();
incomplete.rolled_back = false;
incomplete.rollback = Some(RollbackOutcome {
attempted: 2,
failed: vec!["mods/a.jar".to_string()],
});
assert_eq!(rollback_stage(&incomplete), Some(RollbackStage::Incomplete));
assert!(rollback_line(&incomplete).contains("1 项没回去"));
assert_eq!(status_label(&incomplete), "失败(回滚不完整)");
// 三句必须互不相同(把任何两档并成一句,这里就红)。
let lines = [
rollback_line(&preflight),
rollback_line(&rolled_back),
rollback_line(&incomplete),
];
assert_eq!(
lines.iter().collect::<std::collections::HashSet<_>>().len(),
3,
"{lines:?}"
);
}
/// 不是致命失败 ⇒ 没有回滚那一段话(成功/部分成功不该出现「已回滚」)。
#[test]
fn a_non_fatal_report_says_nothing_about_rollback() {
let mut report = ImportReport::empty(PathBuf::from("C:/tmp"));
report.status = ImportStatus::Partial;
assert_eq!(rollback_stage(&report), None);
assert_eq!(rollback_line(&report), "");
assert_eq!(status_label(&report), "部分成功");
}
}
+322
View File
@@ -0,0 +1,322 @@
//! `sessions list` / `logs` / `diag`:读 M18 的会话索引与 JSONL 事件流。
//!
//! 数据源形状见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5:
//! - `<sessions_root>/game/index.jsonl`:一会话一行摘要(同 id 取最后一行,倒序);
//! - `<sessions_root>/game/<id>.jsonl`:事件流(`kind` + `payload`)。
//!
//! **M188 起 `sessions_root` = `<.ULL>/logs`**(旧家 `<.ULL>/data/agent/sessions` 只读兼容:
//! 两个索引合并、事件流先新根后旧家),`launcher.log` 就在同一层的 `logs/` 里。
//!
//! `diag` 的结论优先取会话里**落盘的那份**,老会话才把脱敏文本喂回 M15 的内嵌规则集
//! (M242,为什么与代价见 `sessions/diagnosis.rs` 的模块文档)。
mod diagnosis;
/// M242:`ull diag` 的结论来源(落盘那份优先)。拆文件守「单文件 ≤400 行」。
#[cfg(test)]
#[path = "sessions/m242_tests.rs"]
mod m242_tests;
use std::path::Path;
use serde_json::{json, Value};
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
use crate::cli::{DiagArgs, LogsArgs};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// `ull sessions list [--limit N]`。
///
/// # Errors
///
/// 索引文件读不了时返回用户错误(不存在 = 空列表)。
pub fn list(ctx: &Ctx, limit: usize) -> Result<Report, CliError> {
let index = ctx.session_index();
let entries = merged_entries(ctx)?;
let shown: Vec<&SessionIndexEntry> = entries.iter().take(limit).collect();
let rows: Vec<Value> = shown
.iter()
.map(|entry| {
json!({
"session_id": entry.session_id,
"instance_id": entry.instance_id,
"instance_name": entry.instance_name,
"game_version": entry.game_version,
"started_at": entry.started_at,
"ended_at": entry.ended_at,
"duration_ms": entry.duration_ms,
"exit_code": entry.exit_code,
"exit_type": entry.exit_type,
"silent_failure": entry.silent_failure,
"window_detected": entry.window_detected,
"crash_found": entry.crash_found,
"diagnosis_top": entry.diagnosis_top,
})
})
.collect();
let mut human = vec![format!(
"会话 {} 条(索引 {})",
shown.len(),
index.display()
)];
// M188:旧家的会话仍然列得出来,就得说清它们从哪来——否则用户会以为「新目录里怎么没有」。
let legacy = ctx.legacy_session_index();
if legacy.is_file() {
human.push(format!(
" 旧会话目录仍然只读兼容:{}(不搬不删)",
legacy.display()
));
}
human.push(format!(" 启动器日志:{}", ctx.logs_dir().display()));
if shown.is_empty() {
human.push(" (空)先用 `%prog% launch <实例>` 跑一局".to_string());
}
for entry in &shown {
human.push(format!(
" {} {} {} {} {}",
entry.session_id,
entry.instance_name,
entry.game_version,
entry.started_at,
exit_label(entry),
));
}
Ok(Report::new(json!({ "sessions": rows }), human))
}
/// `ull logs <sid> [--tail N]`:打印事件流末尾 N 条。
///
/// # Errors
///
/// 会话不存在/前缀有歧义、事件流读不了时返回用户错误。
pub fn logs(ctx: &Ctx, args: &LogsArgs) -> Result<Report, CliError> {
let entry = find_entry(ctx, &args.session_id)?;
let path = event_path(ctx, &entry);
let events = read_events(&path)?;
let start = events.len().saturating_sub(args.tail.max(1));
let shown = &events[start..];
// 脱敏一次、两条输出共用:`--json` 是机器消费路径,不该比人类路径拿到更多原文。
let shown: Vec<Value> = shown.iter().map(redact_value).collect();
let mut human = vec![format!(
"会话 {} · {} · 事件 {} 条(显示末尾 {})",
entry.session_id,
entry.instance_name,
events.len(),
shown.len()
)];
human.extend(shown.iter().map(render_event));
Ok(Report::new(
json!({
"session_id": entry.session_id,
"path": path.display().to_string(),
"total": events.len(),
"events": shown,
}),
human,
))
}
/// `ull diag <sid> [--tail N]`:给会话下诊断结论。
///
/// 结论来源**先是会话里落盘的 `diagnosis` 事件**(M242),没有它的老会话才在脱敏文本上重跑
/// ——为什么、代价如何见 `sessions/diagnosis.rs` 的模块文档。`--tail` 只影响重跑那条路。
///
/// `exit` 仍取自索引(launch 报的事实),不是 M15 的结论:换来源只换结论。
///
/// # Errors
///
/// 会话不存在、事件流读不了、结论序列化失败时返回错误。
pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result<Report, CliError> {
let entry = find_entry(ctx, &args.session_id)?;
let path = event_path(ctx, &entry);
let events = read_events(&path)?;
let found = diagnosis::conclusion(&entry, &events, args.tail)?;
// 脱敏一次、两条输出共用:`--json` 是机器消费路径,不该比人类路径拿到更多原文。
let human: Vec<String> = diagnosis::lines(&entry, &found)
.iter()
.map(|line| unlucky_agent::redact::redact_text(line))
.collect();
let payload = redact_value(&json!({
"session_id": entry.session_id,
"instance_id": entry.instance_id,
"game_version": entry.game_version,
"exit": {
"code": entry.exit_code,
"exit_type": entry.exit_type,
"silent_failure": entry.silent_failure,
"duration_ms": entry.duration_ms,
},
"conclusion_source": found.provenance.as_str(),
"diagnosis": found.record,
}));
Ok(Report::new(payload, human))
}
fn find_entry(ctx: &Ctx, key: &str) -> Result<SessionIndexEntry, CliError> {
let index = ctx.session_index();
let entries = merged_entries(ctx)?;
if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) {
return Ok(entry.clone());
}
let matches: Vec<&SessionIndexEntry> = entries
.iter()
.filter(|entry| entry.session_id.starts_with(key))
.collect();
match matches.as_slice() {
[only] => Ok((*only).clone()),
[] => Err(
CliError::user("session_not_found", format!("找不到会话 `{key}`"))
.with_hint(format!("索引文件:{}", index.display())),
),
_ => Err(CliError::user(
"session_ambiguous",
format!("`{key}` 匹配到 {} 个会话,请给更长的前缀", matches.len()),
)),
}
}
fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf {
// M188:新会话在 `logs/game/`,旧会话在 `data/agent/sessions/game/`(只读兼容)。
// 先看新根,再回落旧家;两处都没有就把新根路径交出去(错误信息据此说「该去哪找」)。
// M211/D2:`log_path` 已经**相对会话根**(`game/<id>.jsonl`),解析交给 core 里唯一的
// `session_index::event_file`——这里不许再自己拼 `game/`,否则又是一份第三真相。
let primary = session_index::event_file(&ctx.sessions_root(), &entry.log_path);
if primary.is_file() {
return primary;
}
let legacy = session_index::event_file(&ctx.legacy_sessions_root(), &entry.log_path);
if legacy.is_file() {
return legacy;
}
primary
}
/// 两个索引合并后的条目(新根 `logs/` 在前,旧家只读兼容在后;同 id 以新根为准)。
fn merged_entries(ctx: &Ctx) -> Result<Vec<SessionIndexEntry>, CliError> {
let mut indexes = vec![ctx.session_index()];
let legacy = ctx.legacy_session_index();
if legacy.is_file() {
indexes.push(legacy);
}
let mut merged: Vec<SessionIndexEntry> = Vec::new();
for index in indexes {
for entry in session_index::read_all(&index)
.map_err(|error| CliError::io("读会话索引", &index, &error))?
{
if merged
.iter()
.any(|item| item.session_id == entry.session_id)
{
continue;
}
merged.push(entry);
}
}
merged.sort_by(|left, right| right.started_at.cmp(&left.started_at));
Ok(merged)
}
fn read_events(path: &Path) -> Result<Vec<Value>, CliError> {
let text = std::fs::read_to_string(path)
.map_err(|error| CliError::io("读会话事件流", path, &error))?;
Ok(text
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.collect())
}
fn render_event(event: &Value) -> String {
let ts = event.get("ts").and_then(Value::as_str).unwrap_or("");
let kind = event.get("kind").and_then(Value::as_str).unwrap_or("?");
let detail = event
.pointer("/payload/line")
.and_then(Value::as_str)
.or_else(|| event.pointer("/payload/summary").and_then(Value::as_str))
.unwrap_or_default();
format!("[{ts}] {kind} {detail}")
}
/// 递归脱敏任意 JSON(所有字符串过 `redact_text`:主目录用户名段 + Bearer 令牌)。
fn redact_value(value: &Value) -> Value {
match value {
Value::String(text) => Value::String(unlucky_agent::redact::redact_text(text)),
Value::Array(items) => Value::Array(items.iter().map(redact_value).collect()),
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, child)| (key.clone(), redact_value(child)))
.collect(),
),
other => other.clone(),
}
}
fn exit_label(entry: &SessionIndexEntry) -> String {
let code = entry
.exit_code
.map_or_else(|| "无退出码".to_string(), |code| format!("退出码 {code}"));
let exit_type = entry
.exit_type
.clone()
.unwrap_or_else(|| "unknown".to_string());
let silent = if entry.silent_failure {
" · 静默失败"
} else {
""
};
format!("{code}/{exit_type}{silent}")
}
/// M211/D5:`ull launch` 也必须写 `<data>/logs/launcher.log`(与 GUI 同一份文件、同一行格式)。
///
/// 为什么这条用例在这个文件里:CLI 侧本 mission 只允许改 `commands/launch.rs` 与
/// `commands/sessions.rs`,而 `launch.rs` 已到 389 行、再加用例会破「文件 ≤400 行」——
/// 用例放在同一个 `<data>/logs` 层级的读取方(本文件打印「启动器日志:<dir>」)旁边。
///
/// 变异证明:删掉 `commands/launch.rs` 里的 `record_start(...)` ⇒ 本条红(文件根本不存在);
/// 把 `set_dir` 的入参换成别的目录 ⇒ 本条红。
#[cfg(test)]
mod tests {
/// 一个最小实例档案(`profiles.json`):只要能让 `launch` 走到「准备启动」这一段。
const PROFILES: &str = r#"{"schema_version":1,"profiles":[{"id":"11111111-2222-4333-8444-000000000001","name":"生存服","version_id":"1.20.1"}]}"#;
#[test]
fn a_launch_attempt_writes_the_launcher_log() {
let root = std::env::temp_dir().join(format!("ull-m211-cli-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join(".ULL/config")).expect("建配置目录");
std::fs::write(root.join(".ULL/config/profiles.json"), PROFILES).expect("写 profiles");
let args: Vec<String> = vec![
"launch".to_string(),
"生存服".to_string(),
"--data-dir".to_string(),
root.display().to_string(),
];
let (mut out, mut err) = (Vec::new(), Vec::new());
// 版本本体不存在 ⇒ 这条命令会失败,但「启动前」那一行必须已经落盘
// (事故现场最需要的正是「它打算用什么起的」)。
let _ = crate::run(&args, &mut out, &mut err);
let log = std::fs::read_to_string(root.join(".ULL/logs/launcher.log"))
.expect("`ull launch` 必须在 <data>/logs/launcher.log 里留下启动器日志");
assert!(
log.contains("[launch] instance=11111111-2222-4333-8444-000000000001"),
"{log}"
);
assert!(
log.contains("game_dir=") && log.contains("memory="),
"{log}"
);
std::fs::remove_dir_all(&root).ok();
}
}
@@ -0,0 +1,240 @@
//! `ull diag` 的结论来源:**先读会话里落盘的那份**,老会话才重跑规则(M242)。
//!
//! ## 为什么不能每次都在会话文本上重跑
//!
//! 会话 JSONL 是**写前脱敏**的(`launch::redact`,落盘即脱敏),而脱敏拿不准形状时是**有损**的:
//! `≥64` 的不透明串、带点的票据串都会被换成占位符(`<redacted:opaque>` / `<redacted:token>`)。
//! M240 收窄了这条规则——**路径形状**的长串现在整条保留,所以 M239 实测的那条
//! `org/spongepowered/…/FabricMixinTransformerProxy` 今天能活下来。但那是**白名单**:形状一变
//! (或又登记了别的 secret)就回到「抹掉」那一侧,而抹掉之后的重跑不是「少一点细节」,是
//! **换一条结论** —— `fabric.loader_0_12`(critical)降级成 `integrity.no_class_def_found`
//! (warning:把「Fabric Loader 版本不配套」说成「库没下全」)。
//!
//! M241 起写会话时已经在**原始行**上算过一次结论(`SessionWriter::finish` →
//! `diagnose::session::conclude`),落成 `diagnosis` 事件;那份结论自己还要再走一遍
//! `Redactor::scrub_json`,所以盘上只有脱敏后的结论,没有原文。M242 换的就是**证据**,
//! 不是判据:规则集的 pattern 一个字符都没动。
//!
//! ## 回落
//!
//! 没有 `diagnosis` 事件的老会话仍走原来的重跑(行为与 M242 之前完全一致):短字面量的规则
//! (`memory.out_of_memory` 之类)在脱敏文本上照样命中;只有「靠长串证据」那类规则会降级
//! —— 那是老数据本身的代价,输出里的「结论来源」一行会把它说清。
use serde_json::Value;
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
use unlucky_core::diagnose::loader::RuleLoader;
use unlucky_core::launch::session_index::SessionIndexEntry;
use crate::error::CliError;
use super::exit_label;
/// 结论从哪儿来(人类输出与 `--json` 都要说清,否则用户分不清「盘上的」与「刚算的」——
/// 这两者在长串证据类规则上给出的结论**可能不同**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provenance {
/// 会话里落盘的 `diagnosis` 事件(M241 起,写会话时在原始行上算的)。
Stored,
/// 老会话:在脱敏文本上重跑规则。
Rerun,
}
impl Provenance {
/// `--json` 里的值(`stored` / `rerun`)。
pub const fn as_str(self) -> &'static str {
match self {
Self::Stored => "stored",
Self::Rerun => "rerun",
}
}
/// 人类输出里的一行说明。
pub const fn label(self) -> &'static str {
match self {
Self::Stored => "会话落盘的 diagnosis 事件(写会话时在原始行上算的,已脱敏后落盘)",
Self::Rerun => "在脱敏文本上重跑规则(老会话没有落盘结论,靠长串证据的规则可能降级)",
}
}
}
/// 一次诊断:结论本体 + 出处。
#[derive(Debug, Clone)]
pub struct Conclusion {
/// `diagnosis` 记录(`DiagnosisRecord` 的 JSON),两条来源同形。
pub record: Value,
/// 从哪儿来。
pub provenance: Provenance,
/// 重跑时评估了多少条规则(落盘那份不记这个数,所以是 `Option`)。
pub rules_evaluated: Option<usize>,
}
/// 取这个会话的诊断结论:优先落盘的那份,老会话才重跑。
///
/// # Errors
///
/// 重跑路径上结论序列化失败时返回内部错误。
pub fn conclusion(
entry: &SessionIndexEntry,
events: &[Value],
tail: usize,
) -> Result<Conclusion, CliError> {
if let Some(record) = stored(events) {
return Ok(Conclusion {
record,
provenance: Provenance::Stored,
rules_evaluated: None,
});
}
let (record, rules_evaluated) = rerun(entry, events, tail)?;
Ok(Conclusion {
record,
provenance: Provenance::Rerun,
rules_evaluated: Some(rules_evaluated),
})
}
/// 事件流里最后一条 `diagnosis` 事件的 payload。
///
/// 正常只有一条(挂钩写的或收尾补的,二选一);真有多条取**最后一条**:收尾补的证据最新鲜。
/// 没有主因(`candidates` 空)的 payload 与「没结论」等价——`conclude` 也不会写它——此时
/// 回落到重跑,而不是拿一份空结论顶掉。
fn stored(events: &[Value]) -> Option<Value> {
let payload = events
.iter()
.rev()
.find(|event| event.get("kind").and_then(Value::as_str) == Some("diagnosis"))?
.get("payload")?;
let has_primary = payload
.get("candidates")
.and_then(Value::as_array)
.is_some_and(|items| !items.is_empty());
has_primary.then(|| payload.clone())
}
/// 老会话的回落:在脱敏文本上重跑规则,返回 `(结论记录, 评估条数)`。
fn rerun(
entry: &SessionIndexEntry,
events: &[Value],
tail: usize,
) -> Result<(Value, usize), CliError> {
let mut stdout_lines: Vec<&str> = Vec::new();
let mut stderr_lines: Vec<&str> = Vec::new();
let mut error_lines: u32 = 0;
for event in events {
let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else {
continue;
};
match event.get("kind").and_then(Value::as_str) {
Some("stdout") => stdout_lines.push(line),
Some("stderr") => {
stderr_lines.push(line);
if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") {
error_lines += 1;
}
}
_ => {}
}
}
let stdout_text = tail_join(&stdout_lines, tail);
let stderr_text = tail_join(&stderr_lines, tail);
let exit = ExitSignal::classify(
entry.exit_code,
Some(entry.duration_ms),
error_lines,
&stderr_text,
);
let mut input = DiagnosisInput::new().with_exit(exit);
if !stdout_text.is_empty() {
input.push_text(SignalOrigin::Stdout, stdout_text);
}
if !stderr_text.is_empty() {
input.push_text(SignalOrigin::Stderr, stderr_text);
}
let loader = RuleLoader::embedded();
let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec());
let report = diagnoser.analyze(&input);
let record = serde_json::to_value(report.to_record())
.map_err(|error| CliError::internal("diagnosis_serialize", error.to_string()))?;
Ok((record, report.rules_evaluated))
}
/// 人类可读的几行(未脱敏——调用方统一过一次 `redact_text`)。
pub fn lines(entry: &SessionIndexEntry, found: &Conclusion) -> Vec<String> {
let mut human = vec![
format!(
"会话 {} · {} · {} · {}",
entry.session_id,
entry.instance_name,
entry.game_version,
exit_label(entry)
),
format!("结论来源: {}", found.provenance.label()),
];
match found.record.get("candidates").and_then(Value::as_array) {
Some(items) if !items.is_empty() => {
let primary = &items[0];
if let Some(message) = text_at(primary, "message") {
human.push(format!("主因: {message}"));
}
if let Some(detail) = text_at(primary, "detail") {
human.push(format!("说明: {detail}"));
}
for evidence in evidence_of(primary, 3) {
human.push(format!("证据: {evidence}"));
}
}
_ => human.push("规则引擎没有得出确定结论(needs_more_info)".to_string()),
}
human.push(counts_line(&found.record, found.rules_evaluated));
human
}
/// `规则评估 N 条 · 结论 N 条 · 次生 N 条`(落盘那份没有「评估条数」,省掉第一段)。
fn counts_line(record: &Value, rules_evaluated: Option<usize>) -> String {
let count = |key: &str| {
record
.get(key)
.and_then(Value::as_array)
.map_or(0, Vec::len)
};
let mut line = String::new();
if let Some(evaluated) = rules_evaluated {
line.push_str(&format!("规则评估 {evaluated} 条 · "));
}
line.push_str(&format!(
"结论 {} 条 · 次生 {} 条",
count("candidates"),
count("secondary")
));
line
}
fn text_at<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
value.get(key).and_then(Value::as_str)
}
fn evidence_of(candidate: &Value, limit: usize) -> Vec<String> {
candidate
.get("evidence")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.take(limit)
.map(str::to_string)
.collect()
}
/// 取末尾 `tail` 行并拼成文本(与重跑路径的老行为逐字一致)。
fn tail_join(lines: &[&str], tail: usize) -> String {
let start = lines.len().saturating_sub(tail.max(1));
let mut text = lines[start..].join("\n");
if !text.is_empty() {
text.push('\n');
}
text
}
@@ -0,0 +1,221 @@
//! M242:`ull diag <sid>` 的结论来源。两条端到端用例:**真 `SessionWriter` 写盘 → 真 CLI 入口**。
//!
//! 判据是「命中哪条规则」而不是「有没有结论」:脱敏有损,重跑出来的结论会**换一条**规则
//! (`fabric.loader_0_12` → `integrity.no_class_def_found`)。
//!
//! ## 夹具为什么把类路径登记成 secret
//!
//! M240 把「`≥64` 不透明串」这条收窄了:**路径形状**的长串(被 `/` 切开且每段都是词形名字)
//! 现在整条保留,所以 `SessionWriter` 落盘时这条证据行**今天不会被抹**。要复现「读回脱敏文本
//! 就丢了证据」这个世界(M239 实测的那份老落盘形状,也是脱敏规则以后再收紧时的形状),夹具
//! 把类路径登记成 secret 交给 `Redactor`:写盘前它被抹成 `<redacted:token>`,而结论是
//! 在**原始行**上算的 —— 与用户盘上真实存在的那类会话同样失真,足以分辨两条路。
//!
//! 变异证明(改完已还原):
//! - 把 [`super::diagnosis::conclusion`] 里的 `stored(...)` 分支删掉(等于每次都重跑)⇒
//! `diag_reads_the_stored_conclusion...` 红(`candidates[0].id` 变成 `integrity.no_class_def_found`)。
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use unlucky_core::launch::exit_analysis::{self, ExitSignals};
use unlucky_core::launch::pump::{LogLine, Stream};
use unlucky_core::launch::redact::Redactor;
use unlucky_core::launch::session::{EndReason, SessionMeta, SessionWriter};
use unlucky_core::launch::session_index;
use crate::cli::Globals;
use crate::context::Ctx;
/// `fabric.loader_0_12`(critical / priority 80)唯一的证据行(`rules.d/fabric.yaml`)里的类路径。
const CLASS_PATH: &str = "org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy";
/// 那条完整的证据行。
fn evidence_line() -> String {
format!("java.lang.NoClassDefFoundError: {CLASS_PATH}")
}
/// 一个临时数据根(`Drop` 时清掉,与 `crates/cli/tests/support` 同口径)。
struct TempRoot(PathBuf);
impl TempRoot {
fn new(tag: &str) -> Self {
let path = std::env::temp_dir().join(format!("ull-m242-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时数据根");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 与生产同一条解析(`--data-dir` → `Ctx`):会话根不在测试里手拼,避免「夹具说另一种形状」。
fn ctx_for(root: &Path) -> Ctx {
let globals = Globals {
data_dir: Some(root.to_path_buf()),
..Globals::default()
};
Ctx::resolve_in(&globals, None).expect("解析数据根")
}
/// 跑一次真 CLI 命令(`crate::run` = 解析 + `Ctx` + 命令实现),返回 `--json` 载荷的 `data`。
fn run_diag_json(root: &Path, session_id: &str) -> Value {
let args: Vec<String> = vec![
"diag".to_string(),
session_id.to_string(),
"--json".to_string(),
"--data-dir".to_string(),
root.display().to_string(),
];
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = crate::run(&args, &mut out, &mut err);
let stdout = String::from_utf8_lossy(&out).into_owned();
let stderr = String::from_utf8_lossy(&err).into_owned();
assert_eq!(code, 0, "`ull diag` 应成功:{stderr}\n{stdout}");
let value: Value = serde_json::from_str(&stdout).unwrap_or_else(|error| {
panic!("`--json` 输出不是 JSON:{error}\n{stdout}");
});
value["data"].clone()
}
/// 一局真会话:`SessionWriter` 写盘(写前脱敏)→ `finish` 在原始行上补结论 → 索引 + JSONL。
fn write_real_session(root: &Path) -> String {
let session_id = "20261102T090000-m242abc";
let mut writer = SessionWriter::create_with_id(
&ctx_for(root).sessions_root(),
session_id,
SessionMeta::new("inst-1", "1.20.1 Fabric", "1.20.1"),
// 登记成 secret ⇒ 盘上那条证据行的类路径被抹掉(见模块文档:为什么要人为造这份失真)。
Redactor::new().with_secret(CLASS_PATH),
)
.expect("建会话");
writer.session_start().expect("session_start");
let lines = vec![
LogLine::new(Stream::Stderr, "Failed to start Fabric loader"),
LogLine::new(Stream::Stderr, evidence_line()),
LogLine::new(Stream::Stdout, "[main/INFO]: Loading Minecraft 1.20.1"),
];
for line in &lines {
writer.line(line).expect("写行");
}
let analysis = exit_analysis::analyze(&ExitSignals::new(1, 40_000, &lines));
writer.exit(&analysis).expect("exit");
writer.finish(EndReason::Exit).expect("finish");
session_id.to_string()
}
/// 落盘的会话文件(诊断事件也在这份里)。
fn event_file(root: &Path, session_id: &str) -> PathBuf {
session_index::event_file(
&ctx_for(root).sessions_root(),
&session_index::log_path_for(session_id),
)
}
/// 主判据:结论是**盘上那份**。只有读到 `diagnosis` 事件才能命中 `fabric.loader_0_12`——
/// 在脱敏文本上重跑永远命中不了它(证据行已被抹成占位符)。
#[test]
fn diag_reads_the_stored_conclusion_instead_of_rerunning_redacted_text() {
let temp = TempRoot::new("diag-stored");
let root = temp.path();
let session_id = write_real_session(root);
let data = run_diag_json(root, &session_id);
assert_eq!(data["session_id"], session_id.as_str());
assert_eq!(
data["conclusion_source"], "stored",
"有 diagnosis 事件时不得重跑:{data}"
);
assert_eq!(
data["diagnosis"]["candidates"][0]["id"], "fabric.loader_0_12",
"落盘的结论必须原样读出(重跑会降级成 integrity.no_class_def_found):{data}"
);
assert_eq!(data["diagnosis"]["needs_more_info"], false);
// 盘上那份**真的**丢了证据(不然这条用例证明不了「重跑会失真」),而修 bug ≠ 把原文写进盘。
let disk = std::fs::read_to_string(event_file(root, &session_id)).expect("读会话文件");
assert!(
disk.contains("<redacted:token>"),
"夹具前提:盘上的证据行必须已被抹掉:{disk}"
);
assert!(!disk.contains("FabricMixinTransformerProxy"), "{disk}");
// 人类路径也要说清结论从哪来(否则「盘上的」与「刚算的」分不开)。
let args: Vec<String> = vec![
"diag".to_string(),
session_id.clone(),
"--data-dir".to_string(),
root.display().to_string(),
];
let (mut out, mut err) = (Vec::new(), Vec::new());
assert_eq!(crate::run(&args, &mut out, &mut err), 0);
let human = String::from_utf8_lossy(&out);
assert!(
human.contains("结论来源: 会话落盘的 diagnosis 事件"),
"{human}"
);
assert!(human.contains("主因: "), "{human}");
}
/// 老会话兼容:没有 `diagnosis` 事件(M241 之前写的 JSONL,且还在旧家)→ 回落重跑仍然可用。
///
/// 与上一条的差别只在**证据**:这里盘上只有脱敏文本,所以用的是短字面量规则
/// `memory.out_of_memory`(脱敏抹不掉 `java.lang.OutOfMemoryError`)。长串证据那类规则
/// 在老会话上只能降级,这是老数据本身的代价——`conclusion_source` 会标成 `rerun`。
#[test]
fn legacy_session_without_a_stored_conclusion_falls_back_to_rerunning() {
let temp = TempRoot::new("diag-legacy");
let root = temp.path();
let session_id = "20261030T101500-legacy";
let log_path = session_index::log_path_for(session_id);
// M188 之前的生产落点(`data/agent/sessions`,只读兼容):老会话的「老」还多一层。
let legacy_root = ctx_for(root).legacy_sessions_root();
let index = session_index::index_path(&legacy_root);
std::fs::create_dir_all(index.parent().expect("索引父目录")).expect("建目录");
let entry = json!({
"v": 1,
"session_id": session_id,
"instance_id": "inst-1",
"instance_name": "1.20.1",
"game_version": "1.20.1",
"started_at": "2026-10-30T10:15:00Z",
"ended_at": "2026-10-30T10:15:06Z",
"duration_ms": 5000,
"exit_code": 1,
"exit_type": "application_error",
"silent_failure": false,
"window_detected": false,
"event_count": 2,
"log_path": log_path,
"crash_found": false,
"diagnosis_top": null
});
std::fs::write(&index, format!("{entry}\n")).expect("写索引");
let file = session_index::event_file(&legacy_root, &log_path);
std::fs::write(
&file,
concat!(
"{\"v\":1,\"seq\":0,\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.OutOfMemoryError: Java heap space\",\"level\":\"error\"}}\n",
"{\"v\":1,\"seq\":1,\"kind\":\"session_end\",\"payload\":{\"reason\":\"exit\"}}\n"
),
)
.expect("写老会话事件流");
let data = run_diag_json(root, session_id);
assert_eq!(
data["conclusion_source"], "rerun",
"没有 diagnosis 事件时必须回落重跑:{data}"
);
assert_eq!(
data["diagnosis"]["candidates"][0]["id"], "memory.out_of_memory",
"回落路径仍要给出结论:{data}"
);
}
+331
View File
@@ -0,0 +1,331 @@
//! 实例 → [`LaunchRequest`] 的装配:launch 命令与 `--dry-run` 共用这一段。
//!
//! 全部走 core 的既有公共面(`game::resolve` / `arguments.expand` / `GameLayout` /
//! `launch::adapter::build_request`),CLI 不复制任何继承/参数展开逻辑。
use std::path::PathBuf;
use crate::context::Ctx;
use crate::error::CliError;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::{self, ProfileId, VersionJson};
use unlucky_core::launch::adapter::{
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
};
use unlucky_core::launch::request::{AuthPlaceholders, LaunchRequest, MemoryOptions};
/// 装配输入(来自命令行 + 实例档案)。
#[derive(Debug, Clone, Default)]
pub struct PlanInput {
/// 版本 id(实例绑定的 `versions/<id>/`)。
pub version_id: String,
/// 实例 id(写进会话索引;无档案时为空)。
pub instance_id: String,
/// 实例显示名。
pub instance_name: String,
/// 已派生的运行目录(D20 隔离覆盖链在 `super::launch` 求值完,这里只消费)。
pub game_dir: Option<PathBuf>,
/// Java 可执行文件覆盖。
pub java: Option<PathBuf>,
/// 离线账户名(无账户时生效;有账户时身份由 `auth` 决定)。
pub username: Option<String>,
/// 已解析好的账户占位(`None` = 退回 `username` 的离线占位)。
pub auth: Option<AuthPlaceholders>,
/// 最大堆 MB。
pub max_memory: Option<u32>,
}
/// 装配结果。
#[derive(Debug, Clone)]
pub struct Plan {
/// 实际使用的版本 id。
pub version_id: String,
/// 装配好的启动请求。
pub request: LaunchRequest,
/// 非致命告警(缺主类/缺资源索引等)。
pub warnings: Vec<String>,
}
/// 装配一个 [`LaunchRequest`]。
///
/// # Errors
///
/// 版本 JSON 缺失/非法、继承链断裂(缺父版本/成环)时返回用户错误。
pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
let game = ctx.game();
let version_id = input.version_id.clone();
let manifest_path = game.version_json(&version_id);
let manifest = read_version_json(&manifest_path)?;
let source = |id: &str| -> Option<VersionJson> {
let path = game.version_json(id);
read_version_json(&path).ok()
};
let resolved = game::resolve(&source, &manifest).map_err(|error| {
CliError::user("inherit", format!("解析继承链失败:{error}"))
.with_hint(format!("检查 {}", manifest_path.display()))
})?;
let environment = Environment::host();
let expanded = resolved
.arguments
.as_ref()
.map(|arguments| arguments.expand(&environment))
.unwrap_or_default();
let mut libraries: Vec<LibraryInput> = Vec::new();
for library in resolved.libraries.as_deref().unwrap_or_default() {
if !library.applies(&environment) {
continue;
}
// 1.19+ 的 natives 是独立库条目(name 带分类器),必须进 classpath;
// ≤1.18 的 `natives` 表条目归 natives::collect,不进 classpath。
// Linux arm64:独立条目改写成 natives-linux-arm64(路径与展示名一起改)。
let arm64 = library.linux_arm64_native_name(&environment);
if arm64.is_none() && library.native_classifier(&environment).is_some() {
continue;
}
let (coordinate, name) = match arm64 {
Some(rewritten) => {
let Ok(coordinate) =
unlucky_core::game::library::MavenCoordinate::parse(&rewritten)
else {
continue;
};
(coordinate, rewritten)
}
None => {
let Ok(coordinate) = library.coordinate() else {
continue;
};
(coordinate, library.name.clone())
}
};
let path = if library.is_local() {
game.version_library(&version_id, &coordinate)
} else {
game.library(&coordinate)
};
libraries.push(LibraryInput::new(path, name));
}
// natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。
let natives = unlucky_core::launch::natives::collect(
&game,
&version_id,
resolved.libraries.as_deref().unwrap_or_default(),
&environment,
);
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
let primary_jar = game.version_jar(&jar_id);
// 运行目录:调用方(`ull launch`)已按隔离覆盖链派生;`None` 兜底到游戏根
// (库/其它消费者直接构造 `PlanInput` 时保持旧行为)。
let game_dir = input
.game_dir
.clone()
.unwrap_or_else(|| ctx.data.game_home.clone());
let assets_root = game.assets_dir();
let assets_index = resolved
.assets
.clone()
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
// 同 presenter:装配门限按**继承链的 MC 根版本**判(loader 实例的 version_id 带加载器后缀,
// 周快照的 6 字符形态判据在那儿失效,`--graphicsBackend` 会被静默跳过)。
let root_version = super::instance_launch::root_version_id(&game, &manifest);
let snapshot = SnapshotInput {
version_id: &version_id,
version_name: Some(&input.instance_name),
version_type: None,
game_version: Some(root_version.as_str()),
main_class: resolved.main_class.as_deref(),
jvm_arguments: &expanded.jvm,
game_arguments: &expanded.game,
legacy_arguments: resolved.minecraft_arguments.as_deref(),
libraries: &libraries,
natives: &natives,
primary_jar,
game_dir,
assets_index: assets_index.as_deref(),
natives_dir: game.natives_dir(&version_id),
library_dir: game.libraries_dir(),
assets_root: assets_root.clone(),
game_assets: assets_root,
};
// 实例级启动参数的三级回落(实例 → 全局 → 默认)。临时启动参数(`--max-memory` /
// `--java`)在 D20 的覆盖链里位于实例之上,所以这里先把它们叠上去再落位。
let mut effective = super::instance_launch::resolved(ctx, &input.instance_id);
if let Some(mb) = input.max_memory {
effective.memory_mb = mb;
}
if let Some(path) = &input.java {
effective.java_path = Some(path.clone());
}
let options = AdapterOptions {
java: super::java::selection(effective.java_path.as_deref()),
memory: MemoryOptions {
min_mb: None,
max_mb: Some(effective.memory_mb),
},
auth: input.auth.clone().unwrap_or_else(|| AuthPlaceholders {
player_name: input
.username
.clone()
.unwrap_or_else(|| "Player".to_string()),
uuid: ProfileId::generate().as_str().replace('-', ""),
user_type: "legacy".to_string(),
user_properties: "{}".to_string(),
..AuthPlaceholders::default()
}),
launcher_name: super::meta::LAUNCHER_NAME.to_string(),
launcher_version: env!("CARGO_PKG_VERSION").to_string(),
language: "zh-CN".to_string(),
..AdapterOptions::strict()
};
let output = adapter::build_request(&snapshot, options);
let warnings = output.warnings.iter().map(describe_warning).collect();
let mut request = output.request;
// 会话索引要用实例身份(adapter 默认拿版本 id/名)。
if !input.instance_id.is_empty() {
request.instance.id = input.instance_id.clone();
}
request.instance.name = input.instance_name.clone();
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
unlucky_core::launch::overrides::apply(&mut request, &effective);
Ok(Plan {
version_id,
request,
warnings,
})
}
fn read_version_json(path: &std::path::Path) -> Result<VersionJson, CliError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(CliError::user(
"version_missing",
format!("版本 JSON 不存在:{}", path.display()),
)
.with_hint("先准备好版本本体文件(下载装配属 P1 后续 mission)"));
}
Err(error) => return Err(CliError::io("读版本 JSON", path, &error)),
};
VersionJson::from_json_str(&text)
.map_err(|error| CliError::user("version_parse", format!("{}:{error}", path.display())))
}
/// 适配层告警 → 人读一行(`AdapterWarning` 没有 Display,这里集中翻译)。
fn describe_warning(warning: &AdapterWarning) -> String {
match warning {
AdapterWarning::MissingLibrary { name, path } => {
format!("缺少库文件 {name}({})", path.display())
}
AdapterWarning::MissingGameArguments => {
"版本 JSON 既没有 arguments.game 也没有 minecraftArguments".to_string()
}
AdapterWarning::MissingAssetsIndex => {
"版本 JSON 没有资源索引 id(${assets_index_name} 为空)".to_string()
}
AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(),
AdapterWarning::MissingNativeJar { name, path } => {
format!("缺少 natives 构件 {name}({})", path.display())
}
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
/// 一次性临时数据根(`--data-dir` 的等价物:custom 模式 → `<root>/.ULL` + `<root>/.minecraft`)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-cli-snapshot-{tag}-{}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn ctx_at(dir: &TempDir) -> Ctx {
let globals = crate::cli::Globals {
data_dir: Some(dir.path().to_path_buf()),
..crate::cli::Globals::default()
};
Ctx::resolve(&globals).expect("解析数据根")
}
fn write_version(ctx: &Ctx, id: &str, json: &str) {
let path = ctx.game().version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, json).expect("写版本 JSON");
}
/// **周快照 + 加载器实例**(M157 r3 复审 P2-1):`prepare` 交给装配的游戏版本是
/// 继承链根的 `26w14a`,不是 `26w14a-fabric-0.16.5`。
///
/// 抓得住:`SnapshotInput.game_version` 回退成 `None`(装配用带后缀的 id 判门限,
/// `--graphicsBackend` 会被静默跳过,而 CLI 自己的门槛显示「可下发」)。
/// 抓不住:完整 argv(`crates/core/src/launch/overrides/tests.rs` 的周快照用例)。
#[test]
fn weekly_loader_instance_is_assembled_against_the_mc_root_version() {
let dir = TempDir::new("weekly");
let ctx = ctx_at(&dir);
write_version(
&ctx,
"26w14a",
r#"{"id":"26w14a","mainClass":"net.minecraft.client.main.Main",
"libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#,
);
write_version(
&ctx,
"26w14a-fabric-0.16.5",
r#"{"id":"26w14a-fabric-0.16.5","inheritsFrom":"26w14a",
"mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient"}"#,
);
let plan = prepare(
&ctx,
&PlanInput {
version_id: "26w14a-fabric-0.16.5".to_string(),
instance_name: "周快照 Fabric".to_string(),
..PlanInput::default()
},
)
.expect("装配应成功");
assert_eq!(
plan.request.instance.version.raw(),
"26w14a",
"装配的游戏版本必须是继承链的 MC 根版本(CLI 门槛与拼装必须同一口径)"
);
assert!(plan.request.instance.version.is_weekly_snapshot());
assert!(plan.request.manifest.has_vulkan_backend);
}
}
+286
View File
@@ -0,0 +1,286 @@
//! `ull update check` / `ull update apply`(M159):**复用 core 的同一内核**。
//!
//! 两条命令都只做「解析参数 → 造一个 [`Job`] → 调 `check` / `apply` → 排版输出」,
//! 版本比较、渠道准入、清单校验、长度/SHA256 双检、原子替换全在 `core::update`
//! (GUI 走的是同一个 `Job`,见 `crates/presenter/src/host/update.rs`)。
//!
//! 三条不许折叠的边界:
//!
//! | 情形 | 输出 |
//! |---|---|
//! | 已是最新 | `status=up_to_date`(退出码 0) |
//! | 有可装的更新 | `status=available`(退出码 0;`apply` 才动文件) |
//! | 检查失败 | **错误**(退出码 1,`code=update_check_failed`)——绝不说成「已是最新」 |
//!
//! 还有一条单独报的:`missing_artifact`(有更高版本但里面没有本平台的产物)。那是发布侧的漏项,
//! 同样不许说成「已是最新」。
//!
//! `apply` 默认替换**本进程的 exe**(`Job::for_process`);`--dest` 可以钉到别的路径(取证用)。
//!
//! # 测试通道(M169)
//!
//! 想在没有稳定 Release 的情况下整环验一遍,得用**专门编出来的验收二进制**:
//!
//! ```text
//! cargo build --release --features update-test-source # 官方发布构建不带这个 feature
//! ULL_UPDATE_DIR=<root> ULL_UPDATE_TEST_SOURCE=1 ./ull update check
//! ```
//!
//! 三档全给才生效(feature 默认关 = 官方二进制里没有这条路径;带 feature 时还要两档环境变量,
//! 判据见 `core::update::job`)。这时 `--json` 的 `source` 是 `file:<root>`、`test_source` 是
//! `true`,人读输出另有一行点名。它不是第二个更新源:没有设置键、也没有命令行参数。
//!
//! ⚠ **验收时必须给 `--dest`**:不给就是替换本进程的 exe。自动化用例里的「本进程」是测试二进制,
//! 去掉 `--dest` 会把它真改名换掉(`crates/cli/tests/update_e2e_test.rs` 的注释写了这件事)。
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::json;
use unlucky_core::download::DownloadEngine;
use unlucky_core::update::{default_source, Applied, CheckOutcome, Job, UpdateError, Version};
use crate::cli::{UpdateApplyArgs, UpdateCheckArgs};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{Outcome, Report};
/// D50:先只发 stable。
const CHANNEL: unlucky_core::update::Channel = unlucky_core::update::Channel::Stable;
/// 本进程版本(与 `ull --version` 同源)。
fn current_version() -> Result<Version, CliError> {
let text = env!("CARGO_PKG_VERSION");
Version::parse(text)
.map_err(|error| CliError::internal("bad_version", format!("编译期版本号不合法:{error}")))
}
/// 自更新的下载引擎:与 `install` / `java` / `pack` 同一条装配(参数来自
/// [`crate::download`],设置读数与落点在那边)。
///
/// 自更新只下**一个** artifact(`update::github` 的 `fetch_artifact` → 单次 `engine.download`),
/// 所以并发的差别在这条路径上不可观测;真正随设置变的是**每源重试次数**与**gh-proxy 前缀**——
/// 那正是「设置说真话」的两维。单独留这个接缝是为了让用例能逐命令打点(变异时只有它红)。
pub(crate) fn engine(ctx: &Ctx) -> Arc<DownloadEngine> {
super::install::build_engine(ctx)
}
/// 造一个内核 job(源 = GitHub / `ULL_UPDATE_DIR`,暂存 = 数据根缓存,目标 = 本进程 exe)。
fn job(ctx: &Ctx, dest: Option<PathBuf>) -> Job {
let engine = engine(ctx);
let job = Job::for_process(default_source(engine), &ctx.layout().cache());
match dest {
Some(dest) => job.with_dest(dest),
None => job,
}
}
/// 失败 → 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 {
// 签名类错误单独给一句:它们**不可重试**,而且绝不能靠「换个源/跳过校验」绕过——绕过一次
// 就等于把信任根拆了(见 `core::update::signature` 的文件头)。
let hint = if error.kind.is_signature() {
concat!(
"清单签名没通过,重试没有意义:signature_missing = 发布侧漏了签名;",
"signature_unknown_key / keyring_empty = 本构建的信任表里没有这把钥(升级启动器或等发布侧换钥);",
"signature_invalid = 签名之后清单被改过。别绕过校验,等发布侧修"
)
} else if error.is_retryable() {
"网络/占用类失败,过一会儿重试一次通常就好"
} else {
"这不是瞬时失败:清单或版本号本身有问题,等发布侧修"
};
CliError::user(
code,
format!("{action}失败({}):{error}", error.kind.as_str()),
)
.with_detail(json!({
"kind": error.kind.as_str(),
"retryable": error.is_retryable(),
}))
.with_hint(hint)
}
/// 这个源是不是测试通道(判据派生自**实际用了哪个源**,不是回读环境变量:
/// `DirSource` 的 id 是 `file:<root>`,GitHub 源是 `github:<owner>/<repo>`)。
fn is_test_source(source_id: &str) -> bool {
source_id.starts_with("file:")
}
/// 测试通道的点名行(只在真走了本地目录源时出现)。文案里点明**这是验收构建**:官方发布二进制
/// 没有这条路径(`update-test-source` 默认关),所以出现这一行就说明手上这份不是官方产物。
fn test_channel_note(source: &str) -> String {
format!(
"测试通道生效:源 = {source}(本地目录,不是 GitHub Releases;本二进制带 \
`update-test-source` feature 编出来,官方发布构建不含这条路径)"
)
}
/// `ull update check`:只查、不动任何文件。
///
/// # Errors
///
/// 清单取不到 / 清单不合法 / 渠道不符 → 用户错误(退出码 1)。
pub fn check(ctx: &Ctx, _args: &UpdateCheckArgs) -> Result<Outcome, CliError> {
let current = current_version()?;
let job = job(ctx, None);
let outcome = job
.check(&current, CHANNEL)
.map_err(|error| failure("update_check_failed", "检查更新", &error))?;
Ok(Outcome::new(report_of(&current, job.source_id(), &outcome)))
}
/// `ull update apply`:复查 → 下载 → 双检 → 替换(失败自动清理/回滚)。
///
/// # Errors
///
/// 复查、取包、校验、替换任一步失败 → 用户错误(退出码 1)。
pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
let current = current_version()?;
let job = job(ctx, args.dest.clone());
let dest = job.dest().display().to_string();
match job
.apply(&current, CHANNEL)
.map_err(|error| failure("update_apply_failed", "应用更新", &error))?
{
Applied::Installed { receipt, version } => {
let backup = receipt
.backup
.as_ref()
.map(|path| path.display().to_string());
let source = job.source_id();
let test_source = is_test_source(source);
let mut human = vec![
format!("已下载并校验 v{version}({} 字节)", receipt.bytes),
format!("已替换:{dest}"),
];
human.push(match &backup {
Some(backup) => format!("旧版本备份:{backup}"),
None => "原本没有旧文件(首次落盘)".to_string(),
});
human.push("下次启动生效(正在运行的进程仍是旧版本)。".to_string());
if test_source {
human.push(test_channel_note(source));
}
Ok(Outcome::new(Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"test_source": test_source,
"status": "installed",
"version": version,
"path": dest,
"bytes": receipt.bytes,
"backup": backup,
}),
human,
)))
}
Applied::Nothing(outcome) => {
let report = report_of(&current, job.source_id(), &outcome);
Ok(Outcome::new(Report::new(
json!({ "status": "nothing_to_apply", "check": report.data }),
{
let mut human =
vec!["复查之后没有可应用的更新(已经不是最新版本冲突)".to_string()];
human.extend(report.human);
human
},
)))
}
}
}
/// 把检查结论排成「人读几行 + `--json` 一份」。
///
/// `test_source` 逐条写进 JSON(不只在高亮时写):脚本据此判断「这一份结论是不是本地夹具给的」,
/// 而判据来自 `source`(实际用了哪个源),所以不会出现「环境变量说关了、输出说开着」。
fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report {
let test_source = is_test_source(source);
let base = |status: &str| {
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"test_source": test_source,
"status": status,
})
};
let mut report = match outcome {
CheckOutcome::UpToDate { latest, .. } => {
let latest = latest.as_ref().map(ToString::to_string);
let mut data = base("up_to_date");
data["latest"] = json!(latest);
Report::new(
data,
vec![match &latest {
Some(latest) => format!("已是最新:v{current}(渠道 {latest} 内最高)"),
None => format!("已是最新:v{current}(该渠道还没有任何发布)"),
}],
)
}
CheckOutcome::Available(candidate) => {
let version = candidate.release.version.to_string();
let forced = candidate.urgency == unlucky_core::update::Urgency::Forced;
let notes = candidate.release.notes.clone().unwrap_or_default();
let mut human = vec![format!(
"发现新版本 v{version}(当前 v{current},{} 字节{})",
candidate.artifact.size,
if forced { ",强制更新" } else { "" }
)];
if !notes.trim().is_empty() {
human.push(format!("说明:{}", notes.trim()));
}
human.push("用 `%prog% update apply` 下载并应用(下次启动生效)".to_string());
Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"test_source": test_source,
"status": "available",
"version": version,
"size": candidate.artifact.size,
"forced": forced,
"notes": notes,
"target": candidate.artifact.target.as_str(),
}),
human,
)
}
CheckOutcome::MissingArtifact { release } => {
let version = release.version.to_string();
Report::new(
json!({
"current": current.to_string(),
"channel": CHANNEL.as_str(),
"source": source,
"test_source": test_source,
"status": "missing_artifact",
"version": version,
"target": unlucky_core::update::Target::current().as_str(),
}),
vec![format!(
"v{version} 已发布,但没有为本平台({})提供产物——这是发布侧漏项,不是「已是最新」",
unlucky_core::update::Target::current().as_str()
)],
)
}
};
if test_source {
report.human.push(test_channel_note(source));
}
report
}
+256
View File
@@ -0,0 +1,256 @@
//! 数据根解析与目录视图。
//!
//! 三模式(D6 / `04-data-and-config.md` §1)全部交给 `core::platform::data_dir`:
//! - `--data-dir <dir>` → custom 模式(`<dir>/.ULL` + `<dir>/.minecraft`);
//! - 否则先按便携目录的 `launcher.json` 读 `core.data_mode` / `core.data_custom_root` /
//! `core.launcher_home` / `core.game_home`,再交 [`Resolver`] 解析(含 `redirect.json` 跟随)。
//!
//! CLI 不自己拼路径:目录形状的真相在 `core::platform::layout`。
//!
//! `ull config get/set` 的落点也从这里出:[`Ctx::setting_path_for`] 让决定数据根的
//! `core.*` 键落在**便携目录那份** `launcher.json`(与解析同源),其余键落解析后的数据根。
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver};
use unlucky_core::platform::layout::{
LauncherLayout, CONFIG_DIR, LAUNCHER_CONFIG_FILE, LAUNCHER_HOME_DIR,
};
use unlucky_core::platform::paths::Platform;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::Globals;
use crate::error::CliError;
use crate::os;
/// 决定数据根本身的配置键(`request_from_portable_config` 读的就是它们)。
///
/// `ull config get/set` 对这几个键必须读写**同一份文件**(便携目录那份),否则会出现
/// 「`config get core.data_mode` 说 portable、实际按 standard 跑」这种两套真相
/// (finding 20261030-worker-java2-improve-ull-config-get-set-launcher-json)。
pub const DATA_ROOT_KEYS: [&str; 4] = [
"core.data_mode",
"core.data_custom_root",
"core.launcher_home",
"core.game_home",
];
/// 该键是否决定数据根。
#[must_use]
pub fn is_data_root_key(key: &str) -> bool {
DATA_ROOT_KEYS.contains(&key)
}
/// 便携目录那份 `launcher.json` 的路径(`<exe 目录>/.ULL/config/launcher.json`)。
#[must_use]
pub fn portable_config_path(exe_dir: &Path) -> PathBuf {
exe_dir
.join(LAUNCHER_HOME_DIR)
.join(CONFIG_DIR)
.join(LAUNCHER_CONFIG_FILE)
}
/// 一次命令执行的上下文:数据根 + 平台 + 系统摘要。
#[derive(Debug, Clone)]
pub struct Ctx {
/// 解析后的数据目录(launcher home / game home)。
pub data: ResolvedData,
/// 宿主平台族。
pub platform: Platform,
/// 系统信息与支持等级(D19 探测 / D21 Win10+ 单基线)。
pub os: unlucky_core::platform::os_info::OsInfo,
/// exe 目录(便携目录那份 `launcher.json` 的根;拿不到时为 `None`)。
exe_dir: Option<PathBuf>,
/// `--data-dir` 是否由命令行强制指定(此时数据根不看 `core.*` 键)。
data_dir_forced: bool,
}
impl Ctx {
/// 解析数据根。
///
/// # Errors
///
/// `--data-dir` 非绝对路径、exe 目录拿不到、`redirect.json` 损坏等返回用户错误。
pub fn resolve(globals: &Globals) -> Result<Self, CliError> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf));
Self::resolve_in(globals, exe_dir)
}
/// 指定 exe 目录的解析入口(单测用它把便携目录钉在临时目录里)。
///
/// # Errors
///
/// 同 [`Self::resolve`]。
pub fn resolve_in(globals: &Globals, exe_dir: Option<PathBuf>) -> Result<Self, CliError> {
let platform = Platform::current();
let data_dir_forced = globals.data_dir.is_some();
let request = match &globals.data_dir {
Some(dir) => {
if !dir.is_absolute() {
return Err(CliError::usage(format!(
"--data-dir 需要绝对路径,收到 `{}`",
dir.display()
)));
}
DataDirRequest {
mode: DataMode::Custom,
custom_root: Some(dir.clone()),
..DataDirRequest::default()
}
}
None => request_from_portable_config(exe_dir.clone()),
};
let resolver = Resolver::new(platform, exe_dir.clone());
let data = resolver
.resolve_default(&request)
.map_err(|error| CliError::user("data_dir", error.to_string()))?;
Ok(Self {
data,
platform,
os: os::detect(),
exe_dir,
data_dir_forced,
})
}
/// 启动器数据布局(`.ULL/` 四分区)。
#[must_use]
pub fn layout(&self) -> LauncherLayout {
self.data.layout()
}
/// 游戏目录布局(`.minecraft/`)。
#[must_use]
pub fn game(&self) -> GameLayout {
GameLayout::new(self.data.game_home.clone())
}
/// `launcher.json` 路径。
#[must_use]
pub fn launcher_config(&self) -> PathBuf {
self.layout().launcher_config()
}
/// 便携目录那份 `launcher.json`(数据根解析的唯一输入;拿不到 exe 目录时为 `None`)。
#[must_use]
pub fn portable_launcher_config(&self) -> Option<PathBuf> {
self.exe_dir.as_deref().map(portable_config_path)
}
/// `--data-dir` 是否由命令行强制指定(此时 `core.*` 数据根键本轮不参与解析)。
#[must_use]
pub const fn data_dir_forced(&self) -> bool {
self.data_dir_forced
}
/// 某个配置键的读写落点。
///
/// 数据根键([`DATA_ROOT_KEYS`])走便携目录那份——和 [`Self::resolve`] 读的是同一份文件,
/// 这样 `config get core.data_mode` 展示的就是真正生效的值,`config set` 也真的会影响
/// 下次解析;其余键走解析后的数据根(`<data>/.ULL/config/launcher.json`)。
#[must_use]
pub fn setting_path_for(&self, key: &str) -> PathBuf {
if is_data_root_key(key) {
if let Some(path) = self.portable_launcher_config() {
return path;
}
}
self.launcher_config()
}
/// `profiles.json` 路径。
#[must_use]
pub fn profiles_config(&self) -> PathBuf {
self.layout().profiles_config()
}
/// 会话根(M188 起 = `<data>/logs`;launch 的事件流与索引都在这下面)。
#[must_use]
pub fn sessions_root(&self) -> PathBuf {
self.layout().sessions_root()
}
/// 会话日志的**旧家**(`<data>/data/agent/sessions`):只读兼容,M188 起不再往里写。
#[must_use]
pub fn legacy_sessions_root(&self) -> PathBuf {
self.layout().agent_sessions()
}
/// 启动器日志目录(`<data>/logs`:`launcher.log` + `game/` 会话)。
#[must_use]
pub fn logs_dir(&self) -> PathBuf {
self.layout().logs()
}
/// 账户数据目录(`data/auth/`:`accounts.json` + `credentials.dat`)。
#[must_use]
pub fn auth_data(&self) -> PathBuf {
self.layout().auth_data()
}
/// 会话索引文件(`<sessions_root>/game/index.jsonl`)。
#[must_use]
pub fn session_index(&self) -> PathBuf {
unlucky_core::launch::session_index::index_path(&self.sessions_root())
}
/// 旧家的会话索引(`data/agent/sessions/game/index.jsonl`):只读兼容。
#[must_use]
pub fn legacy_session_index(&self) -> PathBuf {
unlucky_core::launch::session_index::index_path(&self.legacy_sessions_root())
}
/// D19/D21:低于基线(Win10+ / macOS 12+ / Linux 达标)时拒绝执行。
///
/// # Errors
///
/// 系统不支持时返回 `unsupported_os` 用户错误(文案与核心自检同一份)。
pub fn ensure_supported(&self) -> Result<(), CliError> {
let check = os::check();
if check.is_usable() {
return Ok(());
}
Err(CliError::user("unsupported_os", check.message)
.with_hint(format!("最低要求:{}", os::BASELINE)))
}
/// 环境级提示(低于基线时的升级指引;一等基线时为空)。
#[must_use]
pub fn notes(&self) -> Vec<String> {
os::notes(&self.os)
}
}
/// 便携目录的 `launcher.json` → 三模式请求。
///
/// 只读 `effective`(文件值 → 默认值),坏文件也永不 panic(`SettingStore` 的加载降级)。
/// 路径由 [`portable_config_path`] 决定,`config get/set` 用的是同一个函数(同源)。
fn request_from_portable_config(exe_dir: Option<PathBuf>) -> DataDirRequest {
let mut request = DataDirRequest::default();
let Some(exe_dir) = exe_dir else {
return request;
};
let path = portable_config_path(&exe_dir);
if !path.is_file() {
return request;
}
let store = SettingStore::load(&path, launcher_schema());
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
if let Some(mode) = text("core.data_mode").as_deref().and_then(DataMode::parse) {
request.mode = mode;
}
if let Some(root) = text("core.data_custom_root") {
request.custom_root = Some(PathBuf::from(root));
}
if let Some(home) = text("core.launcher_home") {
request.launcher_home = Some(PathBuf::from(home));
}
if let Some(home) = text("core.game_home") {
request.game_home = Some(PathBuf::from(home));
}
request
}
+57
View File
@@ -0,0 +1,57 @@
//! CLI 侧的下载引擎:把 CLI 自己的 `launcher.json` 接到 core 的下载调优上(M203)。
//!
//! `install` / `java` / `pack` 三条命令此前各自 `DownloadEngine::new(EngineConfig::default(), …)`:
//! 用户在设置里配的 `download.concurrency` / `download.retry_limit` / `download.gh_proxy` 一概不生效。
//! M193 把出厂并发从 64 改成 8 之后,它们还**静默**跟着降了——「设置项说假话」的同期病灶
//! (`docs/download-settings.md` §3.1 记着这三条 ❌)。第四条路径 `update`(自更新)复用 `install`
//! 的装配,也随之吃设置;它只下一个 artifact,所以并发在那条路径上不可观测。
//!
//! 现在四条一律经 [`engine`] 取引擎(进程内共享,见 `core::download::shared`):
//!
//! ```text
//! <数据根>/config/launcher.json(与 `ull config set` 同一份) → setting store → DownloadTuning
//! (默认值/夹取/回落在 core)
//! → shared::engine_with_tuning(<cache 根>, tuning) → 引擎的 Semaphore / attempts_for()
//! ```
//!
//! **回落是静默的**:`SettingStore::load` 永不失败(坏值只进 `problems()`);类型不对 → `None`,
//! 越界 → `DownloadTuning::from_settings` 的区间过滤。两种情况都落成出厂值,**不报错、不中断安装**。
use std::sync::Arc;
use unlucky_core::download::config::{
DownloadTuning, KEY_CONCURRENCY, KEY_GH_PROXY, KEY_RETRY_LIMIT,
};
use unlucky_core::download::{shared, DownloadEngine};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::context::Ctx;
/// 从 CLI 自己的 setting store 读下载调优(`launcher.json` 的三个 `download.*` 键)。
///
/// 读的是 `ctx.launcher_config()`——与 `ull config set download.concurrency` 的落点同一份文件
/// (设置键的落点分流见 [`Ctx::setting_path_for`])。gh-proxy 空串 = 跟随内置默认,**不是**关。
#[must_use]
pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
let doc = store.doc();
let gh_proxy = doc
.effective_str(KEY_GH_PROXY)
.unwrap_or_default()
.to_string();
DownloadTuning::from_settings(
doc.effective_i64(KEY_CONCURRENCY),
doc.effective_i64(KEY_RETRY_LIMIT),
Some(gh_proxy.as_str()),
)
}
/// 建该数据根的下载引擎:参数来自 [`tuning`],引擎本体由 core 按缓存根共享。
#[must_use]
pub(crate) fn engine(ctx: &Ctx) -> Arc<DownloadEngine> {
shared::engine_with_tuning(ctx.layout().cache(), tuning(ctx))
}
#[cfg(test)]
mod tests;
+266
View File
@@ -0,0 +1,266 @@
//! [`super`] 的单测(M203):`launcher.json` 的 `download.*` 真的进到四条命令拿到的引擎里
//! (`install` / `java` / `pack` / `update`——`update` 复用 `install` 的装配,自更新只下一个
//! artifact,所以并发在那条路径上不可观测,随设置变的是每源重试次数与 gh-proxy 前缀)。
//!
//! 断言读的是**引擎的真实字段**——`config().max_concurrency` 与 `Semaphore` 的
//! `available_permits()`(`concurrency_probe`)——不是「函数被调用过」:后者在回落写错、
//! 或者命令又自建了一个裸引擎时照样绿。gh-proxy 那一条断言的是**候选链**(真行为),不是配置值。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use unlucky_core::download::config::{
DownloadTuning, DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY, KEY_CONCURRENCY,
};
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
use unlucky_core::download::DownloadEngine;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::Globals;
use crate::context::Ctx;
/// 一次性临时目录(cli 没有 dev-dependency 的 tempfile;与 `commands/config/tests.rs` 同一手法)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-cli-download-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 数据根钉在临时目录里:`launcher.json` 落在 `<dir>/.ULL/config/launcher.json`。
fn ctx_at(dir: &TempDir) -> Ctx {
let globals = Globals {
data_dir: Some(dir.path().to_path_buf()),
..Globals::default()
};
Ctx::resolve(&globals).expect("解析数据根")
}
/// 直接写 `launcher.json`——落点用 [`Ctx::setting_path_for`](`ull config set` 用的那个表达式),
/// **不是**实现读侧用的 `ctx.launcher_config()`。
///
/// 两个表达式今天指向同一份文件(`download.*` 不是数据根键 ⇒ 回落 `launcher_config()`),但写侧
/// 走「用户改配置那条路」的 API,实现哪天改读别的文件时这条用例会红,而不是跟着一起漂移。
fn write_launcher_config(ctx: &Ctx, document: Value) -> PathBuf {
let path = ctx.setting_path_for(KEY_CONCURRENCY);
std::fs::create_dir_all(path.parent().expect("配置目录")).expect("建配置目录");
std::fs::write(&path, serde_json::to_vec(&document).expect("序列化"))
.expect("写 launcher.json");
path
}
/// 「引擎真的按这个并发跑」:配置字段 + 闸门许可数,两处都读真实值。
fn assert_concurrency(engine: &DownloadEngine, expected: usize, case: &str) {
assert_eq!(
engine.config().max_concurrency,
expected,
"{case}:`EngineConfig` 里的并发"
);
assert_eq!(
engine.concurrency_probe(),
expected,
"{case}:`Semaphore` 真实许可数"
);
}
/// **`install` 吃设置**:`download.concurrency` 改了就变;同一次进程里再改一次也立刻变(不是只读一次)。
///
/// 抓得住的变异:`commands::install::build_engine` 换回裸 `EngineConfig::default()` ⇒ 3 / 5 两处红。
/// 抓不住的:真实下载时「只有 3 条连接在飞」的时序(离线验不了,见报告「没验的部分」)。
#[test]
fn install_engine_takes_the_concurrency_from_the_setting_store() {
let dir = TempDir::new("install");
let ctx = ctx_at(&dir);
// 没有 launcher.json = 没配过 ⇒ 出厂值(这也是「回落」的第一档)。
assert_concurrency(
&crate::commands::install::build_engine(&ctx),
DEFAULT_MAX_CONCURRENCY,
"未配置",
);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 3 } }));
assert_concurrency(
&crate::commands::install::build_engine(&ctx),
3,
"concurrency=3",
);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 5 } }));
assert_concurrency(
&crate::commands::install::build_engine(&ctx),
5,
"concurrency=5",
);
}
/// **`java` 吃设置**:并发与每个源的尝试次数都进引擎。
///
/// 抓得住的变异:`commands::java::build_engine` 换回裸 `EngineConfig::default()` ⇒ 探针停在 8、次数停在 3,红。
#[test]
fn java_engine_takes_the_concurrency_and_retry_from_the_setting_store() {
let dir = TempDir::new("java");
let ctx = ctx_at(&dir);
write_launcher_config(
&ctx,
json!({ "download": { "concurrency": 3, "retry_limit": 4 } }),
);
let engine = crate::commands::java::build_engine(&ctx);
assert_concurrency(&engine, 3, "concurrency=3");
assert_eq!(
engine.config().max_attempts_per_source,
4,
"retry_limit=4 要真的落到引擎上(出厂是 3)"
);
}
/// **`pack` 吃设置**(四条命令各有一条独立断言:把任意一处改回裸引擎,只有那一条红)。
///
/// 抓得住的变异:`commands::pack::build_engine` 换回裸 `EngineConfig::default()` ⇒ 2 处红。
#[test]
fn pack_engine_takes_the_concurrency_from_the_setting_store() {
let dir = TempDir::new("pack");
let ctx = ctx_at(&dir);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 2 } }));
assert_concurrency(
&crate::commands::pack::build_engine(&ctx),
2,
"concurrency=2",
);
}
/// **`update` 吃设置**(第 4 条路径,`commands/update.rs::engine`):自更新也只拿出厂值是不行的。
///
/// 这条路径上「并发」实际不可观测(自更新只下**一个** artifact,闸门开 1 还是 8 都只有一条连接),
/// 但引擎参数是同一处装配出来的,用并发打点最省事;真正随设置变的是每源重试次数与 gh-proxy 前缀。
/// 抓得住的变异:`commands::update::engine` 换回裸 `EngineConfig::default()` ⇒ 4 ≠ 8 处红,另五条绿。
#[test]
fn update_engine_takes_the_concurrency_from_the_setting_store() {
let dir = TempDir::new("update");
let ctx = ctx_at(&dir);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 4 } }));
assert_concurrency(&crate::commands::update::engine(&ctx), 4, "concurrency=4");
}
/// **`gh_proxy` 的 CLI 侧闭环**:手写 `"off"` ⇒ 候选链里真的没有 gh-proxy(读的是 `plan()`,不是配置值)。
///
/// 两段式:先在镜像优先的地区档案下确认**出厂**(空串 = 跟随内置)确实生成 gh-proxy 候选,再把它
/// 改成 `off` 确认候选消失——只断言后者的话,「本来就没有候选」会让用例白绿。
/// 抓得住的变异:`download::tuning` 不读 `KEY_GH_PROXY`(传 `None`)⇒ 第二段仍有 gh-proxy 候选,红;
/// 把空串当成关(`gh_proxy_override("")` 返回 `Off`)⇒ 第一段没有候选,红(回落用例的
/// `tuning == default` 同时红——两处都该红,实测如此)。
/// 抓不住的:真实网络下这个前缀能不能通(离线只验候选链的**生成**)。
#[test]
fn the_gh_proxy_setting_reaches_the_candidate_chain() {
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{store_cached, RegionProfile};
let dir = TempDir::new("ghproxy");
let ctx = ctx_at(&dir);
// gh-proxy 只在「镜像优先」的地区档案下生成候选:先把该数据根的 region.json 钉住。
let cache_root = ctx.layout().cache();
store_cached(
&cache_root.join("region.json"),
&RegionProfile::mirror_first(now_ms()),
)
.expect("地区档案");
let url = "https://github.com/o/r/releases/download/v1/a.jar";
let chain = |ctx: &Ctx| -> Vec<String> {
let request =
DownloadRequest::new(ResourceKind::Other, url, ctx.layout().cache().join("a.jar"));
crate::download::engine(ctx)
.plan(&request, 0)
.into_iter()
.map(|candidate| candidate.url)
.collect()
};
let has_gh_proxy = |chain: &[String]| chain.iter().any(|url| url.contains("gh-proxy"));
// 出厂(未配置):空串 = 跟随内置默认 ⇒ 有 gh-proxy 候选。
assert!(has_gh_proxy(&chain(&ctx)), "出厂该套内置前缀");
// 写 off:候选里不该再有 gh-proxy(其余候选仍在)。
write_launcher_config(&ctx, json!({ "download": { "gh_proxy": "off" } }));
let off = chain(&ctx);
assert!(!has_gh_proxy(&off), "off 档还有 gh-proxy 候选:{off:?}");
assert!(!off.is_empty(), "关掉加速不等于没有候选:{off:?}");
}
/// **反向断言**:键缺失 / 越界 / 负数 / 类型不对 ⇒ 回落出厂值,而且回落是**静默的**。
///
/// 「静默」在这里是结构性的:`crate::download::engine` 的返回类型没有错误通道,坏设置只进
/// `SettingStore::problems()`,调用方(安装流程)连一个 `?` 都不用加——`ull install` 不会因为一条
/// 写坏的设置而中断,只是按出厂值跑。
///
/// 抓得住的变异:删掉 `DownloadTuning::from_settings` 的区间过滤 ⇒ 9_999 那条把许可数变成 64,红;
/// 把 CLI 的读数改成 `expect`/报错 ⇒ 坏值文件这几档 panic 或编译不过,红。
/// 抓不住的:`problems()` 的内容与措辞(那是 core 的 `setting` 模块,本用例只借它证明文件真的坏)。
#[test]
fn missing_or_illegal_values_fall_back_to_the_factory_defaults_silently() {
let dir = TempDir::new("fallback");
let ctx = ctx_at(&dir);
assert_eq!(
super::tuning(&ctx),
DownloadTuning::default(),
"没有 launcher.json 就该是出厂调优"
);
for (case, document) in [
(
"越界",
json!({ "download": { "concurrency": 9_999, "retry_limit": 99 } }),
),
(
"负数",
json!({ "download": { "concurrency": -4, "retry_limit": -1 } }),
),
(
"类型不对",
json!({ "download": { "concurrency": "八", "retry_limit": "很多" } }),
),
] {
let written = write_launcher_config(&ctx, document);
// 先证明这份文件**真的**是坏的(否则「回落」只是键不存在,用例就成了空断言)。
let store = SettingStore::load(&written, launcher_schema());
assert!(
!store.problems().is_empty(),
"{case}:这份文件该被判为有问题"
);
assert_eq!(
super::tuning(&ctx),
DownloadTuning::default(),
"{case}:坏值必须回落出厂调优"
);
let engine = crate::commands::install::build_engine(&ctx);
assert_concurrency(&engine, DEFAULT_MAX_CONCURRENCY, case);
assert_eq!(
engine.config().max_attempts_per_source,
DEFAULT_MAX_ATTEMPTS_PER_SOURCE,
"{case}:尝试次数也回落出厂值"
);
}
}
+198
View File
@@ -0,0 +1,198 @@
//! 错误形状与退出码。
//!
//! 约定(10-cli-design.md §3):**0 成功 / 1 用户错误 / 2 内部错误**。
//! 用户错误 = 用法错、目标不存在、环境不支持、依赖模块未接线;
//! 内部错误 = 不该发生的事(序列化失配等),脚本见到 2 应该当 bug 报。
use std::fmt;
use serde_json::{json, Value};
/// 退出码:成功。
pub const EXIT_OK: i32 = 0;
/// 退出码:用户错误。
pub const EXIT_USER: i32 = 1;
/// 退出码:内部错误。
pub const EXIT_INTERNAL: i32 = 2;
/// 错误大类(决定退出码)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
/// 用户侧问题:改参数/改环境可解决。
User,
/// 内部问题:不该发生,应报 bug。
Internal,
}
impl ErrorKind {
/// 稳定名(`--json` 输出用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Internal => "internal",
}
}
/// 对应的进程退出码。
#[must_use]
pub const fn exit_code(self) -> i32 {
match self {
Self::User => EXIT_USER,
Self::Internal => EXIT_INTERNAL,
}
}
}
/// 一条 CLI 错误。
///
/// `code` 是给脚本的稳定机读标识(`usage` / `not_found` / `unsupported_os` …),
/// `message` 是给人看的中文说明,`hint` 是「下一步该做什么」。
#[derive(Debug, Clone)]
pub struct CliError {
kind: ErrorKind,
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)。
///
/// 渲染放在**构造时**,而不是打印时:错误有两条出口(人类可读 stderr、`--json` 信封),
/// 只在打印处渲染会漏掉 JSON 那条(r2 就是这么漏的,`%prog%` 直接出现在了 JSON 里)。
fn render(text: impl Into<String>) -> String {
crate::cli::program::fill(&text.into())
}
impl CliError {
/// 用法错误(参数不对)。
#[must_use]
pub fn usage(message: impl Into<String>) -> Self {
Self {
kind: ErrorKind::User,
code: "usage",
message: render(message),
hint: None,
detail: None,
}
}
/// 用户错误,带稳定 code。
#[must_use]
pub fn user(code: &'static str, message: impl Into<String>) -> Self {
Self {
kind: ErrorKind::User,
code,
message: render(message),
hint: None,
detail: None,
}
}
/// 内部错误,带稳定 code。
#[must_use]
pub fn internal(code: &'static str, message: impl Into<String>) -> Self {
Self {
kind: ErrorKind::Internal,
code,
message: render(message),
hint: None,
detail: None,
}
}
/// IO 失败(读配置/读写会话文件):按用户错误处理,路径进消息。
#[must_use]
pub fn io(op: &str, path: &std::path::Path, error: &std::io::Error) -> Self {
Self::user("io", format!("{op}失败:{}({error})", path.display()))
}
/// 补一句提示。
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(render(hint));
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 {
self.kind
}
/// 稳定错误码。
#[must_use]
pub const fn code(&self) -> &'static str {
self.code
}
/// 人类可读说明。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// 下一步提示。
#[must_use]
pub fn hint(&self) -> Option<&str> {
self.hint.as_deref()
}
/// 进程退出码。
#[must_use]
pub const fn exit_code(&self) -> i32 {
self.kind.exit_code()
}
/// `--json` 里的错误对象。
#[must_use]
pub fn to_json(&self) -> Value {
let mut error = json!({
"code": self.code,
"kind": self.kind.as_str(),
"message": self.message,
});
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 })
}
}
impl fmt::Display for CliError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.message)
}
}
impl std::error::Error for CliError {}
+29
View File
@@ -0,0 +1,29 @@
//! Unlucky-Launcher 命令行(`unlucky_cli`):`ull` 二进制的可测试内核。
//!
//! 设计定稿:`DevDocs/PlanDoc/10-cli-design.md`(D18)—— core 是零 UI 纯库,
//! CLI 只是与 Slint GUI 平级的第二个消费者,因此**本 crate 不依赖任何 UI 框架**。
//!
//! 分层:
//! - [`cli`]:手写参数解析(不引 clap)+ 命令模型 + 帮助文案;
//! - [`commands`]:每个子命令一个函数,输入 [`context::Ctx`],输出 [`output::Report`];
//! - [`download`]:CLI 侧的下载引擎(设置 store → core 调优,`install`/`java`/`pack` 共用);
//! - [`context`]:数据根解析(三模式 / `--data-dir` 覆盖)与目录视图;
//! - [`os`]:系统摘要与 D19/D21 支持级别(事实来自 core::platform::os_info,这里只做呈现);
//! - [`output`]:人类可读 + `--json` 双格式输出;
//! - [`error`]:错误形状与退出码(0 成功 / 1 用户错误 / 2 内部错误)。
//!
//! 可测试性:入口是 [`run`],参数是 `&[String]`,输出写进两个 `&mut dyn Write`,
//! 返回值就是进程退出码——集成测试因此不需要真的起子进程也能覆盖三条通道。
#![forbid(unsafe_code)]
pub mod cli;
pub mod commands;
pub mod context;
pub mod download;
pub mod error;
pub mod os;
pub mod output;
pub mod runner;
pub use runner::run;
+9
View File
@@ -0,0 +1,9 @@
//! `ull` 二进制入口:把真实 stdio 交给 [`unlucky_cli::run`],退出码照传。
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
let code = unlucky_cli::run(&args, &mut stdout, &mut stderr);
std::process::exit(code);
}
+78
View File
@@ -0,0 +1,78 @@
//! 系统摘要与启动自检的 **CLI 呈现层**(D19 探测 + D21 Win10+ 单基线)。
//!
//! 事实采集与等级判定全在 `unlucky_core::platform::os_info`(M20):
//! - [`os_info::detect`] 真探一次(会起子进程,别在热路径反复调用);
//! - [`os_info::current`] 进程内缓存(CLI 每次执行只真探一次);
//! - [`os_info::check`] 启动自检(等级 + 双语文案 + 系统信息)。
//!
//! 本模块只做 CLI 关心的事:一行摘要、`--json` 对象、拒绝运行时的文案。
//! 支持级别只有两档(D21 用户拍板「直接 win10 起步」):[`SupportLevel::One`] 与
//! [`SupportLevel::Unsupported`]——Win8.1/8/7 不承诺 GUI 也不承诺 CLI,
//! 识别出旧版本正是为了明确报「不支持」。
use serde_json::{json, Value};
use unlucky_core::platform::os_info::{self, OsInfo, SupportLevel};
/// 取进程内缓存的系统信息(一次进程只真探一次)。
#[must_use]
pub fn detect() -> OsInfo {
os_info::current().clone()
}
/// 启动自检(等级 + 双语文案 + 系统信息),供拒绝运行时直接打印。
#[must_use]
pub fn check() -> os_info::SupportCheck {
os_info::check()
}
/// 一行摘要(`ull --version` 附带)。
#[must_use]
pub fn summary_line(info: &OsInfo) -> String {
let version = if info.version_string.is_empty() {
"版本未知"
} else {
info.version_string.as_str()
};
format!(
"{} · {}({})· {}",
info.product,
version,
info.arch,
support_label(info.support_level)
)
}
/// `--json` 里的系统对象。
#[must_use]
pub fn to_json(info: &OsInfo) -> Value {
json!({
"family": info.family.manifest_name(),
"product": info.product,
"version": if info.version_string.is_empty() { None } else { Some(&info.version_string) },
"major_minor": info.major_minor.map(|(major, minor)| format!("{major}.{minor}")),
"build": info.build,
"arch": info.arch,
"session": info.session.as_str(),
"support": info.support_level.as_str(),
"baseline": BASELINE,
})
}
/// 支持基线的一句话(`--json` 与帮助文案共用)。
pub const BASELINE: &str = "Windows 10+ / macOS 12+ / Linux(内核 ≥ 3.2,glibc ≥ 2.17)";
/// 环境级提示:仅在低于基线时非空(一等基线不打岔)。
#[must_use]
pub fn notes(info: &OsInfo) -> Vec<String> {
match info.support_level {
SupportLevel::One => Vec::new(),
SupportLevel::Unsupported => info.describe().zh.lines().map(str::to_string).collect(),
}
}
fn support_label(level: SupportLevel) -> &'static str {
match level {
SupportLevel::One => "满足最低要求",
SupportLevel::Unsupported => "不支持(低于最低要求)",
}
}
+215
View File
@@ -0,0 +1,215 @@
//! 双格式输出:人类可读行 + `--json` 机器可读载荷。
//!
//! 一个命令只产出 [`Report`](`data` 给脚本,`human` 给人);怎么打印由 [`print_report`]
//! 一处决定,命令里不出现 `println!`——这样集成测试能直接断言输出形状,
//! 也能保证 `--json` 覆盖到每个命令(10-cli-design.md §3 全局参数)。
use std::io::Write;
use serde_json::{json, Value};
use crate::error::CliError;
/// 一个命令的结果。
#[derive(Debug, Clone)]
pub struct Report {
/// `--json` 模式下的 `data` 载荷。
pub data: Value,
/// 人类可读输出(一行一条)。
pub human: Vec<String>,
}
impl Report {
/// 构造(人类可读行在这里就把程序名占位符渲染掉,见 [`crate::cli::program::PLACEHOLDER`])。
#[must_use]
pub fn new(data: Value, human: Vec<String>) -> Self {
Self {
data,
human: human.into_iter().map(|line| fill_program(&line)).collect(),
}
}
}
/// 命令结果 + 环境提示(D19/D21 低于基线时的升级指引之类,不混进 `data`)。
#[derive(Debug, Clone)]
pub struct Outcome {
/// 命令结果。
pub report: Report,
/// 环境级提示(人类模式进 stderr,JSON 模式进信封的 `warnings`)。
pub warnings: Vec<String>,
/// 命令自定义的退出码;`None` = 0(绝大多数命令)。
///
/// 只有"结果多态且脚本要分流"的命令才用得上:`ull pack import` 的三态是
/// `0` 成功 / `3` 部分成功 / `1` 失败,光靠 0/1 分不出来。加这个字段而不是让命令
/// 自己 `std::process::exit`,是为了让**输出照常刷新**(绕过 runner 的输出刷新会把
/// "输出没刷完就退出"这种 bug 装进 CLI)。
pub exit_code: Option<i32>,
}
impl Outcome {
/// 无提示的结果。
#[must_use]
pub fn new(report: Report) -> Self {
Self {
report,
warnings: Vec::new(),
exit_code: None,
}
}
/// 带上环境提示(同样在构造时渲染占位符,JSON 与人类可读两条出口才不会分叉)。
#[must_use]
pub fn with_warnings(report: Report, warnings: Vec<String>) -> Self {
Self {
report,
warnings: warnings
.into_iter()
.map(|line| fill_program(&line))
.collect(),
exit_code: None,
}
}
/// 指定进程退出码(不指定 = 0)。
#[must_use]
pub fn with_exit_code(mut self, code: i32) -> Self {
self.exit_code = Some(code);
self
}
}
/// 打印成功结果。
///
/// # Errors
///
/// 写 stdout/stderr 失败时返回 IO 错误(管道被关掉等)。
pub fn print_report(
outcome: &Outcome,
json: bool,
quiet: bool,
out: &mut dyn Write,
err: &mut dyn Write,
) -> std::io::Result<()> {
if json {
// 信封固定三个键,脚本按 `ok` 分流后直接吃 `data`。
let envelope = json!({
"ok": true,
"data": outcome.report.data,
"warnings": outcome.warnings,
});
writeln!(out, "{}", serde_json::to_string_pretty(&envelope)?)?;
return Ok(());
}
if !quiet {
for line in &outcome.report.human {
writeln!(out, "{line}")?;
}
}
for warning in &outcome.warnings {
writeln!(err, "提示: {warning}")?;
}
Ok(())
}
/// 打印错误(人类模式进 stderr,JSON 模式进 stdout 的错误信封)。
///
/// # Errors
///
/// 写 stdout/stderr 失败时返回 IO 错误。
pub fn print_error(
error: &CliError,
json: bool,
out: &mut dyn Write,
err: &mut dyn Write,
) -> std::io::Result<()> {
if json {
writeln!(out, "{}", serde_json::to_string_pretty(&error.to_json())?)?;
return Ok(());
}
writeln!(err, "错误: {}", error.message())?;
if let Some(hint) = error.hint() {
writeln!(err, "提示: {hint}")?;
}
Ok(())
}
/// 渲染文案里的程序名占位符(M82)。
///
/// **只替换占位符**,不对整条文本做词替换:消息里插值进来的账户名 / 路径 / 回显的子命令
/// 一个字节都不动(r1 曾把账户名 `ull` 改成程序名、把 `D:/tmp/ull/.ULL` 改成
/// `D:/tmp/unlucky-launcher/.ULL`)。
fn fill_program(text: &str) -> String {
crate::cli::program::fill(text)
}
/// 长任务的实时进度行(**只进 stderr**,不参与 [`Report`])。
///
/// 命令的正经输出仍然全部走 [`Report`](stdout、可断言的形状);下载/安装这类分钟级任务
/// 需要无缓冲的实时反馈,才单独开这条通道。行内不含 ANSI,`--no-color` 无需处理。
///
/// `--json` / `--quiet` 的抑制由**调用方**决定(`InstallArgs::progress` 已按全局选项算好),
/// 这样这条通道不需要知道全局选项长什么样。
///
/// 写不出去(管道早闭)就静默放弃:进度永远不该让命令失败。
pub fn progress_line(text: &str) {
let mut stderr = std::io::stderr();
let _ = writeln!(stderr, "{text}");
let _ = stderr.flush();
}
#[cfg(test)]
mod tests {
use super::{print_report, progress_line, Outcome, Report};
use crate::cli::program::{self, PLACEHOLDER};
use crate::error::CliError;
use serde_json::json;
#[test]
fn progress_line_never_panics() {
// 只验证"写不出去也不抛"(真实 stderr 在测试里通常可写)。
progress_line("测试进度 1/2");
progress_line("");
}
#[test]
fn json_error_envelope_never_leaks_the_placeholder() {
// r2 的回归:JSON 分支直接序列化未渲染的 message/hint,`%prog%` 原样漏给脚本。
let error = CliError::usage(format!("{PLACEHOLDER} 需要一个值"))
.with_hint(format!("{PLACEHOLDER} --help 查看命令列表"));
let envelope = serde_json::to_string(&error.to_json()).expect("序列化");
assert!(!envelope.contains(PLACEHOLDER), "{envelope}");
assert!(envelope.contains(&program::name()), "{envelope}");
}
#[test]
fn json_warnings_never_leak_the_placeholder() {
// warnings 是第二条 JSON 出口(以前连人类可读那条都没走到)。
let report = Report::new(
json!({ "ok": true }),
vec![format!("{PLACEHOLDER} instances list 看看现有实例")],
);
let outcome =
Outcome::with_warnings(report, vec![format!("用 {PLACEHOLDER} install 装版本")]);
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
print_report(&outcome, true, false, &mut out, &mut err).expect("写 JSON");
let text = String::from_utf8_lossy(&out).into_owned();
assert!(!text.contains(PLACEHOLDER), "{text}");
assert!(text.contains(&program::name()), "{text}");
}
#[test]
fn human_lines_render_the_placeholder_too() {
let report = Report::new(
json!({}),
vec![format!("用 `{PLACEHOLDER} install` 登记实例")],
);
assert_eq!(
report.human[0],
format!("用 `{} install` 登记实例", program::name())
);
// 用户数据不受影响:账户名恰好叫 ull 的那种
let report = Report::new(json!({}), vec!["找不到账户 `ull`".to_string()]);
assert_eq!(report.human[0], "找不到账户 `ull`");
}
}
+56
View File
@@ -0,0 +1,56 @@
//! 进程入口的可测试形态:解析 → 分发 → 打印 → 退出码。
//!
//! `main` 只做两件事:把真实 stdio 传进来、把返回码交给进程。
use std::io::Write;
use crate::cli;
use crate::commands;
use crate::error::EXIT_OK;
use crate::output;
/// 跑一条命令行,返回进程退出码(0 / 1 / 2)。
///
/// `args` 不含 argv[0]。`--json` 与 `--quiet` 在解析失败时也要生效,
/// 因此这里先扫一遍原始参数,而不是依赖解析结果。
pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let json = args.iter().any(|arg| arg == "--json");
let quiet = args.iter().any(|arg| arg == "--quiet" || arg == "-q");
let invocation = match cli::parse(args) {
Ok(invocation) => invocation,
Err(error) => {
let _ = output::print_error(&error, json, out, err);
return error.exit_code();
}
};
// `ull agent serve` 提前分流:stdio 形态下 stdout 是 JSON-RPC 流,
// 任何一行额外输出(包括返回后的 Report)都会让客户端解析失败。
if let cli::Command::AgentServe(serve_args) = &invocation.command {
return match crate::commands::agent::run_serve_with(&invocation.globals, serve_args, err) {
Ok(code) => code,
Err(error) => {
let _ = output::print_error(&error, json, out, err);
error.exit_code()
}
};
}
match commands::run(&invocation) {
Ok(outcome) => {
if let Err(write_err) = output::print_report(&outcome, json, quiet, out, err) {
// 输出写不出去(管道早闭)不再折腾,直接按内部错误退出。
let _ = writeln!(err, "错误: 写输出失败:{write_err}");
return crate::error::EXIT_INTERNAL;
}
// 命令可以自己指定退出码(`ull pack import` 的三态:0 / 3 / 1);
// 没指定就是 0——其它命令的行为不变。
outcome.exit_code.unwrap_or(EXIT_OK)
}
Err(error) => {
let _ = output::print_error(&error, json, out, err);
error.exit_code()
}
}
}
+293
View File
@@ -0,0 +1,293 @@
//! `ull accounts` 与 `ull launch --account` 的端到端断言。
//!
//! 职责:证明「账户能建/能删/能选」,并且**凭据红线守住了**——
//! stdout(含 `--json`)、`launch --dry-run` 的 argv 与身份块里都不出现 token 明文。
#[path = "support/mod.rs"]
mod support;
use std::path::{Path, PathBuf};
use support::{parse_json, run_cli, TempDir};
use unlucky_core::auth::offline::offline_uuid;
const VERSION: &str = "1.20.1";
/// 建一个带「版本本体 + 一个实例」的数据根。
fn data_root(tag: &str) -> TempDir {
let dir = TempDir::new(tag);
support::write_version(dir.path(), VERSION);
support::write_profiles(dir.path(), &[("生存服", VERSION)]);
dir
}
/// 覆盖版本 JSON:把 `${auth_access_token}` / `${auth_uuid}` 塞进命令行,
/// 这样「脱敏」才是被真的跑到,而不是「压根没有 token 所以没有明文」。
fn write_version_with_auth(root: &Path) {
let dir = root.join(".minecraft").join("versions").join(VERSION);
std::fs::create_dir_all(&dir).expect("建版本目录");
let version = serde_json::json!({
"id": VERSION,
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"arguments": {
"game": [
"--username", "${auth_player_name}",
"--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}",
"--userType", "${auth_user_type}",
"--version", "${version_name}"
],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
});
std::fs::write(
dir.join(format!("{VERSION}.json")),
serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"),
)
.expect("写版本 JSON");
}
fn auth_dir(root: &Path) -> PathBuf {
root.join(".ULL").join("data").join("auth")
}
/// 拼一条 `--json --data-dir <root> ...` 命令。
fn cli(dir: &TempDir, tail: &[&str]) -> Vec<String> {
let mut args = vec!["--json".to_string(), "--data-dir".to_string(), dir.arg()];
args.extend(tail.iter().map(|item| (*item).to_string()));
args
}
/// 拼一条 `launch 生存服 --dry-run ...` 命令。
fn launch(dir: &TempDir, extra: &[&str]) -> Vec<String> {
let mut tail = vec!["launch", "生存服", "--dry-run"];
tail.extend_from_slice(extra);
cli(dir, &tail)
}
/// 跑一条命令并断言退出码。
fn run(args: &[String], expected: i32) -> (String, String) {
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let (code, out, err) = run_cli(&refs);
assert_eq!(code, expected, "args={refs:?}\nstdout={out}\nstderr={err}");
(out, err)
}
#[test]
fn accounts_list_is_empty_at_first() {
let dir = data_root("accounts-empty");
let (out, _) = run(&cli(&dir, &["accounts", "list"]), 0);
let envelope = parse_json(&out);
assert_eq!(envelope["data"]["count"], 0);
assert_eq!(
envelope["data"]["accounts"].as_array().map(Vec::len),
Some(0)
);
assert!(!auth_dir(dir.path()).join("accounts.json").exists());
}
#[test]
fn add_offline_writes_both_files_and_reports_hmcl_uuid() {
let dir = data_root("accounts-add");
let (out, _) = run(&cli(&dir, &["accounts", "add", "offline", "小明"]), 0);
let envelope = parse_json(&out);
let account = &envelope["data"]["account"];
assert_eq!(account["name"], "小明");
assert_eq!(account["kind"], "offline");
assert_eq!(account["profile_id"], offline_uuid("小明"));
let accounts_file = auth_dir(dir.path()).join("accounts.json");
let credentials_file = auth_dir(dir.path()).join("credentials.dat");
assert!(accounts_file.is_file(), "元数据文件必须落盘");
assert!(
credentials_file.is_file(),
"凭据文件必须落盘(哪怕离线轨是空的)"
);
// list 里能看到,且输出里没有任何凭据字段。
let (list_out, _) = run(&cli(&dir, &["accounts", "list"]), 0);
let list = parse_json(&list_out);
assert_eq!(list["data"]["count"], 1);
assert_eq!(list["data"]["accounts"][0]["name"], "小明");
for forbidden in ["access_token", "refresh_token", "client_token", "password"] {
assert!(
!list_out.contains(forbidden),
"accounts list 不该出现 {forbidden}"
);
}
}
#[test]
fn duplicate_offline_account_is_rejected() {
let dir = data_root("accounts-dup");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&cli(&dir, &["accounts", "add", "offline", "steve"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "conflict");
}
#[test]
fn add_offline_requires_a_valid_name() {
let dir = data_root("accounts-badname");
let (out, _) = run(&cli(&dir, &["accounts", "add", "offline"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "usage");
let (out, _) = run(
&cli(&dir, &["accounts", "add", "offline", "0123456789abcdefg"]),
1,
);
assert_eq!(parse_json(&out)["error"]["code"], "malformed");
}
#[test]
fn remove_deletes_account_and_reports_missing() {
let dir = data_root("accounts-remove");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&cli(&dir, &["accounts", "remove", "Steve"]), 0);
assert_eq!(parse_json(&out)["data"]["removed"]["name"], "Steve");
let (out, _) = run(&cli(&dir, &["accounts", "list"]), 0);
assert_eq!(parse_json(&out)["data"]["count"], 0);
let (out, _) = run(&cli(&dir, &["accounts", "remove", "Steve"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "not_found");
}
#[test]
fn add_msa_without_client_id_is_explained() {
// 没配 ULL_MSA_CLIENT_ID 时不该去联网,而是明确告知要配什么。
let saved = std::env::var("ULL_MSA_CLIENT_ID").ok();
std::env::remove_var("ULL_MSA_CLIENT_ID");
let dir = data_root("accounts-msa");
let (out, _) = run(&cli(&dir, &["accounts", "add", "msa"]), 1);
if let Some(value) = saved {
std::env::set_var("ULL_MSA_CLIENT_ID", value);
}
let envelope = parse_json(&out);
assert_eq!(envelope["error"]["code"], "unsupported");
assert!(envelope["error"]["hint"]
.as_str()
.unwrap_or_default()
.contains("ULL_MSA_CLIENT_ID"));
}
#[test]
fn launch_dry_run_uses_the_only_account() {
let dir = data_root("launch-account");
write_version_with_auth(dir.path());
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&launch(&dir, &[]), 0);
let data = &parse_json(&out)["data"];
let account = &data["account"];
assert_eq!(account["name"], "Steve");
assert_eq!(account["kind"], "offline");
assert_eq!(account["profile_id"], offline_uuid("Steve"));
assert_eq!(account["registered"], true);
// argv 里玩家名与 UUID 都对上了,token 位置是脱敏占位。
let argv: Vec<String> = data["argv"]
.as_array()
.expect("argv")
.iter()
.map(|item| item.as_str().unwrap_or_default().to_string())
.collect();
let username_at = argv
.iter()
.position(|arg| arg == "--username")
.expect("有 --username");
assert_eq!(argv[username_at + 1], "Steve");
// launch 的脱敏器把 uuid 也当敏感值处理(M18 定案:dry-run 输出是「脱敏 uuid」),
// 真实 uuid 在下面的身份块里以可公开元数据的形式给出。
let uuid_at = argv
.iter()
.position(|arg| arg == "--uuid")
.expect("有 --uuid");
assert_eq!(argv[uuid_at + 1], "<redacted:token>");
// 真凭据位置:离线占位 token 是随机 32 hex,输出里必须只剩脱敏占位。
let token_at = argv
.iter()
.position(|arg| arg == "--accessToken")
.expect("有 --accessToken");
assert_eq!(argv[token_at + 1], "<redacted:token>");
assert!(out.contains("<redacted:token>"));
// 命令行与展示行里不该有任何 32 位十六进制串(= 没漏出 accessToken)。
let display = data["display"].as_str().unwrap_or_default();
assert!(!has_hex32(&argv.join(" ")), "argv 漏了 token:{argv:?}");
assert!(!has_hex32(display), "display 漏了 token:{display}");
assert!(
!out.contains("access_token"),
"输出不该出现 access_token 字段"
);
}
/// 输出里是否有 32 位十六进制串(token 形状)。
fn has_hex32(text: &str) -> bool {
text.split(|ch: char| !ch.is_ascii_hexdigit())
.any(|run| run.len() == 32)
}
#[test]
fn multiple_accounts_require_explicit_choice() {
let dir = data_root("launch-multi");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
run(&cli(&dir, &["accounts", "add", "offline", "Alex"]), 0);
let (out, _) = run(&launch(&dir, &[]), 1);
let envelope = parse_json(&out);
assert_eq!(envelope["error"]["code"], "account_ambiguous");
assert!(
envelope["error"]["hint"]
.as_str()
.unwrap_or_default()
.contains("Steve"),
"hint 要列候选"
);
// 指定名字就能选出来(大小写不敏感)。
let (out, _) = run(&launch(&dir, &["--account", "alex"]), 0);
let data = &parse_json(&out)["data"];
assert_eq!(data["account"]["name"], "Alex");
assert_eq!(data["account"]["profile_id"], offline_uuid("Alex"));
}
#[test]
fn unknown_account_and_username_conflict_are_usage_errors() {
let dir = data_root("launch-unknown");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&launch(&dir, &["--account", "Nobody"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "not_found");
let (out, _) = run(&launch(&dir, &["--username", "Alex"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "usage");
}
#[test]
fn launch_without_accounts_keeps_the_offline_placeholder() {
let dir = data_root("launch-placeholder");
let (out, _) = run(&launch(&dir, &["--username", "Node"]), 0);
let data = &parse_json(&out)["data"];
assert_eq!(data["account"]["registered"], false);
assert_eq!(data["account"]["kind"], "offline-placeholder");
assert_eq!(data["account"]["name"], "Node");
let argv: Vec<&str> = data["argv"]
.as_array()
.expect("argv")
.iter()
.map(|item| item.as_str().unwrap_or_default())
.collect();
let at = argv
.iter()
.position(|arg| *arg == "--username")
.expect("--username");
assert_eq!(argv[at + 1], "Node");
}
#[test]
fn accounts_help_topic_exists() {
let (out, _) = run(&["help".to_string(), "accounts".to_string()], 0);
assert!(out.contains("credentials.dat"), "{out}");
assert!(out.contains("accounts add offline"), "{out}");
}
+332
View File
@@ -0,0 +1,332 @@
//! M221 CLI 端到端:`ull instances scan` / `ull instances adopt` 的真实入口(解析 → 执行 → 两条输出)。
//!
//! 用的都是**真磁盘**数据根(`support::TempDir`)+ 一份「别的启动器铺好的」`.minecraft`。
//! 每条用例都断言外部文件哈希/mtime 不变(`fixture::assert_unchanged`),
//! 因为「只登记不搬文件」是本 mission 的核心承诺。
#[path = "adopt_test/cli_surface.rs"]
mod cli_surface;
#[path = "adopt_test/collision_cases.rs"]
mod collision_cases;
#[path = "adopt_test/fixture.rs"]
mod fixture;
#[path = "support/mod.rs"]
mod support;
use std::path::Path;
use support::{parse_json, run_cli, TempDir};
use crate::fixture::{assert_unchanged, foreign_data_root, snapshot, snapshot_opt, Node};
/// 缺省登记:落 ULL 数据区的独立目录,隔离档 `custom`,**不沿用全局 `isolated`**。
///
/// 抓得住:
/// - 缺省沿用全局 `isolated`(运行目录会变成 `versions/1.20.1`,第二条断言会红)——
/// 这是本 mission 点名的最容易漏的一条;
/// - 命令只登记不输出(`--json` 缺 `instance`);
/// - 登记过程写了外部 `.minecraft`(哈希断言)。
///
/// 抓不住:GUI 是否也能触发同一条路(GUI 接线是后续 mission)。
#[test]
fn adopt_without_isolation_uses_a_separate_run_dir_and_keeps_global_isolated_out_of_it() {
let dir = TempDir::new("adopt-default");
let root = dir.path();
foreign_data_root(root);
// 全局档显式写成 isolated(D20 的缺省):登记**不许**继承它去踩别人的版本目录。
set_global_isolated(&dir);
let minecraft = root.join(".minecraft");
let before = snapshot(&minecraft);
let (code, stdout, stderr) = run_cli(&[
"instances",
"adopt",
"1.20.1",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
let after = snapshot(&minecraft);
assert_unchanged(&before, &after, "adopt 之后");
let value = parse_json(&stdout);
let instance = &value["data"]["instance"];
assert_eq!(instance["isolation"], "custom");
assert_eq!(instance["run_dir_source"], "instance_dir");
let run_dir = instance["run_dir"].as_str().expect("run_dir");
assert!(
run_dir.contains("instances"),
"缺省运行目录应落 ULL 数据区:{run_dir}"
);
assert!(
!Path::new(run_dir).starts_with(&minecraft),
"缺省运行目录不许落进别人的 .minecraft:{run_dir}"
);
assert!(
!run_dir.ends_with("versions/1.20.1") && !run_dir.ends_with("versions\\1.20.1"),
"不许沿用全局 isolated 的 versions/<id>:{run_dir}"
);
assert_eq!(instance["created"], true);
// 再走一次 `instances list`:档案真的进去了(不是只写了实例文件)。
let (code, stdout, _) = run_cli(&["instances", "list", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
let listed = parse_json(&stdout);
assert_eq!(listed["data"]["instances"][0]["version_id"], "1.20.1");
assert_eq!(listed["data"]["instances"][0]["version_present"], true);
}
/// 登记之后 `launch --dry-run` 的 `--gameDir` 就是选定的运行目录(本 mission 的功能落点)。
///
/// 抓得住:登记把隔离档写错(`inherit`)⇒ 启动派生坚持全局 `isolated`,`game_dir` 变成
/// `versions/1.20.1`(第二条断言会红);`gameDir` 没进启动计划。
/// 抓不住:真进程是否真的用这个 CWD(要真跑游戏,本 mission 不跑)。
#[test]
fn the_launch_plan_points_game_dir_at_the_chosen_run_dir() {
let dir = TempDir::new("adopt-launch");
let root = dir.path();
foreign_data_root(root);
set_global_isolated(&dir);
let (code, stdout, stderr) = run_cli(&[
"instances",
"adopt",
"1.20.1",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
let adopted = parse_json(&stdout);
let run_dir = adopted["data"]["instance"]["run_dir"]
.as_str()
.expect("run_dir")
.to_string();
let instance_id = adopted["data"]["instance"]["id"]
.as_str()
.expect("instance id")
.to_string();
let dir_arg = dir.arg();
let args = [
"launch",
instance_id.as_str(),
"--dry-run",
"--json",
"--data-dir",
dir_arg.as_str(),
];
let (code, stdout, stderr) = run_cli(&args);
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
let plan = parse_json(&stdout);
assert_eq!(plan["data"]["game_dir"], run_dir.as_str());
assert_eq!(plan["data"]["isolation"], "custom");
assert_eq!(plan["data"]["isolation_source"], "instance");
let command = plan["data"]["argv"]
.as_array()
.expect("启动计划里应有 argv");
let game_dir_flag = command
.windows(2)
.find(|pair| pair[0].as_str() == Some("--gameDir"))
.map(|pair| pair[1].as_str().unwrap_or_default().to_string())
.expect("命令行里应有 --gameDir");
// `argv` 是**脱敏后**的(主目录 → `<home>`,见 `core::launch::redact`),所以比尾段而不是全等;
// 全等的那条断言在 `data.game_dir` 上(那是未脱敏的权威值)。
let tail = format!(
"instances/{}",
Path::new(&run_dir)
.file_name()
.expect("运行目录名")
.to_string_lossy()
);
assert!(
game_dir_flag.ends_with(&tail) || game_dir_flag.ends_with(&tail.replace('/', "\\")),
"--gameDir 必须指向选定的运行目录:{game_dir_flag} 应以 {tail} 结尾"
);
}
/// `--isolation shared`:明确选共享 ⇒ 运行目录 = 别人的游戏根,**人读输出必须说出后果**。
///
/// 抓得住:shared 档没有告知(stdout 里没有「共用」)、shared 被写成 custom + 自己的目录、
/// 登记往游戏根写文件(哈希断言)。
/// 抓不住:用户是否真的看见这句(UI 验收另说)。
#[test]
fn shared_isolation_states_the_consequence_in_human_output() {
let dir = TempDir::new("adopt-shared");
let root = dir.path();
foreign_data_root(root);
let minecraft = root.join(".minecraft");
let before = snapshot(&minecraft);
let (code, stdout, stderr) = run_cli(&[
"instances",
"adopt",
"1.20.1",
"--isolation",
"shared",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
let after = snapshot(&minecraft);
assert_unchanged(&before, &after, "shared 登记之后");
assert!(
stdout.contains("共用"),
"必须说出「共用别人的 .minecraft」:{stdout}"
);
assert!(stdout.contains("一个字节都没动"), "{stdout}");
}
/// `--dry-run`:只检查不写盘(连配置目录都不建)。
///
/// 抓得住:`--dry-run` 真的写了档案或建了运行目录(快照会红)。
/// 抓不住:`--dry-run` 有没有顺带读了不该读的东西。
#[test]
fn dry_run_writes_nothing() {
let dir = TempDir::new("adopt-dry");
let root = dir.path();
foreign_data_root(root);
let before = snapshot(root);
// 判据强度自检(R5):快照必须含**目录**节点,否则「凭空建个空目录」看不出来。
assert!(
matches!(before.get(".minecraft"), Some(Node::Dir { .. })),
"快照缺少目录节点:{:?}",
before.get(".minecraft")
);
assert!(
before.contains_key(".minecraft/versions"),
"快照缺少 versions 目录节点"
);
let (code, stdout, stderr) = run_cli(&[
"instances",
"adopt",
"1.20.1",
"--dry-run",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
let after = snapshot(root);
assert_unchanged(&before, &after, "--dry-run 之后");
// `--dry-run` 连 `.ULL` 这个**空目录**都不许建(存在性判据用 `snapshot_opt`)。
assert!(
snapshot_opt(&root.join(".ULL")).is_none(),
"--dry-run 不该建配置目录"
);
assert!(stdout.contains("检查通过"), "{stdout}");
assert!(stdout.contains("没有写任何文件"), "{stdout}");
}
/// 坏的版本(缺父)⇒ 退出码 1,`--json` 里带**逐条**问题码。
///
/// 抓得住:不合格版本被登记成功(退出码变 0)、问题码被折叠成一句散文(`detail.problems` 缺项)。
/// 抓不住:用户在原来的启动器里修好之后重试(自然覆盖)。
#[test]
fn not_adoptable_version_exits_one_with_structured_problems() {
let dir = TempDir::new("adopt-refuse");
let root = dir.path();
foreign_data_root(root);
let before = snapshot(root);
let (code, stdout, _) = run_cli(&[
"instances",
"adopt",
"1.20.1-orphan",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1, "{stdout}");
let value = parse_json(&stdout);
assert_eq!(value["ok"], false);
assert_eq!(value["error"]["code"], "adopt_not_adoptable");
assert_eq!(
value["error"]["detail"]["problems"][0]["code"],
"missing_parent"
);
let after = snapshot(root);
assert_unchanged(&before, &after, "失败之后");
}
/// 重复登记 ⇒ 退出码 1 + 无副作用(档案逐字节不变)。
///
/// 抓得住:重复登记当成功(退出码 0)或失败时改了档案。
/// 抓不住:并发两次登记(没有锁;这不是并发入口)。
#[test]
fn repeating_the_same_adopt_exits_one_without_side_effects() {
let dir = TempDir::new("adopt-repeat");
let root = dir.path();
foreign_data_root(root);
let (code, _, stderr) = run_cli(&["instances", "adopt", "1.20.1", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "{stderr}");
let config = root.join(".ULL/config");
let before = snapshot(&config);
let (code, stdout, _) = run_cli(&[
"instances",
"adopt",
"1.20.1",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1, "{stdout}");
assert_eq!(
parse_json(&stdout)["error"]["code"],
"adopt_already_registered"
);
let after = snapshot(&config);
assert_unchanged(&before, &after, "重复登记失败之后");
}
/// `instances scan`:只读列出候选,坏版本逐条给原因。
///
/// 抓得住:扫描变成空列表(那是本 mission 点名不许的行为)、坏版本被静默跳过、
/// 扫描写了文件。
/// 抓不住:真实 `.minecraft` 里那些平台特有的怪目录(夹具只覆盖已知形状)。
#[test]
fn scan_lists_candidates_and_reasons_without_writing() {
let dir = TempDir::new("adopt-scan");
let root = dir.path();
foreign_data_root(root);
let before = snapshot(root);
let (code, stdout, stderr) =
run_cli(&["instances", "scan", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
let after = snapshot(root);
assert_unchanged(&before, &after, "scan 之后");
let value = parse_json(&stdout);
assert_eq!(value["data"]["counts"]["total"], 2);
assert_eq!(value["data"]["counts"]["adoptable"], 1);
let candidates = value["data"]["candidates"].as_array().expect("candidates");
let ok = candidates
.iter()
.find(|item| item["version_id"] == "1.20.1")
.expect("有 1.20.1");
assert_eq!(ok["adoptable"], true);
assert_eq!(ok["jar_present"], true);
let orphan = candidates
.iter()
.find(|item| item["version_id"] == "1.20.1-orphan")
.expect("有 1.20.1-orphan");
assert_eq!(orphan["adoptable"], false);
assert_eq!(orphan["problems"][0]["code"], "missing_parent");
}
/// 把全局隔离档设成 `isolated`(走 schema 校验的正式入口,不手写 launcher.json)。
fn set_global_isolated(dir: &TempDir) {
let dir_arg = dir.arg();
let (code, _, stderr) = run_cli(&[
"config",
"set",
"instances.mode",
"isolated",
"--data-dir",
dir_arg.as_str(),
]);
assert_eq!(code, 0, "设置全局隔离档失败:{stderr}");
}
+156
View File
@@ -0,0 +1,156 @@
//! 命令面:help 文案、用法错误、错误码——不碰数据或者只碰「拒绝」那条路。
//!
//! 从 `tests/adopt_test.rs` 拆出来只为守 400 行硬线;搬动是纯搬,断言未变。
use crate::support::{parse_json, run_cli, TempDir};
use crate::fixture::foreign_data_root;
/// 运行目录选项的用法错误:一律退出码 1 + `code=usage`(不是内部错误)。
///
/// 抓得住:`--isolation custom` 少 `--game-dir` 被默默当成缺省、`shared` 同时给 `--game-dir`
/// 被静默忽略、未知词被当缺省。
/// 抓不住:提示文案的措辞(只断言 code 与关键字)。
#[test]
fn isolation_option_misuse_is_a_usage_error() {
let dir = TempDir::new("adopt-usage");
foreign_data_root(dir.path());
let cases: Vec<Vec<&str>> = vec![
vec!["--isolation", "custom"],
vec!["--isolation", "shared", "--game-dir", "C:/tmp/x"],
vec!["--isolation", "nope"],
vec!["--game-dir", "relative/dir"],
];
for extra in cases {
let mut args = vec!["instances", "adopt", "1.20.1", "--json", "--data-dir"];
let dir_arg = dir.arg();
args.push(&dir_arg);
args.extend(extra.iter().copied());
let (code, stdout, stderr) = run_cli(&args);
assert_eq!(code, 1, "args={args:?}\nstdout={stdout}\nstderr={stderr}");
let value = parse_json(&stdout);
assert_eq!(
value["error"]["code"], "usage",
"args={args:?} 应是用法错误:{stdout}"
);
}
}
/// 不存在的版本 ⇒ `adopt_version_not_found`(不联网、不偷偷装一个)。
///
/// 抓得住:不存在的 id 被登记成功。
/// 抓不住:`versions/` 整个不存在时 `scan` 的空结果(core 侧用例覆盖)。
#[test]
fn unknown_version_reports_not_found() {
let dir = TempDir::new("adopt-unknown");
foreign_data_root(dir.path());
let (code, stdout, _) = run_cli(&[
"instances",
"adopt",
"9.9.9",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1, "{stdout}");
assert_eq!(
parse_json(&stdout)["error"]["code"],
"adopt_version_not_found"
);
}
/// help 里必须有这两条命令(新命令不留「有实现没文档」的状态),且旧命令仍在。
///
/// 抓得住:help 漏列 `instances scan` / `instances adopt`(子串断言),或把旧的
/// `instances launch-args` / `pack import` 弄丢。
/// 抓不住:帮助文案与真实行为是否一致(那要靠上面的行为用例 + 人读)。
#[test]
fn help_lists_the_new_subcommands_next_to_the_old_ones() {
let general = unlucky_cli::cli::help::general();
for needle in [
"instances scan",
"instances adopt",
"instances list",
"install <version>",
] {
assert!(general.contains(needle), "总帮助缺 `{needle}`:\n{general}");
}
let topic = unlucky_cli::cli::help::topic("instances").expect("help instances 存在");
for needle in [
"instances scan",
"instances adopt",
"缺省不沿用全局 isolated",
"一个字节都不写",
] {
assert!(topic.contains(needle), "help instances 缺 `{needle}`");
}
// 旧的 `per_version` 字面量只在 core 的 `Isolation::parse` 里被兼容,CLI 词表不接受它。
let bogus = std::env::temp_dir().join("ull-adopt-help-nonexistent");
let bogus = bogus.display().to_string();
let args = [
"instances",
"adopt",
"1.20.1",
"--isolation",
"per_version",
"--json",
"--data-dir",
bogus.as_str(),
];
let (code, stdout, _) = run_cli(&args);
assert_eq!(code, 1, "`per_version` 不是 CLI 词表:{stdout}");
}
/// **更高祖先断链**(r2 R2.3 的 CLI 面):`--data-dir` 指到迁移联接下面的 nested 目录,
/// 联接目标被搬走后 `instances scan` 必须非零 + 结构化错误,不能 `ok:true / total:0`。
///
/// 抓得住:只判 `versions`/游戏根两层(更高层断链会漏,这里会看到 exit0 与 total 0)。
///
/// 抓不住:真正的权限不足(EACCES)那一支。
#[cfg(windows)]
#[test]
fn a_dangling_ancestor_is_a_structured_scan_error() {
let dir = TempDir::new("scan-ancestor-dangling");
let outer = TempDir::new("scan-ancestor-target");
let real = outer.path();
let game = real.join("nested").join(".minecraft");
std::fs::create_dir_all(game.join("versions").join("1.20.1")).expect("建完整版本树");
std::fs::write(
game.join("versions/1.20.1/1.20.1.json"),
br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#,
)
.expect("写清单");
let link = dir.path().join("game-parent-link");
let ok = std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(&link)
.arg(real)
.output()
.is_ok_and(|out| out.status.success());
if !ok {
eprintln!("[link-guard] 建不出目录联接,跳过祖先断链 CLI 用例");
return;
}
let data_arg = link.join("nested").display().to_string();
// 断开之前:能扫到 1 个候选。
let (code, stdout, stderr) = run_cli(&["instances", "scan", "--json", "--data-dir", &data_arg]);
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
assert_eq!(parse_json(&stdout)["data"]["counts"]["total"], 1);
// 断开:联接目标改名(入口还在,versions 与游戏根都 NotFound)。
let moved = dir.path().join("moved-target");
std::fs::rename(real, &moved).expect("移走联接目标");
let (code, stdout, stderr) = run_cli(&["instances", "scan", "--json", "--data-dir", &data_arg]);
assert_eq!(
code, 1,
"祖先断链必须非零退出:stdout={stdout} stderr={stderr}"
);
let value = parse_json(&stdout);
assert_eq!(value["ok"], false, "{stdout}");
assert_eq!(
value["error"]["code"], "adopt_versions_unreadable",
"{stdout}"
);
}
@@ -0,0 +1,195 @@
//! 运行目录**碰撞**的 CLI 面:slug 同名 / 大小写不敏感不许让两个实例共用一个目录(复审 r1 的 R1)。
//!
//! 从 `tests/adopt_test.rs` 拆出来只为守 400 行硬线;用例体未改。
use std::path::Path;
use crate::support::{parse_json, run_cli, TempDir};
use crate::fixture::foreign_data_root;
/// **slug 碰撞的两个展示名不能绑定同一个物理运行目录**(R1 的 CLI 面)。
///
/// 复审 r1 的实跑:`Better MC` 与 `Better-MC` 都成功、两个 UUID、却指向同一个目录,
/// 随后 `launch --dry-run` 的 `--gameDir` 也是同一个。这条把它钉住。
///
/// 抓得住:
/// - 登记侧不查已有实例占用的目录(两个 `run_dir` 相同,第一条断言红);
/// - `launch` 的 `game_dir` 与登记的目录不一致(第三条断言红)。
///
/// 抓不住:`pack_dir` 的 slug 规则本身(既有行为,本 mission 不改)。
#[test]
fn colliding_slug_names_get_separate_run_dirs() {
let dir = TempDir::new("adopt-slug");
let root = dir.path();
foreign_data_root(root);
let dir_arg = dir.arg();
let first = run_cli(&[
"instances",
"adopt",
"1.20.1",
"--name",
"Better MC",
"--json",
"--data-dir",
dir_arg.as_str(),
]);
assert_eq!(
first.0, 0,
"第一次登记应成功:stdout={} stderr={}",
first.1, first.2
);
let first = parse_json(&first.1);
let second = run_cli(&[
"instances",
"adopt",
"1.20.1",
"--name",
"Better-MC",
"--json",
"--data-dir",
dir_arg.as_str(),
]);
assert_eq!(
second.0, 0,
"第二次登记应成功:stdout={} stderr={}",
second.1, second.2
);
let second = parse_json(&second.1);
let first_dir = first["data"]["instance"]["run_dir"]
.as_str()
.expect("run_dir A")
.to_string();
let second_dir = second["data"]["instance"]["run_dir"]
.as_str()
.expect("run_dir B")
.to_string();
assert_ne!(first_dir, second_dir, "两个实例不许共用运行目录");
assert!(
Path::new(&first_dir).is_dir() && Path::new(&second_dir).is_dir(),
"两个运行目录都要真实存在"
);
// 真启动计划(dry-run)也要分开:两条实例各自的 game_dir 不同。
for (value, expect) in [(&first, &first_dir), (&second, &second_dir)] {
let id = value["data"]["instance"]["id"]
.as_str()
.expect("instance id");
let args = [
"launch",
id,
"--dry-run",
"--json",
"--data-dir",
dir_arg.as_str(),
];
let (code, stdout, stderr) = run_cli(&args);
assert_eq!(
code, 0,
"stdout={stdout}
stderr={stderr}"
);
assert_eq!(
parse_json(&stdout)["data"]["game_dir"]
.as_str()
.expect("game_dir"),
expect.as_str()
);
}
}
/// **Win32 尾点/尾空格**(r2 R2.1 的 CLI 面):两次登记 + 两条 `launch --dry-run` 不许落到同一物理目录。
///
/// 抓得住:认领目录的尾点/尾空格不归一(第二次默认登记仍拿到 `Foo`,`run_dir` 相同断言红)。
///
/// 抓不住:`\?\` verbatim 写法(那本来就不折叠,是另一个目录)。
#[cfg(windows)]
#[test]
fn win32_trailing_dot_or_space_claims_do_not_share_a_run_dir() {
for suffix in [".", " "] {
let dir = TempDir::new("adopt-win32-alias");
let root = dir.path();
foreign_data_root(root);
let dir_arg = dir.arg();
let claimed = root
.join(".ULL/data/instances")
.join(format!("Foo{suffix}"));
let claimed_arg = claimed.display().to_string();
let first = run_cli(&[
"instances",
"adopt",
"1.20.1",
"--name",
"First",
"--isolation",
"custom",
"--game-dir",
claimed_arg.as_str(),
"--json",
"--data-dir",
dir_arg.as_str(),
]);
assert_eq!(
first.0, 0,
"custom 登记应成功:stdout={} stderr={}",
first.1, first.2
);
let first = parse_json(&first.1);
let second = run_cli(&[
"instances",
"adopt",
"1.20.1",
"--name",
"Foo",
"--json",
"--data-dir",
dir_arg.as_str(),
]);
assert_eq!(
second.0, 0,
"默认登记应成功:stdout={} stderr={}",
second.1, second.2
);
let second = parse_json(&second.1);
let first_dir = first["data"]["instance"]["run_dir"]
.as_str()
.expect("run_dir A")
.to_string();
let second_dir = second["data"]["instance"]["run_dir"]
.as_str()
.expect("run_dir B")
.to_string();
assert_ne!(
first_dir, second_dir,
"尾点/尾空格别名不许共用运行目录(suffix={suffix:?})"
);
// 两条启动计划的 --gameDir 也必须分开(argv 已脱敏,比尾段)。
for (value, expect) in [(&first, &first_dir), (&second, &second_dir)] {
let id = value["data"]["instance"]["id"]
.as_str()
.expect("instance id");
let args = [
"launch",
id,
"--dry-run",
"--json",
"--data-dir",
dir_arg.as_str(),
];
let (code, stdout, stderr) = run_cli(&args);
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
let plan = parse_json(&stdout);
assert_eq!(
plan["data"]["game_dir"].as_str().expect("game_dir"),
expect.as_str(),
"启动计划必须落在自己的运行目录(suffix={suffix:?})"
);
}
}
}
+81
View File
@@ -0,0 +1,81 @@
//! CLI 侧夹具:一个「别的启动器铺好了 `.minecraft`」的数据根。
//!
//! 快照工具**复用 core 测试那一份**(`crates/core/tests/adopt/snapshot.rs`,全节点:目录/文件/链接
//! 各记形状与 mtime)。以前 CLI 自己抄了一份 walk,两份都漏了目录节点——复审 r1 的 R5 正是这个洞,
//! 所以这里直接 `#[path]` 引用同一份源码:判据只有一处,不会再漂。
#[path = "../../../core/tests/adopt/snapshot.rs"]
mod snapshot;
pub use snapshot::{assert_unchanged, snapshot, snapshot_opt, Node};
use std::path::Path;
/// 造一份「别的启动器已经铺好的」数据根(`<root>/.minecraft` + `<root>/.ULL`):
///
/// | 路径 | 用意 |
/// | --- | --- |
/// | `versions/1.20.1/` | 正常版本(清单 + jar),可登记、可 `launch --dry-run` |
/// | `versions/1.20.1-orphan/` | 缺父版本 ⇒ 不可登记 |
/// | `libraries/` `assets/` | 恒共享层(登记不该碰) |
/// | `mods/` `saves/` | 玩家数据(登记不该碰) |
/// | `launcher_profiles.json` | 别的启动器的元数据(连读都不该读,更不该写) |
pub fn foreign_data_root(root: &Path) {
let minecraft = root.join(".minecraft");
let versions = minecraft.join("versions");
write_version(&versions, "1.20.1", MANIFEST_OK, true);
write_version(&versions, "1.20.1-orphan", MANIFEST_ORPHAN, true);
write_file(
&minecraft.join("libraries/com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar"),
b"library-bytes",
);
write_file(
&minecraft.join("assets/indexes/5.json"),
b"{\"objects\":{}}",
);
write_file(&minecraft.join("mods/jei.jar"), b"mod-bytes");
write_file(&minecraft.join("saves/World/level.dat"), b"save-bytes");
write_file(
&minecraft.join("launcher_profiles.json"),
br#"{"profiles":{"1.20.1":{"name":"1.20.1","lastVersionId":"1.20.1"}}}"#,
);
}
fn write_version(versions: &Path, id: &str, manifest: &str, jar: bool) {
let dir = versions.join(id);
std::fs::create_dir_all(&dir).expect("建版本目录");
std::fs::write(dir.join(format!("{id}.json")), manifest.as_bytes()).expect("写清单");
if jar {
std::fs::write(dir.join(format!("{id}.jar")), b"junk").expect("写 jar");
}
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建父目录");
}
std::fs::write(path, bytes).expect("写文件");
}
const MANIFEST_OK: &str = r#"{
"id": "1.20.1",
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}"],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
}"#;
const MANIFEST_ORPHAN: &str = r#"{
"id": "1.20.1-orphan",
"type": "release",
"inheritsFrom": "1.20.1-not-on-disk",
"mainClass": "net.minecraft.client.main.Main",
"libraries": []
}"#;
+257
View File
@@ -0,0 +1,257 @@
//! 真机验收:起 `ull agent serve --stdio`,用裸 JSON-RPC 走一遍握手 → 列工具 → 调用。
//!
//! 这是 M39 任务 5 的自动化形态:**不引任何 MCP 客户端库**,直接按协议喂行给子进程。
//! stdio 形态是 llama.cpp 内置 MCP client 的入口,它的 stdout 必须是纯 JSON-RPC——
//! 本测试顺带守住「stdout 里没有多余输出」这条最容易破的约束。
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use serde_json::{json, Value};
#[path = "support/mod.rs"]
mod support;
fn data_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ull-agent-stdio-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时数据根");
dir
}
struct Session {
child: std::process::Child,
stdin: std::process::ChildStdin,
stdout: std::io::Lines<BufReader<std::process::ChildStdout>>,
}
impl Session {
fn start(dir: &Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_ull"))
.args([
"agent",
"serve",
"--stdio",
"--allow-write",
"--data-dir",
&dir.display().to_string(),
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("应能起 ull agent serve --stdio");
let stdin = child.stdin.take().expect("stdin");
let stdout = BufReader::new(child.stdout.take().expect("stdout")).lines();
Self {
child,
stdin,
stdout,
}
}
fn send(&mut self, message: &Value) {
writeln!(self.stdin, "{message}").expect("写请求");
self.stdin.flush().expect("刷新 stdin");
}
fn next_response(&mut self) -> Value {
let line = self
.stdout
.next()
.expect("stdio 应有一行响应(不应挂断)")
.expect("读响应行失败");
serde_json::from_str(&line).unwrap_or_else(|_| panic!("stdout 不是 JSON-RPC:{line}"))
}
fn rpc(&mut self, id: u32, method: &str, params: Value) -> Value {
self.send(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }));
self.next_response()
}
}
impl Drop for Session {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn stdio_handshake_lists_tools_and_calls_them() {
let dir = data_dir("handshake");
let mut session = Session::start(&dir);
// 1. initialize
let init = session.rpc(
1,
"initialize",
json!({
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "ull-test", "version": "0.1" }
}),
);
assert_eq!(
init["result"]["serverInfo"]["name"],
json!("unlucky-launcher"),
"{init}"
);
session.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }));
// 2. tools/list —— 写工具在(--allow-write),十个工具全在
let list = session.rpc(2, "tools/list", json!({}));
let names: Vec<String> = list["result"]["tools"]
.as_array()
.expect("tools 数组")
.iter()
.filter_map(|tool| tool["name"].as_str().map(str::to_string))
.collect();
assert_eq!(names.len(), 10, "{names:?}");
for expected in [
"get_config",
"set_config",
"list_instances",
"list_accounts",
"diagnose_crash",
] {
assert!(names.iter().any(|name| name == expected), "缺 {expected}");
}
// 3. 只读工具真返回(真实 core 端口:空数据根 → 空实例/空账户)
let instances = session.rpc(
3,
"tools/call",
json!({ "name": "list_instances", "arguments": {} }),
);
assert_ne!(instances["result"]["isError"], json!(true), "{instances}");
let text = instances["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains("\"total\":0"), "空数据根应 0 个实例:{text}");
// 4. 白名单普通键写入真落盘
let written = session.rpc(
4,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
);
assert_ne!(written["result"]["isError"], json!(true), "{written}");
let launcher = dir.join(".ULL").join("config").join("launcher.json");
let content = std::fs::read_to_string(&launcher).expect("应写出 launcher.json");
assert!(content.contains("\"concurrency\""), "{content}");
// 5. 敏感键被拒(外部客户端没有弹窗),文案引导到设置页 / YOLO
let denied = session.rpc(
5,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
);
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
let text = denied["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains("YOLO"), "{text}");
// 6. 审计落盘:每一条都在,且会话标签能区分「外部 MCP」
let audit_dir = dir.join(".ULL").join("data").join("agent").join("audit");
let mut records: Vec<Value> = Vec::new();
for entry in std::fs::read_dir(&audit_dir)
.expect("审计目录应存在")
.flatten()
{
let text = std::fs::read_to_string(entry.path()).unwrap_or_default();
for line in text.lines() {
if let Ok(value) = serde_json::from_str::<Value>(line) {
records.push(value);
}
}
}
assert!(
records
.iter()
.any(|record| record["tool"] == json!("set_config") && record["outcome"] == json!("ok")),
"普通键写入应有 ok 审计:{records:?}"
);
assert!(
records
.iter()
.any(|record| record["outcome"] == json!("denied")),
"敏感键拒绝应有 denied 审计:{records:?}"
);
assert!(records
.iter()
.all(|record| record["agent_session_id"] == json!("mcp-stdio")));
let _ = std::fs::remove_dir_all(&dir);
}
/// 抓得住:真实会话(索引 + 事件流按**生产形状**落盘)经 MCP 走 `read_game_log` /
/// `diagnose_crash` 时真读得到行,而不是「0 行 / 找不到事件流」。
/// 抓不住:事件流内容本身对不对(`ports::session` / `ports::diagnose` 的用例守),
/// 也抓不住 `ull agent` 之外的读取点(`ull logs` / GUI 由 M211 与 presenter 用例守)。
#[test]
fn mcp_reads_a_real_session_log_and_diagnoses_it() {
let dir = data_dir("session-read");
let sid = support::write_session(&dir);
let mut session = Session::start(&dir);
session.rpc(
1,
"initialize",
json!({
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "ull-test", "version": "0.1" }
}),
);
session.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }));
let listed = session.rpc(
2,
"tools/call",
json!({ "name": "list_game_sessions", "arguments": {} }),
);
let text = listed["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains(&sid), "应列出该会话:{text}");
// 读日志:三行事件都在;只要 stderr 时正好那一行 OOM。
let page = session.rpc(
3,
"tools/call",
json!({ "name": "read_game_log", "arguments": { "session_id": sid, "kinds": ["stderr"] } }),
);
assert_ne!(page["result"]["isError"], json!(true), "{page}");
let text = page["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(
text.contains("\"total_events\":3"),
"应读到全部 3 行事件:{text}"
);
assert!(
text.contains("OutOfMemoryError"),
"应读到 stderr 原文:{text}"
);
// 诊断:同一份 OOM 事件流要命中规则,而不是 needs_more_info。
let report = session.rpc(
4,
"tools/call",
json!({ "name": "diagnose_crash", "arguments": { "session_id": sid } }),
);
assert_ne!(report["result"]["isError"], json!(true), "{report}");
let text = report["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains("\"needs_more_info\":false"), "{text}");
assert!(
!text.contains("\"candidates\":[]"),
"应命中至少一条规则:{text}"
);
let _ = std::fs::remove_dir_all(&dir);
}
+179
View File
@@ -0,0 +1,179 @@
//! 敏感键脱敏(P1-1 回归):单键 get、无键全量 dump、set 的旧值回显三条路径
//! 都不能把凭据明文交给用户或脚本。
#[path = "support/mod.rs"]
mod support;
use support::{parse_json, run_cli, TempDir};
/// 一眼可 grep 的假凭据。
const SECRET: &str = "sk-live-THIS-MUST-NOT-LEAK";
/// 代理 URL 里的口令(`download.proxy_url` 是 schema 的 High 档,但键名不含敏感词)。
const PROXY: &str = "socks5://user:pw@127.0.0.1:1080";
/// 建一个已经写入敏感键的数据根。
fn data_dir_with_secrets() -> TempDir {
let dir = TempDir::new("secret");
let (code, _, stderr) = run_cli(&[
"config",
"set",
"ai.api_key",
SECRET,
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
let (code, _, stderr) = run_cli(&[
"config",
"set",
"download.proxy_url",
PROXY,
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
// 一个普通键:用来验证掩码没有把整份配置打成红条(默认值不落盘,必须先写一次)。
let (code, _, stderr) = run_cli(&[
"config",
"set",
"ui.language",
"en_us",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
dir
}
#[test]
fn full_dump_redacts_sensitive_keys_by_default() {
let dir = data_dir_with_secrets();
let (code, stdout, stderr) = run_cli(&["config", "get", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "{stderr}");
assert!(!stdout.contains(SECRET), "全量 dump 泄出 API key:{stdout}");
assert!(
!stdout.contains("user:pw@"),
"全量 dump 泄出代理口令:{stdout}"
);
let value = parse_json(&stdout);
assert_eq!(value["data"]["config"]["ai"]["api_key"], "<redacted>");
assert_eq!(
value["data"]["config"]["download"]["proxy_url"],
"<redacted>"
);
assert_eq!(value["data"]["redacted"], true);
// 非敏感键照常可读(掩码不能把整份配置打成红条)。
assert_eq!(value["data"]["config"]["ui"]["language"], "en_us");
}
#[test]
fn full_dump_human_output_is_redacted_too() {
let dir = data_dir_with_secrets();
let (code, stdout, _) = run_cli(&["config", "get", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
assert!(!stdout.contains(SECRET), "{stdout}");
assert!(!stdout.contains("user:pw@"), "{stdout}");
assert!(stdout.contains("<redacted>"), "{stdout}");
assert!(stdout.contains("--show-secret"), "{stdout}");
}
#[test]
fn single_key_get_masks_by_default() {
let dir = data_dir_with_secrets();
let (code, stdout, _) = run_cli(&[
"config",
"get",
"ai.api_key",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
assert!(!stdout.contains(SECRET), "{stdout}");
let value = parse_json(&stdout);
assert_eq!(value["data"]["value"], "<redacted>");
assert_eq!(value["data"]["sensitive"], true);
assert_eq!(value["data"]["redacted"], true);
}
#[test]
fn show_secret_reveals_plaintext() {
let dir = data_dir_with_secrets();
// 全量 dump + --show-secret:明文,且 redacted=false。
let (code, stdout, _) = run_cli(&[
"config",
"get",
"--show-secret",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert_eq!(value["data"]["config"]["ai"]["api_key"], SECRET);
assert_eq!(value["data"]["config"]["download"]["proxy_url"], PROXY);
assert_eq!(value["data"]["redacted"], false);
// 单键 + --show-secret:人类输出就是明文。
let (code, stdout, _) = run_cli(&[
"config",
"get",
"ai.api_key",
"--show-secret",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
assert!(stdout.contains(SECRET), "{stdout}");
}
#[test]
fn set_masks_old_value_but_echoes_new() {
let dir = data_dir_with_secrets();
let (code, stdout, stderr) = run_cli(&[
"config",
"set",
"ai.api_key",
"sk-new-value",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
assert!(!stdout.contains(SECRET), "set 回显了旧密钥:{stdout}");
let value = parse_json(&stdout);
assert_eq!(value["data"]["old"], "<redacted>");
assert_eq!(value["data"]["new"], "sk-new-value");
// 人类模式同样不回显旧值明文(新值照常,用户刚敲的)。
let (code, stdout, _) = run_cli(&[
"config",
"set",
"ai.api_key",
"sk-third",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
assert!(
!stdout.contains("sk-new-value"),
"人类输出回显旧值:{stdout}"
);
assert!(stdout.contains("<redacted>"), "{stdout}");
assert!(stdout.contains("sk-third"), "{stdout}");
// 非敏感键仍然正常回显原值。
let (code, stdout, _) = run_cli(&[
"config",
"set",
"ui.language",
"zh_cn",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
assert_eq!(parse_json(&stdout)["data"]["old"], "en_us");
}
+109
View File
@@ -0,0 +1,109 @@
//! 退出码与输出通道(0 成功 / 1 用户错误 / 2 内部错误)。
#[path = "support/mod.rs"]
mod support;
use support::{run_cli, write_version, TempDir};
use unlucky_cli::error::{CliError, ErrorKind, EXIT_INTERNAL, EXIT_OK, EXIT_USER};
#[test]
fn exit_code_constants_match_kinds() {
assert_eq!(EXIT_OK, 0);
assert_eq!(EXIT_USER, 1);
assert_eq!(EXIT_INTERNAL, 2);
assert_eq!(ErrorKind::User.exit_code(), 1);
assert_eq!(ErrorKind::Internal.exit_code(), 2);
assert_eq!(CliError::usage("x").exit_code(), 1);
assert_eq!(CliError::user("io", "x").exit_code(), 1);
assert_eq!(CliError::internal("bug", "x").exit_code(), 2);
}
#[test]
fn internal_errors_serialize_as_internal() {
let value = CliError::internal("serialize", "序列化失配").to_json();
assert_eq!(value["ok"], false);
assert_eq!(value["error"]["kind"], "internal");
assert_eq!(value["error"]["code"], "serialize");
assert!(value["error"].get("hint").is_none());
}
#[test]
fn success_exits_zero() {
let dir = TempDir::new("code-ok");
let (code, _, _) = run_cli(&["instances", "list", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
}
#[test]
fn user_error_exits_one() {
let dir = TempDir::new("code-user");
let cases: Vec<Vec<String>> = vec![
vec!["frobnicate".to_string()],
vec!["install".to_string()],
vec![
"launch".to_string(),
"nope".to_string(),
"--data-dir".to_string(),
dir.arg(),
],
vec![
"logs".to_string(),
"deadbeef".to_string(),
"--data-dir".to_string(),
dir.arg(),
],
// `java install` 已实装(会真联网下载)——用户错误只取参数校验这一层:
// 大版本号离谱时在联网之前就拒,测试因此保持零网络(M27 踩坑记录 G 节同款教训)。
vec![
"java".to_string(),
"install".to_string(),
"4".to_string(),
"--data-dir".to_string(),
dir.arg(),
],
vec![
"--data-dir".to_string(),
"relative/path".to_string(),
"instances".to_string(),
"list".to_string(),
],
];
for args in cases {
let (code, _, stderr) = run_cli(&args.iter().map(String::as_str).collect::<Vec<_>>());
assert_eq!(code, 1, "args={args:?} stderr={stderr}");
assert!(stderr.contains("错误:"), "args={args:?} stderr={stderr}");
}
}
#[test]
fn human_and_json_go_to_different_streams() {
let dir = TempDir::new("code-streams");
write_version(dir.path(), "1.20.1");
// 人类模式:结果进 stdout。
let (code, stdout, stderr) = run_cli(&["instances", "list", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
assert!(stdout.contains("实例"), "{stdout}");
assert!(stderr.is_empty(), "{stderr}");
// 错误进 stderr,不污染 stdout。
let (code, stdout, stderr) = run_cli(&["launch", "nope", "--data-dir", &dir.arg()]);
assert_eq!(code, 1);
assert!(stdout.is_empty(), "{stdout}");
assert!(stderr.contains("错误:"), "{stderr}");
}
#[test]
fn quiet_suppresses_human_output_only() {
let dir = TempDir::new("code-quiet");
let (code, stdout, stderr) =
run_cli(&["instances", "list", "--quiet", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
assert!(stdout.is_empty(), "{stdout}");
assert!(stderr.is_empty(), "{stderr}");
// 错误不受 --quiet 影响(脚本仍要能看到原因)。
let (code, _, stderr) = run_cli(&["launch", "nope", "--quiet", "--data-dir", &dir.arg()]);
assert_eq!(code, 1);
assert!(stderr.contains("错误:"), "{stderr}");
}
+345
View File
@@ -0,0 +1,345 @@
//! D20 隔离档位的 CLI 端到端:`ull launch` 按覆盖链把派生结果填进 `--gameDir`。
//!
//! 四条路径(全局 isolated / 全局 shared / 实例 override / custom)+ 旧档案读兼容 +
//! `--dry-run` 不落盘。断言走 `--json` 输出的 `isolation` / `isolation_source` / `game_dir`,
//! 也就是真正喂给 `${game_directory}` 与子进程 CWD 的那个值。
#[path = "support/mod.rs"]
mod support;
use std::path::{Path, PathBuf};
use support::{parse_json, run_cli, TempDir};
/// 实例 id(与 `profiles.json` 里写的必须一致)。
const INSTANCE_ID: &str = "11111111-2222-4333-8444-000000000001";
/// 版本 id。
const VERSION: &str = "1.20.1";
/// 建数据根:版本本体 + `profiles.json`(一个实例「生存服」)。
fn data_root(tag: &str) -> TempDir {
let dir = TempDir::new(tag);
write_version_with_game_dir(dir.path());
write_profiles(dir.path(), None);
dir
}
/// 写一份「带 `--gameDir ${game_directory}`」的最小版本 JSON + 空 client jar。
///
/// 比 `support::write_version` 多这一个参数——`--gameDir` 就是隔离档位的真实落点
/// (正式版清单 1.13+ 都有这条),没有它就无法从命令行验证派生结果。
fn write_version_with_game_dir(root: &Path) {
let dir = root.join(".minecraft").join("versions").join(VERSION);
std::fs::create_dir_all(&dir).expect("建版本目录");
let version = serde_json::json!({
"id": VERSION,
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"arguments": {
"game": [
"--username", "${auth_player_name}",
"--version", "${version_name}",
"--gameDir", "${game_directory}"
],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
});
std::fs::write(
dir.join(format!("{VERSION}.json")),
serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"),
)
.expect("写版本 JSON");
std::fs::write(dir.join(format!("{VERSION}.jar")), b"junk").expect("写 client jar");
}
/// 写 `config/profiles.json`(可选 `game_dir` 遗留字段)。
fn write_profiles(root: &Path, legacy_game_dir: Option<&Path>) {
let profiles_dir = root.join(".ULL").join("config");
std::fs::create_dir_all(&profiles_dir).expect("建配置目录");
let mut profile = serde_json::json!({
"id": INSTANCE_ID,
"name": "生存服",
"version_id": VERSION,
});
if let Some(game_dir) = legacy_game_dir {
profile["game_dir"] = serde_json::json!(game_dir.display().to_string());
}
let document = serde_json::json!({
"schema_version": 1,
"profiles": [profile],
});
std::fs::write(
profiles_dir.join("profiles.json"),
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
)
.expect("写 profiles.json");
}
/// 写实例设置文件(`config/instances/<id>.json`)。
fn write_instance_config(dir: &Path, value: &serde_json::Value) {
let path = dir
.join(".ULL")
.join("config")
.join("instances")
.join(format!("{INSTANCE_ID}.json"));
std::fs::create_dir_all(path.parent().expect("父目录")).expect("建实例配置目录");
std::fs::write(
&path,
serde_json::to_vec_pretty(value).expect("序列化实例设置"),
)
.expect("写实例设置");
}
/// 跑一次 `launch <instance> --dry-run --json`。
fn dry_run(dir: &TempDir) -> serde_json::Value {
let (code, stdout, stderr) = run_cli(&[
"launch",
"生存服",
"--dry-run",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
parse_json(&stdout)["data"].clone()
}
/// 数据根下的游戏目录。
fn game_root(dir: &TempDir) -> PathBuf {
dir.path().join(".minecraft")
}
#[test]
fn default_mode_is_isolated_and_derives_version_dir() {
let dir = data_root("iso-default");
let data = dry_run(&dir);
assert_eq!(data["isolation"], "isolated", "D20 默认隔离");
assert_eq!(data["isolation_source"], "global");
assert_eq!(data["instance_found"], true, "实例名解析仍然有效");
assert_eq!(
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
game_root(&dir).join("versions").join(VERSION)
);
assert!(data["mode_change_warning"].is_null());
// 端到端:派生值必须是命令行里 `--gameDir` 的实参(占位符已替换、家目录已脱敏,
// 所以按路径后缀断言)。
let argv: Vec<&str> = data["argv"]
.as_array()
.expect("argv")
.iter()
.map(|token| token.as_str().expect("token"))
.collect();
let index = argv
.iter()
.position(|token| *token == "--gameDir")
.unwrap_or_else(|| panic!("命令行里没有 --gameDir:{argv:?}"));
let passed = PathBuf::from(argv[index + 1]);
assert!(
passed.ends_with(Path::new("versions").join(VERSION)),
"--gameDir 不是派生的隔离目录:{}",
passed.display()
);
}
#[test]
fn shared_mode_switches_game_dir_to_game_root() {
let dir = data_root("iso-shared");
let (code, _, stderr) = run_cli(&[
"config",
"set",
"instances.mode",
"shared",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
let data = dry_run(&dir);
assert_eq!(data["isolation"], "shared");
assert_eq!(data["isolation_source"], "global");
assert_eq!(
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
game_root(&dir),
"共享档的运行目录就是 .minecraft 根"
);
}
#[test]
fn instance_isolation_overrides_global_mode() {
let dir = data_root("iso-instance");
let (code, _, stderr) = run_cli(&[
"config",
"set",
"instances.mode",
"shared",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
write_instance_config(
dir.path(),
&serde_json::json!({
"schemaVersion": 1,
"id": INSTANCE_ID,
"versionId": VERSION,
"isolation": "isolated",
}),
);
let data = dry_run(&dir);
assert_eq!(data["isolation"], "isolated", "实例档位压过全局");
assert_eq!(data["isolation_source"], "instance");
assert_eq!(
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
game_root(&dir).join("versions").join(VERSION)
);
}
#[test]
fn custom_isolation_uses_the_recorded_game_dir() {
let dir = data_root("iso-custom");
let custom = dir.path().join("mcs").join("farm");
write_instance_config(
dir.path(),
&serde_json::json!({
"schemaVersion": 1,
"id": INSTANCE_ID,
"versionId": VERSION,
"isolation": "custom",
"gameDir": custom.display().to_string(),
}),
);
let data = dry_run(&dir);
assert_eq!(data["isolation"], "custom");
assert_eq!(
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
custom
);
}
#[test]
fn legacy_profile_game_dir_reads_as_custom() {
// M27 之前的档案只在 profiles.json 里存 game_dir(没有实例设置文件):
// 显式指定过的目录不能被静默退回全局档。
let dir = TempDir::new("iso-legacy");
write_version_with_game_dir(dir.path());
let legacy = dir.path().join("old-instance-dir");
write_profiles(dir.path(), Some(&legacy));
let data = dry_run(&dir);
assert_eq!(data["isolation"], "custom");
assert_eq!(
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
legacy
);
}
#[test]
fn dry_run_does_not_write_instance_state() {
// 切换检测只在真启动时落盘:`--dry-run` 不该吃掉「切换后首次启动提示一次」。
let dir = data_root("iso-dry-run");
let config_path = dir
.path()
.join(".ULL")
.join("config")
.join("instances")
.join(format!("{INSTANCE_ID}.json"));
assert!(!config_path.exists());
let _ = dry_run(&dir);
assert!(
!config_path.exists(),
"--dry-run 写盘了:{}",
config_path.display()
);
}
#[test]
fn unknown_instance_still_reports_available_names() {
let dir = data_root("iso-unknown");
let (code, _, stderr) = run_cli(&[
"launch",
"不存在的实例",
"--dry-run",
"--data-dir",
&dir.arg(),
]);
assert_ne!(code, 0, "未知实例要失败");
assert!(stderr.contains("生存服"), "提示里要列出可用实例:{stderr}");
}
/// 读实例设置文件(不存在返回 `None`)。
fn read_instance_config(dir: &TempDir) -> Option<serde_json::Value> {
let path = dir
.path()
.join(".ULL")
.join("config")
.join("instances")
.join(format!("{INSTANCE_ID}.json"));
let text = std::fs::read_to_string(path).ok()?;
Some(serde_json::from_str(&text).expect("实例设置应为合法 JSON"))
}
/// 真启动一次(用一个不存在的 java,保证 spawn 必失败、不会真的跑起游戏),
/// 只为驱动「真启动才写盘」的那段逻辑。
fn real_launch_that_fails(dir: &TempDir) -> (i32, String, String) {
let bogus_java = dir.path().join("no-such-java.exe");
run_cli(&[
"launch",
"生存服",
"--java",
&bogus_java.display().to_string(),
"--data-dir",
&dir.arg(),
])
}
#[test]
fn first_real_launch_bootstraps_instance_file() {
// 回归 P2-3(review r1):profiles.json 有档案但没有实例设置文件(M27 之前的形态),
// 真启动要补建基线文件,否则切换检测对这批遗留实例永远不启动。
let dir = data_root("iso-bootstrap");
assert!(read_instance_config(&dir).is_none(), "前置:还没有设置文件");
let (code, _, stderr) = real_launch_that_fails(&dir);
assert_ne!(code, 0, "假 java 必然启动失败:{stderr}");
let config = read_instance_config(&dir).expect("首次真启动应补建设置文件");
assert_eq!(config["versionId"], VERSION);
assert_eq!(config["id"], INSTANCE_ID);
// 基线:from == to,且已经标记 warned(不该平白弹一次提示)。
assert_eq!(config["lastModeChange"]["from"], "isolated");
assert_eq!(config["lastModeChange"]["to"], "isolated");
assert_eq!(config["lastModeChange"]["warned"], true);
assert!(config["lastModeChange"]["at"].is_string());
assert!(config["lastModeChange"]["probe"]["isolatedSide"].is_number());
assert!(config["lastModeChange"]["probe"]["sharedSide"].is_number());
// inherit 不落盘。
assert!(config.get("isolation").is_none(), "{config}");
}
#[test]
fn legacy_profile_dir_is_recorded_as_custom_on_bootstrap() {
// 同一批遗留档案里「显式指定过目录」的那部分:补建时把 old profiles.game_dir → custom
// 固化进实例文件,之后实例文件就是唯一真相。
let dir = TempDir::new("iso-bootstrap-legacy");
write_version_with_game_dir(dir.path());
let legacy = dir.path().join("old-instance-dir");
write_profiles(dir.path(), Some(&legacy));
let (code, _, stderr) = real_launch_that_fails(&dir);
assert_ne!(code, 0, "假 java 必然启动失败:{stderr}");
let config = read_instance_config(&dir).expect("首次真启动应补建设置文件");
assert_eq!(config["isolation"], "custom");
assert_eq!(
PathBuf::from(config["gameDir"].as_str().expect("gameDir")),
legacy
);
assert_eq!(config["lastModeChange"]["to"], "custom");
assert_eq!(config["lastModeChange"]["warned"], true, "基线不弹提示");
}
+114
View File
@@ -0,0 +1,114 @@
//! M220:`ull launch` 真正 spawn 的 Java 必须是 D4 校验过的那个。
//!
//! 钉路径的**决策**是纯函数(`commands::java_launch::plan_java`,那边有五种来源的单测);
//! 本文件补**端到端**:装配之后命令行里那个 java 也得是同一个。
//!
//! 手法:把 `JAVA_HOME` 指到一个**没有 `bin/java` 的空目录**,让「装配期按环境兜底」
//! 与「D4 选中的候选」必然不同——兜底会退回裸名字 `java[.exe]`(相对名,靠 `PATH` 找),
//! 而 D4 选中的是探出来的绝对路径。不动 `JAVA_HOME` 的话兜底恰好也落在 `JAVA_HOME`
//! 那一个文件上,「只钉 Managed」的老实现也能蒙对,正是复审抓到的那个缺陷。
//!
//! 用真子进程(`CARGO_BIN_EXE_ull` + `Command::env`)而不是 `support::run_cli`:
//! `JAVA_HOME` 要按进程注入,改本进程的环境会与同二进制里的其它用例抢。
#[path = "support/mod.rs"]
mod support;
use std::path::Path;
use std::process::Command;
use support::{parse_json, write_version, TempDir};
/// 一个可组装的版本 id:静态表要求 **Java 21+**。
///
/// 选 21+ 是有意的:「托管区里只有 17(距离 4)、环境/系统上有 21/25(距离 0)」,
/// 按 D4 的版本距离优先,选中的必然是托管的**外面**那个——正是复审抓到的
/// 「只有 managed 会钉路径」缺陷所在的那条分支。要求 17 的话托管 17 会赢,
/// 老实现也能蒙对,用例就白跑了。
const VERSION: &str = "1.21";
/// 跑一次 `ull --data-dir <root> <tail...>`,`JAVA_HOME` 注入成 `java_home`。
///
/// `json` 决定加不加全局 `--json`(加了就只有 JSON envelope,人读行被收起来)。
fn cli_with_java_home(
dir: &TempDir,
java_home: &Path,
json: bool,
tail: &[&str],
) -> (i32, String, String) {
let mut args = vec!["--data-dir".to_string(), dir.arg()];
if json {
args.push("--json".to_string());
}
args.extend(tail.iter().map(|arg| arg.to_string()));
let output = Command::new(env!("CARGO_BIN_EXE_ull"))
.args(&args)
.env("JAVA_HOME", java_home)
.output()
.expect("应能起 ull");
(
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
#[test]
fn the_command_line_runs_the_java_that_d4_validated() {
let dir = TempDir::new("java-pin");
write_version(dir.path(), VERSION);
// 空 JAVA_HOME:`JAVA_HOME/bin/java` 不存在 → 环境兜底这条路走不通。
let empty_home = dir.path().join("empty-java-home");
std::fs::create_dir_all(&empty_home).expect("建空 JAVA_HOME");
let (code, stdout, stderr) =
cli_with_java_home(&dir, &empty_home, true, &["launch", VERSION, "--dry-run"]);
assert_eq!(code, 0, "stderr: {stderr}");
let data = parse_json(&stdout)["data"].clone();
let java = &data["java"];
let source = java["source"].as_str().unwrap_or("none");
assert!(
["PATH", "system"].contains(&source),
"前置:`JAVA_HOME` 已置空,且这条版本要 Java 21+(托管区的 17 不满足),\
来源只该是 PATH 或标准安装位置:{data}"
);
let binary = java["binary"]
.as_str()
.unwrap_or_else(|| panic!("本机一个 Java 都探不到,这条端到端验不到东西(判红):{data}"));
assert!(
Path::new(binary).is_absolute(),
"前置:探到的 Java 必须是绝对路径(否则「钉住」与「裸名字」无从区分,判红):{data}"
);
// ① 装配落地的就是校验过的那个(`--json` 里并排给的两个事实必须相等)。
assert_eq!(
java["launch_binary"].as_str(),
Some(binary),
"装配落地的 java 必须是 D4 校验过的那个:{data}"
);
// ② 命令行里真正要跑的也是它 —— 这一条才是「spawn 的是哪个」。
// 注意 `program` 会过脱敏(主目录 → `<home>`),所以按「绝对路径 + 文件名」判,
// 不按整串判;退回裸 `java[.exe]` 的老实现在这里必红。
let program = data["program"].as_str().unwrap_or_default();
assert!(
program.starts_with("<home>") || Path::new(program).is_absolute(),
"命令行里的 java 必须是钉住的绝对路径(裸 `java` = 又退回装配期重选了):{data}"
);
assert_eq!(
Path::new(program).file_name(),
Path::new(binary).file_name(),
"命令行里的 java 与 D4 校验的不是同一个:{data}"
);
// ③ 人读输出也要说清「实际用哪个、来源是什么」(终端里对着日志排查时只有一个依据)。
let (code, stdout, stderr) =
cli_with_java_home(&dir, &empty_home, false, &["launch", VERSION, "--dry-run"]);
assert_eq!(code, 0, "stderr: {stderr}");
assert!(
stdout.contains(&format!("{source} · ")),
"人读行要写出 Java 来源:{stdout}"
);
assert!(
stdout.contains(binary),
"人读行要写出实际使用的 java 路径({binary}):{stdout}"
);
}
+294
View File
@@ -0,0 +1,294 @@
//! `ull java list` / `ull java install` / `ull launch` 的 Java 选择(D13)CLI 端。
//!
//! 不联网:`install` 只测参数校验与稳定错误码;`launch --dry-run` 断言 `java` 块的结构
//! 与「实例覆盖优先」——真正的下载/解压由 `core::game::java` 的测试与本机验收覆盖。
#[path = "support/mod.rs"]
mod support;
use support::{parse_json, run_cli, write_version, TempDir};
/// 一个可组装的版本 id。
const VERSION: &str = "1.20.1";
#[test]
fn java_list_reports_the_managed_root() {
let dir = TempDir::new("java-list");
let (code, stdout, stderr) = run_cli(&["--json", "--data-dir", &dir.arg(), "java", "list"]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
let json = &envelope["data"];
assert!(
json["managed_root"]
.as_str()
.is_some_and(|root| root.ends_with("java-managed")),
"managed_root 应是 data/java-managed:{json}"
);
// D23:多根扫描 + 落点模式要说清(`--data-dir` 是 custom 数据模式 → portable 落点)。
assert!(
json["managed_roots"]
.as_array()
.is_some_and(|roots| !roots.is_empty()),
"{json}"
);
assert_eq!(json["install"]["mode"], "portable", "{json}");
assert!(
json["install"]["root"]
.as_str()
.is_some_and(|root| root.ends_with("java-managed")),
"落点根应是数据根里的 java-managed:{json}"
);
assert!(json["candidates"].is_array(), "{json}");
}
#[test]
fn java_vendors_lists_the_four_vendors_with_the_current_order() {
let dir = TempDir::new("java-vendors");
let (code, stdout, stderr) = run_cli(&["--json", "--data-dir", &dir.arg(), "java", "vendors"]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
let json = &envelope["data"];
let order: Vec<&str> = json["order"]
.as_array()
.expect("order 数组")
.iter()
.filter_map(|item| item.as_str())
.collect();
assert_eq!(order.len(), 4, "{json}");
assert!(order.contains(&"temurin"), "{json}");
assert!(order.contains(&"oracle"), "{json}");
assert!(order.contains(&"microsoft"), "{json}");
assert!(order.contains(&"zulu"), "{json}");
// 本机时区/语言决定 domestic/global,两种默认序都合法,但来源必须明示。
let source = json["priority_source"].as_str().unwrap_or("?");
assert_eq!(
source, "region_default",
"没设 java.vendorPriority 时:{json}"
);
let vendors = json["vendors"].as_array().expect("vendors 数组");
assert_eq!(vendors.len(), 4);
let microsoft = vendors
.iter()
.find(|row| row["vendor"] == "microsoft")
.expect("应有 microsoft 行");
assert_eq!(
microsoft["supported"].as_array().map(Vec::len),
Some(4),
"MS 只发 25/21/17/11:{microsoft}"
);
assert!(
microsoft["sources"]
.as_array()
.is_some_and(|sources| !sources.is_empty()),
"MS 的源(aka.ms → VS CDN)要列出来:{microsoft}"
);
}
#[test]
fn java_install_rejects_an_unknown_vendor_without_network() {
let dir = TempDir::new("java-vendor-bad");
let (code, stdout, _stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"java",
"install",
"21",
"--vendor",
"graalvm",
]);
assert_eq!(code, 1);
let envelope = parse_json(&stdout);
assert_eq!(envelope["error"]["code"], "usage", "{envelope}");
}
#[test]
fn java_install_rejects_an_absurd_major_without_network() {
let dir = TempDir::new("java-install");
let (code, stdout, _stderr) =
run_cli(&["--json", "--data-dir", &dir.arg(), "java", "install", "4"]);
assert_eq!(code, 1);
let envelope = parse_json(&stdout);
assert_eq!(envelope["error"]["code"], "usage", "{envelope}");
}
#[test]
fn launch_dry_run_reports_the_java_requirement_of_the_version() {
let dir = TempDir::new("java-launch");
write_version(dir.path(), VERSION);
let (code, stdout, stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"launch",
VERSION,
"--dry-run",
]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
let json = &envelope["data"];
let java = &json["java"];
// 1.20.1 的清单没写 javaVersion → 静态表兜底 17。
assert_eq!(java["required_major"], 17, "{json}");
assert_eq!(java["requirement_source"], "table", "{json}");
assert_eq!(json["version_id"], VERSION);
// `source` 只可能是探测到的来源之一(本机未必装了 Java)。
let source = java["source"].as_str().unwrap_or("none");
assert!(
["instance", "managed", "JAVA_HOME", "PATH", "none"].contains(&source),
"意外的来源:{source}"
);
// M220:`binary` 是 D4 校验过的那个,`launch_binary` 是装配落地的那个——两者都得给出来。
assert!(
java["launch_binary"].as_str().is_some(),
"`--json` 的 java 块要写明实际装配的 java 路径:{json}"
);
}
#[test]
fn instance_java_override_wins_over_the_system_java() {
let dir = TempDir::new("java-override");
write_version(dir.path(), VERSION);
// 故意给一个不存在的路径:只要能进 `source=instance` 就说明覆盖优先级生效
// (探测失败只影响 major,不该把用户指定的路径换成系统 Java)。
let fake = dir.path().join("some-jdk").join("bin").join("java");
let (code, stdout, stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"launch",
VERSION,
"--dry-run",
"--java",
&fake.display().to_string(),
]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
let json = &envelope["data"];
assert_eq!(json["java"]["source"], "instance", "{json}");
assert_eq!(
json["java"]["binary"].as_str(),
Some(fake.display().to_string().as_str())
);
}
#[test]
fn legacy_version_requires_java_8_and_flags_a_newer_runtime() {
let dir = TempDir::new("java-legacy");
write_version(dir.path(), "1.12.2");
let (code, stdout, stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"launch",
"1.12.2",
"--dry-run",
]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
let json = &envelope["data"];
assert_eq!(json["java"]["required_major"], 8, "{json}");
assert_eq!(json["java"]["required"], "Java 8", "{json}");
// 本机若有比 8 新的 Java,必须给出 java_too_new 告警(不能只靠「最低版本」)。
if let Some(code) = json["java"]["mismatch"]["code"].as_str() {
assert!(code == "java_too_new" || code == "java_too_old", "{json}");
assert!(
json["warnings"]
.as_array()
.is_some_and(|warnings| !warnings.is_empty()),
"不符时必须进 warnings:{json}"
);
}
}
/// 写一份「清单自己声明要 Java `required`」的版本 JSON(其余字段与 `support::write_version` 同)。
///
/// 本机任何一个 Java 都不满足 `required = 99` ⇒ D4 必然要拦(`java_too_old`),
/// 与「这台机器装了哪几个 JDK」无关、也不需要联网:用例的观察点是**拦截发生在哪一步**。
fn write_version_requiring_java(root: &std::path::Path, required: u32) {
let dir = root.join(".minecraft").join("versions").join(VERSION);
std::fs::create_dir_all(&dir).expect("创建版本目录");
let version = serde_json::json!({
"id": VERSION,
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"javaVersion": { "majorVersion": required },
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}"],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
});
std::fs::write(
dir.join(format!("{VERSION}.json")),
serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"),
)
.expect("写版本 JSON");
std::fs::write(dir.join(format!("{VERSION}.jar")), b"junk").expect("写 client jar");
}
/// M220 nit3:D4 的「Java 不符」拦截必须排在身份解析**之前**。
///
/// 身份解析有副作用(微软轨会联网刷新令牌、刷新成功还会回写凭据),一个注定要被拦下的
/// 启动不该先跑那一遍。观察点:数据根里放两个离线账户 ⇒ 解析身份必然报 `account_ambiguous`,
/// 而这次启动注定被 D4 拦下 ⇒ 报出来的必须是 `java_mismatch`。
/// 老实现(拦在身份之后)在这里会先撞上 `account_ambiguous`,用例必红。
///
/// 对照组:`--dry-run` 本来就只提示不拦(D4 只管真启动),所以同一条版本在 dry-run 下
/// 会走到身份解析那一步——这一对断言把「拦截在哪一步」钉死。
#[test]
fn the_java_mismatch_block_runs_before_identity_resolution() {
let dir = TempDir::new("java-block-order");
write_version_requiring_java(dir.path(), 99);
for name in ["alpha", "beta"] {
let (code, _, stderr) =
run_cli(&["accounts", "add", "offline", name, "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "建离线账户失败:{stderr}");
}
// 前置:这条版本的要求确实没人满足(否则「注定被拦」这个前提不成立,直接判红)。
let (code, stdout, stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"launch",
VERSION,
"--dry-run",
"--account",
"alpha",
]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
assert_eq!(
envelope["data"]["java"]["mismatch"]["code"], "java_too_old",
"前置:本机探测到的 Java 必须都不满足 Java 99:{envelope}"
);
// 真启动(不指定账户):两个账户 ⇒ 解析身份会报 account_ambiguous,但 D4 拦得更早。
let (code, stdout, _stderr) = run_cli(&["--json", "--data-dir", &dir.arg(), "launch", VERSION]);
assert_eq!(code, 1);
let envelope = parse_json(&stdout);
assert_eq!(
envelope["error"]["code"], "java_mismatch",
"D4 拦截必须排在身份解析之前(否则注定被拦的启动也先刷了一遍令牌):{envelope}"
);
// 对照:dry-run 不拦(D4 只管真启动)⇒ 它会走到身份解析,报的是账户歧义。
let (code, stdout, _stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"launch",
VERSION,
"--dry-run",
]);
assert_eq!(code, 1);
let envelope = parse_json(&stdout);
assert_eq!(
envelope["error"]["code"], "account_ambiguous",
"dry-run 不该被 D4 拦住(它只提示),应当走到身份解析:{envelope}"
);
}
+140
View File
@@ -0,0 +1,140 @@
//! `ull install --loader liteloader` 的**离线**可验面:词表分流、父版本缺失、help 文案。
//!
//! 这里刻意不联网:LiteLoader 的版本表在 `dl.liteloader.com`,把「拉到表之后」的链路放在
//! `crates/core/tests/liteloader/**`(真实响应夹具 + 合成 + 计划 + 落盘 + 启动参数)。
//! 本文件守的是「进门那一步」:`optifine` 与随机拼法的错误码必须分开、父版本没装必须具名报错、
//! 非 LiteLoader 不许叠在别的加载器层上。
#[path = "support/mod.rs"]
mod support;
use support::{parse_json, run_cli, TempDir};
#[test]
fn optifine_is_recognized_but_not_supported() {
let dir = TempDir::new("liteloader-optifine");
let (code, stdout, _) = run_cli(&[
"install",
"1.20.1",
"--loader",
"optifine",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1);
let value = parse_json(&stdout);
assert_eq!(
value["error"]["code"], "loader_unsupported",
"optifine 是「认得但装不了」,不是未知加载器:{stdout}"
);
assert!(
value["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("optifine"),
"{stdout}"
);
}
/// `optifine` 之外,真正不认识的拼法仍是 `loader_unknown`(样本用**明显无效**的名字,
/// 不拿任何真实加载器名当「未知」前提)。
#[test]
fn genuinely_unknown_loader_stays_unknown() {
let dir = TempDir::new("liteloader-unknown");
let (code, stdout, _) = run_cli(&[
"install",
"1.20.1",
"--loader",
"ull-invalid-loader-test",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1);
let value = parse_json(&stdout);
assert_eq!(value["error"]["code"], "loader_unknown", "{stdout}");
let hint = value["error"]["hint"].as_str().unwrap_or_default();
assert!(hint.contains("liteloader"), "提示要列全六家:{hint}");
}
/// 父版本(`1.12.2-forge-…`)没装时:具名报缺哪个父版本,且**不发网络请求**。
#[test]
fn liteloader_on_a_missing_forge_parent_names_the_parent() {
let dir = TempDir::new("liteloader-missing-parent");
let (code, stdout, _) = run_cli(&[
"install",
"1.12.2-forge-14.23.5.2860",
"--loader",
"liteloader",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1);
let value = parse_json(&stdout);
assert_eq!(value["error"]["code"], "loader_not_found", "{stdout}");
let message = value["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("1.12.2-forge-14.23.5.2860"),
"要点名缺哪个父版本:{message}"
);
}
/// 只有 LiteLoader 接受「叠在别的加载器层上」;别家在进门处就说清(不静默改成叠原版)。
#[test]
fn only_liteloader_accepts_stacking_on_another_loader_layer() {
let dir = TempDir::new("liteloader-stack-guard");
let (code, stdout, _) = run_cli(&[
"install",
"1.12.2-forge-14.23.5.2860",
"--loader",
"fabric",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1);
let value = parse_json(&stdout);
assert_eq!(value["error"]["code"], "usage", "{stdout}");
let message = value["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("LiteLoader") && message.contains("Fabric"),
"{message}"
);
}
#[test]
fn install_help_names_the_liteloader_route() {
let (code, stdout, _) = run_cli(&["help", "install"]);
assert_eq!(code, 0);
for needle in [
"liteloader",
"optifine",
"launchwrapper",
"tweakClass",
"loader_unsupported",
"loader_unknown",
] {
assert!(
stdout.contains(needle),
"`ull help install` 少了 `{needle}`:\n{stdout}"
);
}
}
#[test]
fn general_help_lists_liteloader_under_install() {
let (code, stdout, _) = run_cli(&["--help"]);
assert_eq!(code, 0);
let install_block = stdout
.lines()
.skip_while(|line| !line.trim_start().starts_with("install <version>"))
.take(3)
.collect::<Vec<_>>()
.join("\n");
assert!(
install_block.contains("liteloader"),
"总帮助的 install 段要提到 liteloader:\n{install_block}"
);
}
+115
View File
@@ -0,0 +1,115 @@
//! D19/D21 支持基线:core 的纯判定函数 + CLI 的呈现层(注入样本,不动真机)。
use unlucky_cli::os;
use unlucky_core::platform::linux_caps::{GlProbe, SessionKind};
use unlucky_core::platform::os_info::{
self, linux_level, macos_level, support_level_for, windows_level, OsFamily, OsInfo,
SupportLevel,
};
use unlucky_core::platform::vk_probe::VkProbe;
/// 手工构造一份样本系统信息(字段全是探测事实,等级用 core 的纯函数算)。
fn sample(family: OsFamily, version: Option<(u32, u32)>, product: &str) -> OsInfo {
let version_string =
version.map_or_else(String::new, |(major, minor)| format!("{major}.{minor}"));
OsInfo {
family,
arch: "x86_64".to_string(),
major_minor: version,
build: None,
product: product.to_string(),
version_string,
session: SessionKind::Unknown,
gl: GlProbe::Unknown,
vk: VkProbe::Unknown,
kernel: None,
glibc: None,
support_level: support_level_for(family, version, None, None),
}
}
#[test]
fn windows_samples_below_win10_are_unsupported() {
// D21 用户拍板「直接 win10 起步」:Win8.1 / 8 / 7 及更旧一律不支持。
assert_eq!(windows_level(Some((6, 1))), SupportLevel::Unsupported); // Win7
assert_eq!(windows_level(Some((6, 2))), SupportLevel::Unsupported); // Win8
assert_eq!(windows_level(Some((6, 3))), SupportLevel::Unsupported); // Win8.1
assert_eq!(windows_level(Some((5, 1))), SupportLevel::Unsupported); // XP
assert_eq!(windows_level(Some((10, 0))), SupportLevel::One); // Win10
// 探不到版本 → 保守判不支持(不能因为"探不到"就放行)。
assert_eq!(windows_level(None), SupportLevel::Unsupported);
}
#[test]
fn macos_and_linux_samples() {
assert_eq!(macos_level(Some(11)), SupportLevel::Unsupported);
assert_eq!(macos_level(Some(12)), SupportLevel::One);
assert_eq!(macos_level(None), SupportLevel::Unsupported);
// Linux:内核/glibc 探得到但过低 → 不支持;取不到(容器/musl/Termux)不阻塞。
assert_eq!(linux_level(Some((2, 6)), None), SupportLevel::Unsupported);
assert_eq!(linux_level(None, Some((2, 12))), SupportLevel::Unsupported);
assert_eq!(linux_level(Some((6, 8)), Some((2, 39))), SupportLevel::One);
assert_eq!(linux_level(None, None), SupportLevel::One);
}
#[test]
fn unsupported_sample_renders_upgrade_hint() {
let info = sample(OsFamily::Windows, Some((6, 3)), "Windows 8.1");
assert!(!info.support_level.is_usable());
let line = os::summary_line(&info);
assert!(line.contains("Windows 8.1"), "{line}");
assert!(line.contains("6.3"), "{line}");
assert!(line.contains("不支持"), "{line}");
let notes = os::notes(&info);
assert_eq!(notes.len(), 1);
assert!(notes[0].contains("Windows 10 或更高版本"), "{notes:?}");
// 文案是中英双语(core 同一份措辞),脚本只读 --json。
let message = info.describe().combined();
assert!(message.contains("Windows 10 或更高版本"), "{message}");
assert!(message.contains("Windows 10 or newer"), "{message}");
let value = os::to_json(&info);
assert_eq!(value["family"], "windows");
assert_eq!(value["product"], "Windows 8.1");
assert_eq!(value["support"], "unsupported");
assert_eq!(value["major_minor"], "6.3");
}
#[test]
fn supported_sample_has_no_notes() {
let info = sample(OsFamily::Windows, Some((10, 0)), "Windows 11");
assert!(info.support_level.is_usable());
assert!(os::notes(&info).is_empty());
assert!(os::summary_line(&info).contains("满足最低要求"));
assert_eq!(os::to_json(&info)["support"], "one");
}
#[test]
fn missing_version_is_reported_as_null_not_zero() {
// 探不到版本时 `version` 是 null(脚本按 null 判定"未探测"),而不是空串。
let info = sample(OsFamily::Windows, None, "Windows");
let value = os::to_json(&info);
assert!(value["version"].is_null());
assert!(value["major_minor"].is_null());
assert_eq!(value["support"], "unsupported");
assert!(os::summary_line(&info).contains("版本未知"));
}
#[test]
fn real_detect_matches_core_cache() {
// 真机上 CLI 的摘要必须就是 core 的缓存快照(同一份事实,不做二次加工)。
let info = os::detect();
assert_eq!(&info, os_info::current());
assert!(!info.arch.is_empty());
assert!(!info.product.is_empty());
// 自检形态与事实一致。
let check = os::check();
assert_eq!(check.level, info.support_level);
assert_eq!(check.os, info);
assert_eq!(check.is_usable(), info.support_level.is_usable());
assert!(check.message.contains(&info.product), "{}", check.message);
}
+297
View File
@@ -0,0 +1,297 @@
//! `--json` 输出形状与真实 core 轻量路径(只读命令 + dry-run)。
#[path = "support/mod.rs"]
mod support;
use support::{parse_json, run_cli, write_profiles, write_session, write_version, TempDir};
#[test]
fn version_json_has_os_summary() {
let (code, stdout, _) = run_cli(&["--version", "--json"]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert_eq!(value["ok"], true);
// 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());
let support = value["data"]["os"]["support"].as_str().unwrap_or_default();
assert!(matches!(support, "one" | "unsupported"), "{support}");
}
#[test]
fn welcome_without_subcommand_prints_version_and_help() {
let (code, stdout, _) = run_cli(&[]);
assert_eq!(code, 0);
// 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"]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert!(value["data"]["help"]
.as_str()
.unwrap_or_default()
.contains("用法:"));
}
#[test]
fn instances_list_json_shape() {
let dir = TempDir::new("instances");
write_version(dir.path(), "1.20.1");
write_profiles(dir.path(), &[("生存服", "1.20.1"), ("空实例", "1.19.4")]);
let (code, stdout, _) = run_cli(&["instances", "list", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
let instances = value["data"]["instances"]
.as_array()
.expect("instances 数组");
assert_eq!(instances.len(), 2);
assert_eq!(instances[0]["name"], "生存服");
assert_eq!(instances[0]["version_present"], true);
assert_eq!(instances[1]["version_present"], false);
let installed = value["data"]["installed_versions"]
.as_array()
.expect("已安装版本");
assert_eq!(installed.len(), 1);
assert_eq!(installed[0], "1.20.1");
}
#[test]
fn install_rejects_unknown_loader_before_touching_network() {
let dir = TempDir::new("install-loader");
// 样本必须是一个**任何词表里都没有**的拼法:`liteloader` 自 M222 起是受支持加载器,
// 拿它当「未知」会让这条用例变成真去拉 dl.liteloader.com 的联网测试。
let (code, stdout, _) = run_cli(&[
"install",
"1.20.1",
"--loader",
"ull-invalid-loader-test",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1, "未知加载器是用户错误");
let value = parse_json(&stdout);
assert_eq!(value["error"]["code"], "loader_unknown");
assert!(
!dir.path()
.join(".ULL")
.join("config")
.join("profiles.json")
.exists(),
"拒绝时不该登记任何实例"
);
}
#[test]
fn install_reports_name_conflict_without_downloading() {
let dir = TempDir::new("install-conflict");
write_profiles(dir.path(), &[("生存服", "1.19.4")]);
// 同名但绑定别的版本 → 冲突,且这一步在联网/下载之前就返回。
let (code, stdout, _) = run_cli(&[
"install",
"1.20.1",
"--name",
"生存服",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1);
let value = parse_json(&stdout);
assert_eq!(value["error"]["code"], "instance_exists");
assert!(
value["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("1.19.4"),
"错误里应说明冲突版本:{stdout}"
);
}
#[test]
fn install_requires_a_version_id() {
let dir = TempDir::new("install-empty");
let (code, _, stderr) = run_cli(&["install", " ", "--data-dir", &dir.arg()]);
assert_eq!(code, 1);
assert!(stderr.contains("版本 id"), "stderr={stderr}");
}
#[test]
fn launch_dry_run_prints_assembled_command() {
let dir = TempDir::new("dryrun");
write_version(dir.path(), "1.20.1");
write_profiles(dir.path(), &[("生存服", "1.20.1")]);
let (code, stdout, _) = run_cli(&[
"launch",
"生存服",
"--dry-run",
"--username",
"Steve",
"--max-memory",
"2048",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stdout}");
let value = parse_json(&stdout);
let data = &value["data"];
assert_eq!(data["dry_run"], true);
assert_eq!(data["instance_found"], true);
let argv = data["argv"].as_array().expect("argv 数组");
let joined = argv
.iter()
.filter_map(|item| item.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(joined.contains("-Xmx2048"), "{joined}");
assert!(
joined.contains("net.minecraft.client.main.Main"),
"{joined}"
);
assert!(joined.contains("--username Steve"), "{joined}");
// 凭据绝不落到 dry-run 输出里。
assert!(!joined.contains("accessToken "), "{joined}");
assert!(data["classpath_entries"].as_u64().unwrap_or(0) >= 1);
}
#[test]
fn sessions_logs_and_diag_read_the_index() {
let dir = TempDir::new("sessions");
let sid = write_session(dir.path());
let (code, stdout, _) = run_cli(&["sessions", "list", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
let sessions = value["data"]["sessions"].as_array().expect("sessions 数组");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0]["session_id"], sid);
assert_eq!(sessions[0]["exit_code"], 1);
let (code, stdout, _) = run_cli(&[
"logs",
&sid,
"--tail",
"1",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
let events = value["data"]["events"].as_array().expect("events 数组");
assert_eq!(events.len(), 1);
assert_eq!(events[0]["kind"], "stderr");
// 前缀匹配唯一即可(脚本里手打全 id 太长)。
let (code, stdout, _) = run_cli(&["diag", "20261030", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "{stdout}");
let value = parse_json(&stdout);
assert_eq!(value["data"]["session_id"], sid);
let candidates = value["data"]["diagnosis"]["candidates"]
.as_array()
.expect("candidates 数组");
assert!(!candidates.is_empty(), "OOM 日志应命中规则:{stdout}");
assert_eq!(value["data"]["diagnosis"]["needs_more_info"], false);
}
#[test]
fn logs_json_payload_is_redacted_like_human_output() {
// P2-1:`--json` 是机器消费路径,不该比人类输出拿到更多原文(主目录用户名段)。
let dir = TempDir::new("logs-redact");
let sid = write_session(dir.path());
let events = dir
.path()
.join(".ULL")
.join("data")
.join("agent")
.join("sessions")
.join("game")
.join(format!("{sid}.jsonl"));
let extra = serde_json::json!({
"v": 1, "seq": 9, "ts": "2026-10-30T10:15:07Z", "t_ms": 6000,
"kind": "stdout", "session_id": sid,
"payload": { "line": r"cat C:\Users\Bob\secret.txt failed", "level": "info" }
});
let mut text = std::fs::read_to_string(&events).expect("读事件流");
text.push_str(&serde_json::to_string(&extra).expect("序列化事件"));
text.push('\n');
std::fs::write(&events, text).expect("写事件流");
let (code, stdout, stderr) = run_cli(&["logs", &sid, "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "{stderr}");
assert!(!stdout.contains("Bob"), "JSON 载荷泄出用户名:{stdout}");
assert!(stdout.contains("***"), "JSON 载荷未脱敏:{stdout}");
}
#[test]
fn config_get_set_round_trip() {
let dir = TempDir::new("config");
let data_dir = dir.arg();
let (code, stdout, _) = run_cli(&[
"config",
"get",
"ui.language",
"--json",
"--data-dir",
&data_dir,
]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert_eq!(value["data"]["key"], "ui.language");
assert_eq!(value["data"]["value"], "zh_cn");
let (code, stdout, _) = run_cli(&[
"config",
"set",
"ui.language",
"en_us",
"--json",
"--data-dir",
&data_dir,
]);
assert_eq!(code, 0, "{stdout}");
let value = parse_json(&stdout);
assert_eq!(value["data"]["old"], "zh_cn");
assert_eq!(value["data"]["new"], "en_us");
let (_, stdout, _) = run_cli(&[
"config",
"get",
"ui.language",
"--json",
"--data-dir",
&data_dir,
]);
assert_eq!(parse_json(&stdout)["data"]["value"], "en_us");
}
#[test]
fn error_envelope_shape() {
let dir = TempDir::new("errors");
let (code, stdout, _) = run_cli(&["launch", "nope", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 1);
let value = parse_json(&stdout);
assert_eq!(value["ok"], false);
assert_eq!(value["error"]["code"], "not_found");
assert_eq!(value["error"]["kind"], "user");
assert!(value["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("nope"));
assert!(value["error"]["hint"].is_string());
}
+303
View File
@@ -0,0 +1,303 @@
//! `ull pack import` 的三态契约:退出码 0 / 3 / 1 + `--json` 信封恒有结构。
//!
//! **全部离线、全部不依赖 zip**:
//!
//! - MultiMC / Prism 的真实形态就是**目录**,直接拷 → `0`;
//! - mrpack 也能以**目录**打开(`PackArchive` 对目录与 zip 一视同仁),造一条 `downloads` 为空的
//! `files[]` 条目 ⇒ 补不出来 ⇒ `3`,不会发任何请求;
//! - 坏包用**一个不是 zip 的普通文件** ⇒ 开包即拒 ⇒ `1`(具体的恶意样本分类在 core 的
//! `addon/tests/import_failures.rs`,这里只钉 CLI 的退出码与 JSON 契约)。
#[path = "support/mod.rs"]
mod support;
use std::path::{Path, PathBuf};
use serde_json::Value;
use support::{run_cli, TempDir};
/// 写一个文件(自动建父目录)。
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
std::fs::write(path, bytes).expect("写文件");
}
/// 造一个最小 MultiMC / Prism 实例目录。
fn multimc_instance(root: &Path) -> PathBuf {
let instance = root.join("mmc");
write_file(
&instance.join("mmc-pack.json"),
br#"{"formatVersion": 1, "components": [
{"uid": "net.minecraft", "version": "1.20.1"},
{"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"},
{"uid": "org.lwjgl3", "version": "3.3.2"}]}"#,
);
write_file(
&instance.join("instance.cfg"),
b"InstanceType=OneSix\nname=cli pack\n",
);
write_file(&instance.join(".minecraft/mods/a.jar"), b"jar bytes");
instance
}
/// 造一个目录形式的 mrpack:一条远端条目,但 `downloads` 为空(补不出来 ⇒ 缺文件)。
fn mrpack_directory(root: &Path) -> PathBuf {
let source = root.join("mr-src");
write_file(
&source.join("modrinth.index.json"),
br#"{"game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
"name": "cli mrpack", "summary": "dir-form mrpack",
"files": [{"path": "mods/ghost.jar",
"hashes": {"sha1": "0123456789abcdef0123456789abcdef01234567",
"sha512": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
"env": {"client": "required", "server": "required"},
"downloads": [], "fileSize": 11}],
"dependencies": {"minecraft": "1.20.1", "fabric-loader": "0.15.11"}}"#,
);
write_file(&source.join("overrides/config/known.toml"), b"k = 1\n");
source
}
fn parse_json(stdout: &str) -> Value {
serde_json::from_str(stdout)
.unwrap_or_else(|error| panic!("stdout 不是合法 JSON({error}):{stdout}"))
}
#[test]
fn imports_a_multimc_instance_and_exits_zero() {
let data = TempDir::new("pack-ok");
let source = multimc_instance(data.path());
let target = data.path().join("inst");
let (code, stdout, stderr) = run_cli(&[
"pack",
"import",
&source.display().to_string(),
"--target",
&target.display().to_string(),
"--data-dir",
&data.arg(),
"--no-register",
"--json",
]);
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
let value = parse_json(&stdout);
assert_eq!(value["ok"], Value::Bool(true));
assert_eq!(value["data"]["status"], Value::String("success".into()));
assert_eq!(value["data"]["format"], Value::String("multimc".into()));
assert_eq!(
value["data"]["loader"]["kind"],
Value::String("fabric".into())
);
// 走过的阶段都记下来了。
let stages = value["data"]["stages"].as_array().expect("stages 数组");
assert!(stages.iter().any(|stage| stage == "probe"), "{stages:?}");
assert!(stages.iter().any(|stage| stage == "commit"), "{stages:?}");
// 内容真的落地了。
assert_eq!(
std::fs::read(target.join("mods/a.jar")).expect("a.jar"),
b"jar bytes"
);
// 未支持的 component 逐项进 JSON。
assert_eq!(
value["data"]["rejected_components"][0]["uid"],
Value::String("org.lwjgl3".into())
);
}
#[test]
fn missing_files_make_it_partial_and_exit_three() {
let data = TempDir::new("pack-partial");
let source = mrpack_directory(data.path());
let target = data.path().join("inst");
let (code, stdout, stderr) = run_cli(&[
"pack",
"import",
&source.display().to_string(),
"--target",
&target.display().to_string(),
"--data-dir",
&data.arg(),
"--no-register",
"--json",
]);
assert_eq!(
code, 3,
"部分成功是 3,不是 0/1:stdout={stdout} stderr={stderr}"
);
let value = parse_json(&stdout);
assert_eq!(value["data"]["status"], Value::String("partial".into()));
assert_eq!(
value["data"]["missing"].as_array().map(Vec::len),
Some(1),
"补不出来的条目要如实记缺:{stdout}"
);
// 覆盖内容照装。
assert!(target.join("config/known.toml").is_file());
}
#[test]
fn failed_import_exits_one_and_still_emits_structured_json() {
let data = TempDir::new("pack-fail");
let pack = data.path().join("broken.zip");
// 一个根本不是 zip 的文件:开包即拒。
write_file(&pack, b"this is not a zip archive");
let target = data.path().join("inst");
let (code, stdout, stderr) = run_cli(&[
"pack",
"import",
&pack.display().to_string(),
"--target",
&target.display().to_string(),
"--data-dir",
&data.arg(),
"--no-register",
"--json",
]);
assert_eq!(code, 1, "stdout={stdout} stderr={stderr}");
// 失败也**必须**是结构化 JSON(不抛栈、不打人类可读文本到 stdout)。
let value = parse_json(&stdout);
assert_eq!(value["ok"], Value::Bool(true));
assert_eq!(value["data"]["status"], Value::String("failed".into()));
assert!(
value["data"]["error"]["code"].is_string(),
"失败要带稳定码:{stdout}"
);
assert_eq!(value["data"]["rolled_back"], Value::Bool(true));
// 零残留。
assert!(!target.exists(), "致命失败后目标目录必须不存在");
// 失败也不是"崩了":stderr 里不该出现 panic 之类的栈信息。
assert!(!stderr.contains("panicked"), "{stderr}");
}
#[test]
fn other_commands_still_exit_zero() {
// `Outcome::exit_code` 是给 pack import 用的;其它命令的行为不许变。
let data = TempDir::new("pack-regression");
let (code, stdout, _) = run_cli(&["instances", "list", "--data-dir", &data.arg()]);
assert_eq!(code, 0, "{stdout}");
let (code, _, _) = run_cli(&["--help"]);
assert_eq!(code, 0);
let (code, _, _) = run_cli(&["config", "get", "--data-dir", &data.arg()]);
assert_eq!(code, 0);
}
#[test]
fn help_lists_the_pack_command() {
let (code, stdout, _) = run_cli(&["help", "pack"]);
assert_eq!(code, 0);
assert!(stdout.contains("pack import"), "{stdout}");
assert!(stdout.contains("部分成功"), "{stdout}");
let (code, stdout, _) = run_cli(&["--help"]);
assert_eq!(code, 0);
assert!(stdout.contains("pack import"), "总帮助里也要有:{stdout}");
}
/// **M216 P3-3 + §5**:不带 `--target` 时落点是**新实例目录**(不是共享 `.minecraft` 根),
/// 而且登记出来的实例被绑到那个目录(`isolation=custom` + `gameDir`),否则导入内容永远不会
/// 被启动用到。
///
/// 抓得住:CLI 自己兜底回 `ctx.game().root()`(第一条断言会变成 `.minecraft`);不建实例目录而
/// 直接铺共享根;不做绑定(`isolation` 仍是 `null`);绑定写错目录。抓不住:真实启动那一条
/// `derive_game_dir` 的派生(core 的 `isolation.rs` 用例钉,这里只断言设置文件的内容)。
#[test]
fn the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it() {
let data = TempDir::new("pack-default-target");
let source = multimc_instance(data.path());
let (code, stdout, stderr) = run_cli(&[
"pack",
"import",
&source.display().to_string(),
"--name",
"My Pack",
"--data-dir",
&data.arg(),
"--json",
]);
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
// **M216 r2**:绑定成败也进 `--json`(结构化,不必猜文案)。
let value = parse_json(&stdout);
assert_eq!(
value["data"]["instance_bound"],
Value::Bool(true),
"缺省导入应当把实例绑到内容目录并报 true:{stdout}"
);
let instance_dir = data.path().join(".ULL/data/instances/My-Pack");
assert!(
instance_dir.join("mods/a.jar").is_file(),
"缺省落点应当是 <data>/instances/My-Pack:{stdout}"
);
assert!(
!data.path().join(".minecraft/mods/a.jar").exists(),
"不许再往共享 .minecraft 根铺:{stdout}"
);
// 登记的实例被绑到这个目录(§5)。
let profiles: Value = serde_json::from_str(
&std::fs::read_to_string(data.path().join(".ULL/config/profiles.json"))
.expect("profiles.json"),
)
.expect("profiles.json 是 JSON");
assert_eq!(
profiles["profiles"][0]["name"],
Value::String("My Pack".into())
);
let id = profiles["profiles"][0]["id"]
.as_str()
.expect("登记出来的实例有 id")
.to_string();
let config: Value = serde_json::from_str(
&std::fs::read_to_string(
data.path()
.join(".ULL/config/instances")
.join(format!("{id}.json")),
)
.expect("实例设置文件"),
)
.expect("实例设置是 JSON");
assert_eq!(
config["isolation"],
Value::String("custom".into()),
"isolation 应当是 custom:{config}"
);
assert_eq!(
config["gameDir"].as_str().map(Path::new),
Some(instance_dir.as_path()),
"gameDir 应当指向导入目录:{config}"
);
}
/// **M216 P2-1 / r2**:`--help` 里的缺省落点必须是**真路径**。
///
/// 「数据根」在这份帮助自己的定义里是「含 `.ULL/` 与 `.minecraft/` 的父目录」(`--data-dir` 那行),
/// 所以实例目录的真路径是 `<数据根>/.ULL/data/instances/<目录名>`。r1 写成了
/// `<数据根>/instances/<实例名>` —— 少一层 `.ULL/data`,照读会去一个不存在的位置找包内容。
///
/// 抓得住:缺 `.ULL/data` 这一层(下面第二条断言就是抓它的——少写时该子串恰好出现);
/// 文案退回「共享游戏目录」;目录名归一化这件事没提。抓不住:`model.rs` 的字段文档
/// (编译期文档,测试读不到),以及真实 `--data-dir` 下的绝对路径拼接(那由上面那条端到端
/// 用例按真目录断言)。
#[test]
fn help_describes_the_new_default_target_with_the_real_path() {
let (code, stdout, _) = run_cli(&["help", "pack"]);
assert_eq!(code, 0);
assert!(
stdout.contains("<数据根>/.ULL/data/instances/<实例目录名>"),
"帮助里没写真路径 <数据根>/.ULL/data/instances/<实例目录名>:{stdout}"
);
assert!(
!stdout.contains("<数据根>/instances/"),
"帮助里的落点少了 `.ULL/data` 这一层(r2 抓的就是这个错法):{stdout}"
);
assert!(
!stdout.contains("缺省 = 数据根的共享游戏目录"),
"帮助里还留着旧缺省落点:{stdout}"
);
}
+384
View File
@@ -0,0 +1,384 @@
//! 子命令解析:手写解析器的语法契约。
#[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());
}
+278
View File
@@ -0,0 +1,278 @@
//! 集成测试共用夹具:临时数据根、命令执行器、最小版本 JSON / 会话索引。
//!
//! 每个测试目标都会整份编译本文件但只用其中一部分,因此这里放开 dead_code。
#![allow(dead_code)]
pub mod update_e2e;
pub mod update_e2e_sign;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
/// 一次性临时目录(`Drop` 时清理)。
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
/// 建一个唯一目录。
pub fn new(tag: &str) -> Self {
Self::in_root(&std::env::temp_dir(), tag).expect("创建临时目录")
}
/// 建在**另一块卷**上(跨卷验收用);本机只有一块可写卷时返回 `None`。
pub fn on_other_volume(tag: &str) -> Option<Self> {
other_volume_root().and_then(|root| Self::in_root(&root, tag))
}
fn in_root(root: &Path, tag: &str) -> Option<Self> {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = root.join(format!(
"ull-cli-test-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).ok()?;
Some(Self { path })
}
/// 目录路径。
pub fn path(&self) -> &Path {
&self.path
}
/// 目录的字符串形式(喂给 `--data-dir`)。
pub fn arg(&self) -> String {
self.path.display().to_string()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// 指定异卷根目录的环境变量(本机找不到第二块卷时,用它显式指一块)。
pub const XDEV_ROOT_ENV: &str = "ULL_XDEV_TEST_ROOT";
/// 「我知道本机没有第二块卷」的显式豁免(值必须是 `1`)。
///
/// 与 `crates/core/src/update/tests/support.rs` 的同名常量是**同一条政策**(源头在
/// `crates/core/src/update/tests/install_xdev_cases.rs::xdev_guard`,改一处要改两处):
/// 探不到第二块卷又不豁免 ⇒ 这条 e2e 必须红,不许静静 `return`(M171 r2 P2-1)。
pub const XDEV_WAIVE_ENV: &str = "ULL_XDEV_TEST_WAIVE";
/// 「本机/本作业必须有第二块卷」的显式声明(值必须是 `1`),优先于豁免。
pub const XDEV_REQUIRE_ENV: &str = "ULL_XDEV_TEST_REQUIRE";
/// 跨卷 e2e 探不到第二块卷时的裁决:**显式豁免**才允许跳过(打印一行记录),否则 panic。
///
/// `what` 只用来在输出里点名是哪条用例,裁决规则与 core 侧完全一致(见 [`XDEV_WAIVE_ENV`])。
pub fn cross_volume_waived(what: &str) {
let required = std::env::var(XDEV_REQUIRE_ENV).ok();
let waived = std::env::var(XDEV_WAIVE_ENV).ok();
let required = required.as_deref().map(str::trim) == Some("1");
let waived = waived.as_deref().map(str::trim) == Some("1");
assert!(
waived && !required,
"[xdev-guard] {what}:本机没有第二块可写卷,也没有显式豁免——跨卷 e2e 空跑了,\
所以判红。三种合规做法任选其一:\
① ULL_XDEV_TEST_ROOT=<另一块卷上的可写目录>;② ULL_XDEV_TEST_WAIVE=1(值必须是 1);\
③ 认为本机本该有第二块卷就用 ULL_XDEV_TEST_REQUIRE=1 声明。\
政策与理由见 crates/core/src/update/tests/install_xdev_cases.rs 的模块文档。"
);
eprintln!(
"[xdev-guard] {what}:本机没有第二块可写卷,已显式豁免({XDEV_WAIVE_ENV}=1),\
这条 e2e 跳过。"
);
}
/// 一块与会话临时目录**真的**不同卷的可写根目录(探出来的,不是按平台猜的)。
///
/// 候选:`ULL_XDEV_TEST_ROOT` → Windows 各盘符的 `Temp`(没有就盘根)/ Unix 的 `TMPDIR`、
/// `/dev/shm`、`/tmp`、`/run/user/<uid>`。每个候选都要过「从 temp 目录 rename 进它必须报
/// `CrossesDevices`」这一关,所以返回值**一定**是异卷——同一块卷上的候选会被跳过,
/// 否则跨卷用例会在同卷上跑,等于什么都没验。
pub fn other_volume_root() -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(root) = std::env::var_os(XDEV_ROOT_ENV) {
candidates.push(PathBuf::from(root));
}
candidates.extend(volume_candidates());
candidates
.into_iter()
.find(|candidate| is_other_volume(candidate))
}
fn volume_candidates() -> Vec<PathBuf> {
#[cfg(windows)]
{
('A'..='Z')
.map(|letter| PathBuf::from(format!("{letter}:/")))
.filter(|root| root.is_dir())
.map(|root| {
if root.join("Temp").is_dir() {
root.join("Temp")
} else {
root
}
})
.collect()
}
#[cfg(not(windows))]
{
let mut candidates: Vec<PathBuf> = ["TMPDIR", "TMP", "TEMP"]
.iter()
.filter_map(|key| std::env::var_os(key).map(PathBuf::from))
.collect();
for root in ["/dev/shm", "/tmp", "/var/tmp"] {
candidates.push(PathBuf::from(root));
}
if let Some(uid) = std::env::var_os("UID") {
candidates.push(PathBuf::from("/run/user").join(uid));
}
candidates
}
}
fn is_other_volume(dir: &Path) -> bool {
if dir.as_os_str().is_empty() || std::fs::create_dir_all(dir).is_err() {
return false;
}
let probe = std::env::temp_dir().join(format!("ull-cli-probe-{}", std::process::id()));
if std::fs::write(&probe, b"probe").is_err() {
return false;
}
let landed = dir.join(format!("ull-cli-probe-{}", std::process::id()));
let cross = matches!(
std::fs::rename(&probe, &landed),
Err(ref error) if error.kind() == std::io::ErrorKind::CrossesDevices
);
let _ = std::fs::remove_file(&probe);
let _ = std::fs::remove_file(&landed);
cross
}
/// 跑一次 CLI,返回(退出码, stdout, stderr)。
pub fn run_cli(args: &[&str]) -> (i32, String, String) {
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
let code = unlucky_cli::run(&owned, &mut out, &mut err);
(
code,
String::from_utf8_lossy(&out).into_owned(),
String::from_utf8_lossy(&err).into_owned(),
)
}
/// 解析 `--json` 输出(断言它是合法 JSON 并返回)。
pub fn parse_json(stdout: &str) -> serde_json::Value {
serde_json::from_str(stdout).unwrap_or_else(|error| panic!("输出不是 JSON:{error}\n{stdout}"))
}
/// 写一份最小可组装的新版版本 JSON + 空 client jar。
pub fn write_version(data_root: &Path, id: &str) {
let dir = data_root.join(".minecraft").join("versions").join(id);
std::fs::create_dir_all(&dir).expect("创建版本目录");
let version = serde_json::json!({
"id": id,
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}"],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
});
std::fs::write(
dir.join(format!("{id}.json")),
serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"),
)
.expect("写版本 JSON");
std::fs::write(dir.join(format!("{id}.jar")), b"junk").expect("写 client jar");
}
/// 写 `config/profiles.json`(实例名 → 版本 id)。
pub fn write_profiles(data_root: &Path, profiles: &[(&str, &str)]) {
let dir = data_root.join(".ULL").join("config");
std::fs::create_dir_all(&dir).expect("创建配置目录");
let entries: Vec<serde_json::Value> = profiles
.iter()
.enumerate()
.map(|(index, (name, version))| {
serde_json::json!({
"id": format!("11111111-2222-4333-8444-{:012}", index + 1),
"name": name,
"version_id": version,
})
})
.collect();
let document = serde_json::json!({ "schema_version": 1, "profiles": entries });
std::fs::write(
dir.join("profiles.json"),
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
)
.expect("写 profiles.json");
}
/// 写一份「退出码 1 + OOM」的会话索引与事件流,返回会话 id。
///
/// **形状必须与生产写入一致**(M211/D2):`log_path` 是**相对会话根**的 `game/<sid>.jsonl`,
/// 事件流真落点是 `<会话根>/game/<sid>.jsonl`。这里用的是旧家(`data/agent/sessions`)作会话根
/// ——那是 M188 之前的生产落点,也是用户盘上存量会话的真实形状,读取侧必须还能读回它们。
/// 夹具此前写的是不带 `game/` 的 `"{sid}.jsonl"`,于是「读取侧多 join 一层 `game/`」这个 bug
/// 在测试里永远看不见:夹具自己说了另一种形状。
pub fn write_session(data_root: &Path) -> String {
let sid = "20261030T101500-abcdef";
let dir = data_root
.join(".ULL")
.join("data")
.join("agent")
.join("sessions")
.join("game");
std::fs::create_dir_all(&dir).expect("创建会话目录");
let events = [
serde_json::json!({"v":1,"seq":0,"ts":"2026-10-30T10:15:00Z","t_ms":0,"kind":"session_start","session_id":sid,"payload":{"instance_name":"生存服","game_version":"1.20.1"}}),
serde_json::json!({"v":1,"seq":1,"ts":"2026-10-30T10:15:02Z","t_ms":1500,"kind":"stdout","session_id":sid,"payload":{"line":"[main/INFO]: Loading Minecraft 1.20.1","level":"info"}}),
serde_json::json!({"v":1,"seq":2,"ts":"2026-10-30T10:15:05Z","t_ms":4200,"kind":"stderr","session_id":sid,"payload":{"line":"java.lang.OutOfMemoryError: Java heap space","level":"error"}}),
];
let mut text = String::new();
for event in events {
text.push_str(&serde_json::to_string(&event).expect("序列化事件"));
text.push('\n');
}
std::fs::write(dir.join(format!("{sid}.jsonl")), text).expect("写事件流");
let entry = serde_json::json!({
"v": 1,
"session_id": sid,
"instance_id": "11111111-2222-4333-8444-555555555555",
"instance_name": "生存服",
"game_version": "1.20.1",
"started_at": "2026-10-30T10:15:00Z",
"ended_at": "2026-10-30T10:15:06Z",
"duration_ms": 5000,
"exit_code": 1,
"exit_type": "application_error",
"silent_failure": false,
"window_detected": false,
"event_count": 3,
"log_path": unlucky_core::launch::session_index::log_path_for(sid),
"crash_found": false,
"diagnosis_top": null
});
std::fs::write(
dir.join("index.jsonl"),
format!("{}\n", serde_json::to_string(&entry).expect("序列化索引")),
)
.expect("写会话索引");
sid.to_string()
}
+348
View File
@@ -0,0 +1,348 @@
//! 更新通道端到端验收的夹具(M169):临时现场、真形状清单、测试通道的环境变量守卫。
//!
//! 两个测试目标共用(`update_e2e_test` 正路 / 幂等,`update_e2e_tamper_test` 篡改);每个目标只
//! 用到其中一部分,所以与 `support/mod.rs` 一样放开 dead_code。
//!
//! 整个夹具只在 `update-test-source` feature 下真的被用到:那两个测试目标自己 `#![cfg]` 掉,
//! 这里只是跟着编译(不跑)。判据(feature + 两档环境变量)在 `core::update::job`。
//!
//! # 为什么这套「真形状」只在 Windows 上成立(r1 复审 P2-4)
//!
//! 产物名与形态按 **Windows** 的真实发布走:`unlucky-launcher_<ver>_windows_x64.exe` +
//! `portable-exe`(口径见 `packaging/make_artifacts.py` 的头注释)。Linux/macOS 的真实产物是
//! `.tar.gz` + `portable-zip`,会被 `ensure_self_replaceable` **直接拒**(不是可原地自替换的形态)。
//!
//! * 在 Windows 上跑 = 验真实形状;
//! * 在别的平台上跑绿只能证明「一个世上不存在的 linux/macOS portable-exe 能装上」——**假绿**。
//!
//! 所以 CI 里它是**一个独立的 Windows job**(`.github/workflows/ci.yml` 的 `update-e2e`,
//! `runs-on: windows-latest`),本文件也**不**为非 Windows 平台改断言:要真支持,得让产物名与
//! `format` 一起按平台分叉,那是另一个 mission 的事(真实 Linux 自更新根本不支持,D50 只发裸 exe)。
//!
//! ⚠ **验收必须给 `--dest`**:用例在**进程内**跑 `unlucky_cli::run`,`Job::for_process` 的
//! 「本进程的 exe」就是测试二进制——`apply` 不带 `--dest` 会真的把它 `rename` 成新字节
//! (Windows 允许改名正在运行的映像,见 `core::update::install` 的文档),整轮测试当场崩。
//! `Fixture::run` 里有结构性断言兜这一条(r1 复审 P2-5),不是只靠这段注释。
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use super::update_e2e_sign::write_signature;
use unlucky_core::update::{HashAlgo, OsFamily, Target, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV};
/// 假装的「已安装版本」字节:与产物**必须不同**,否则「目标没被动过」这类断言会假绿。
pub const INSTALLED: &[u8] = b"installed 0.2.3 build (pretend)";
/// 产物体量上限(1 MiB)。
///
/// 现场构建出来的 `ull.exe` 是 **~400 MB**(debug + 全套 reqwest/rmcp/axum);拿整份当过测材,
/// 光 sha256 + 拷贝就要几十秒,验收会变成「谁都不想跑」。所以截取**前 1 MiB**:PE 头与头几节都在,
/// 「这是真 exe 的字节」这一点保留,代价是「装上去能不能启动」不再由它证明——那一跳本来就要人工
/// (见报告「不验的部分」)。清单里的 `size` / `sha256` 按这份截短后的真实字节算,仍然自洽。
const PAYLOAD_LIMIT: usize = 1024 * 1024;
/// 清单里的更新说明(断言它会原样出现在 `check --json` 里)。
const NOTES: &str = "修掉启动崩溃;实例页加载更快";
/// SHA-256 按真实字节算(与双检同一条实现:`core::download::verify::digest_bytes`)。
pub fn sha256(bytes: &[u8]) -> String {
unlucky_core::download::verify::digest_bytes(HashAlgo::Sha256, bytes)
}
/// 清单版本:**比编译期版本严格大一档次版本**。
///
/// 不能写死 `0.2.4`:workspace 版本一升,用例就变成「已是最新」的假红。次版本 +1 恒大于当前
/// 版本,且仍在同一 major 内(不会意外触发 `min_supported` 那条判据)。
pub fn next_version(current: &str) -> String {
let mut parts = current.split('.');
let major = parts.next().unwrap_or("0");
let minor: u64 = parts
.next()
.unwrap_or("0")
.parse()
.unwrap_or_else(|_| panic!("编译期版本号应是三段式:{current}"));
assert!(parts.next().is_some(), "编译期版本号应是三段式:{current}");
format!("{major}.{}.0", minor + 1)
}
/// 真实产物命名(D49):`unlucky-launcher_<ver>_windows_x64.exe`。
///
/// 拿 `Target::as_str()`(`win-x64`)直接拼会得到一个世上不存在的文件名,清单那一栏也就
/// 与真实发布对不上——「真形状」的意义正在这里。
fn asset_name(version: &str) -> String {
let target = Target::current();
let os = match target.os {
OsFamily::Windows => "windows",
OsFamily::MacOs => "macos",
OsFamily::Linux => "linux",
};
format!(
"unlucky-launcher_{version}_{os}_{}.exe",
target.arch.as_str()
)
}
/// 一份与 `packaging/tests/manifest.golden.json` **同字段**的清单。
///
/// 字段一个不多一个不少:core 的 `deny_unknown_fields` 会把多余字段当清单损坏拒掉,所以这条
/// 同时也是「清单形状与真实生成器(`packaging/make_manifest.py`)对齐」的断言。
fn manifest(version: &str, asset: &str, size: u64, sha256: &str) -> String {
format!(
r#"{{
"channel": "stable",
"releases": [
{{
"version": "{version}",
"channel": "stable",
"released_at": "2026-01-02T03:04:05Z",
"notes": "{NOTES}",
"force": false,
"min_supported": "0.1.0",
"artifacts": [
{{
"target": "{}",
"format": "portable-exe",
"url": "{asset}",
"size": {size},
"sha256": "{sha256}"
}}
]
}}
]
}}"#,
Target::current().as_str()
)
}
/// 进程环境是共享的(同一测试进程里各用例并行):动环境变量的用例都拿这把锁串起来。
pub fn channel_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// 两个测试通道变量的现场:构造时记住原值,`Drop` 还原(用例 panic 也还原)。
pub struct Channel {
dir: Option<std::ffi::OsString>,
ack: Option<std::ffi::OsString>,
}
impl Channel {
pub fn capture() -> Self {
Self {
dir: std::env::var_os(UPDATE_DIR_ENV),
ack: std::env::var_os(UPDATE_TEST_SOURCE_ENV),
}
}
/// 测试通道开启:两档环境变量全给(二进制还得带 `update-test-source` feature;只给路径不会被
/// 认,判据见 `core::update::job`)。
pub fn enable(root: &Path) {
std::env::set_var(UPDATE_DIR_ENV, root);
std::env::set_var(UPDATE_TEST_SOURCE_ENV, "1");
}
}
impl Drop for Channel {
fn drop(&mut self) {
for (key, value) in [
(UPDATE_DIR_ENV, self.dir.clone()),
(UPDATE_TEST_SOURCE_ENV, self.ack.clone()),
] {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
/// `--json` 信封 → 命令载荷。
///
/// 成功信封是 `{"ok":true,"data":{…},"warnings":[…]}`,失败信封是 `{"ok":false,"error":{…}}`
/// (没有 `data`)。所以「取载荷」= 有 `data` 取 `data`,否则原样返回——失败用例照样能看
/// `error.code` / `error.detail.kind`。
pub fn payload(json: &serde_json::Value) -> &serde_json::Value {
json.get("data").unwrap_or(json)
}
/// 目录里的文件名(读不到就当空)。
fn list_dir(dir: &Path) -> Vec<String> {
match std::fs::read_dir(dir) {
Ok(entries) => entries
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect(),
Err(_) => Vec::new(),
}
}
/// 一次验收的全部现场:
///
/// ```text
/// <temp>/<tag>-data/ --data-dir(自定义模式 ⇒ <data>/.ULL 与 <data>/.minecraft)
/// <temp>/<tag>-dist/ ULL_UPDATE_DIR(stable.json + 产物)
/// <temp>/<tag>-install/ --dest 所在目录(**临时副本**,不是本进程的 exe)
/// ```
///
/// 三个 `TempDir` 都要活到用例结束(`Fixture` 持有它们)。
pub struct Fixture {
pub data: super::TempDir,
pub dist: super::TempDir,
install: super::TempDir,
/// 产物字节 = 现场构建出来的 `ull.exe` 的前 1 MiB(真 PE 头,理由见 [`PAYLOAD_LIMIT`])。
pub payload: Vec<u8>,
/// 清单里的版本号(= 编译期版本次版本 +1)。
pub version: String,
/// 产物文件名(清单 `url` 与盘上文件同名)。
pub asset: String,
/// 替换目标(安装目录里的副本)。
pub dest: PathBuf,
}
/// 现场构建出来的 `ull.exe` 的前 [`PAYLOAD_LIMIT`] 字节(读一次,整份不超过 1 MiB)。
///
/// 为什么不用占位字符串:这是**发布矩阵里那个单文件 exe** 的真字节,PE 头、节表、体积都对得上;
/// 为什么截短:整份 ~400 MB,测材太重(理由见 [`PAYLOAD_LIMIT`])。
fn payload_bytes() -> Vec<u8> {
let path = env!("CARGO_BIN_EXE_ull");
let mut payload = std::fs::read(path).expect("读构建出来的 ull.exe");
payload.truncate(PAYLOAD_LIMIT);
assert!(
payload.len() >= 64 * 1024,
"产物要像真的(至少 64 KiB),实际 {} 字节",
payload.len()
);
if cfg!(windows) {
assert!(
payload.starts_with(b"MZ"),
"Windows 产物得是 PE 映像(前两个字节 MZ),否则「真形状」这句话就没意义"
);
}
payload
}
impl Fixture {
pub fn new(tag: &str) -> Self {
Self::with_install(tag, super::TempDir::new(&format!("{tag}-install")))
}
/// 安装目录在**另一块卷**上的现场(数据根与暂存仍在会话 temp):M171 的真机跨卷形态。
///
/// 找不到第二块可写卷时返回 `None`(调用方跳过并说明原因)——这条用例在只有一块卷的
/// 机器/CI 上**不生效**,跨卷逻辑本身由 `crates/core` 的注入用例守着。
pub fn cross_volume(tag: &str) -> Option<Self> {
let install = super::TempDir::on_other_volume(&format!("{tag}-install"))?;
Some(Self::with_install(tag, install))
}
fn with_install(tag: &str, install: super::TempDir) -> Self {
let payload = payload_bytes();
let version = next_version(env!("CARGO_PKG_VERSION"));
let dest = install.path().join("unlucky-launcher.exe");
std::fs::write(&dest, INSTALLED).expect("写旧版本");
Self {
data: super::TempDir::new(&format!("{tag}-data")),
dist: super::TempDir::new(&format!("{tag}-dist")),
install,
asset: asset_name(&version),
payload,
version,
dest,
}
}
/// 发一版:清单 + 产物都按 `payload` 的真实长度与摘要。
pub fn publish_good(&self) {
self.publish(&sha256(&self.payload), self.payload.len() as u64);
}
/// 发一版,摘要/长度由调用方给(篡改用例用)。
pub fn publish(&self, sha256: &str, size: u64) {
std::fs::write(self.dist.path().join(&self.asset), &self.payload).expect("写产物");
let path = self.dist.path().join("stable.json");
std::fs::write(&path, manifest(&self.version, &self.asset, size, sha256)).expect("写清单");
// M190:清单与签名成对落盘。篡改用例给的是「错的 sha/size」,签的是**改过之后**那份清单
// —— 那正是「清单本身可信、包体不对」的现场(摘要判据要独立于签名判据被证到)。
write_signature(&path);
}
/// 跑一条 `update` 子命令:`--data-dir` 指向临时数据根(与 GUI 一样由数据根派生暂存目录)。
///
/// **结构性保险(r1 复审 P2-5 / r2 复审 P2-3)**:`apply` 必须显式带 `--dest`,且**这一次调用实际
/// 传下去的那个 `--dest` 值**不许是本进程的 exe。不带 `--dest` 就是 `Job::for_process` 的「本进程
/// exe」——在这套用例里那是**测试二进制**,而 Windows 允许改名正在运行的映像,于是整轮测试会把它
/// 换成一个假启动器(症状极难查)。
///
/// 比的是 **args 里那个 `--dest` 的值**(含 `--dest=<path>` 形式),不是夹具字段:r2 复审指出
/// 「比 `self.dest`」等于只守住了夹具自己那一份,手写
/// `fx.run(&["apply", "--dest", "<测试二进制>", "--json"])` 照样能过。夹具字段那一份作为额外一道
/// 保留(`Fixture::apply()` 走的就是它)。
///
/// 抓得住:漏 `--dest`、把 `--dest` 指到本进程 exe、夹具 dest 被改成 exe——都**当场 panic**。
/// 抓不住:绕开 `Fixture` 直接调 `unlucky_cli::run`(那等于另写一套夹具,`--data-dir` 也没人拼了)。
pub fn run(&self, args: &[&str]) -> (i32, String, String) {
let requested = args
.iter()
.position(|arg| *arg == "--dest")
.and_then(|index| args.get(index + 1).copied())
.or_else(|| args.iter().find_map(|arg| arg.strip_prefix("--dest=")));
if args.first() == Some(&"apply") {
assert!(
requested.is_some(),
"apply 必须带 --dest:不带会替换本进程 exe(这套用例里就是测试二进制)。\
唯一的 apply 入口是 `Fixture::apply()`"
);
}
if let Ok(exe) = std::env::current_exe().and_then(std::fs::canonicalize) {
let asked = requested.and_then(|path| std::fs::canonicalize(path).ok());
let fixture = std::fs::canonicalize(&self.dest).ok();
for dest in [asked, fixture].into_iter().flatten() {
assert_ne!(
dest, exe,
"替换目标不许是本进程的 exe(`--dest` 点的就是它)"
);
}
}
let mut full = vec!["update"];
full.extend_from_slice(args);
let data_dir = self.data.arg();
full.push("--data-dir");
full.push(data_dir.as_str());
super::run_cli(&full)
}
/// 跑 `apply --dest <副本> --json`(**永远**带 `--dest`,见文件头)。
pub fn apply(&self) -> (i32, serde_json::Value) {
let dest = self.dest.display().to_string();
let (code, out, _) = self.run(&["apply", "--dest", &dest, "--json"]);
(code, super::parse_json(&out))
}
/// 暂存目录(`<数据根>/.ULL/cache/update/staging`,与 `core::update::staging_dir` 同形)。
pub fn staging(&self) -> PathBuf {
self.data
.path()
.join(".ULL")
.join("cache")
.join("update")
.join("staging")
}
/// 暂存目录里还留着什么(`StagedPackage` 落地/被搬走之后应为空)。
pub fn staging_leftovers(&self) -> Vec<String> {
list_dir(&self.staging())
}
/// 安装目录里的备份(`.unlucky-launcher.exe.ullold-*`)。
pub fn backups(&self) -> Vec<String> {
list_dir(self.install.path())
.into_iter()
.filter(|name| name.contains(".ullold-"))
.collect()
}
/// 目标文件当前字节。
pub fn installed_bytes(&self) -> Vec<u8> {
std::fs::read(&self.dest).expect("读目标")
}
}
@@ -0,0 +1,69 @@
//! 更新 e2e 夹具的**签名**部分(M190):用 `packaging/sign_manifest.py` 给清单签 `.sig`。
//!
//! 为什么单独一个文件:`support/update_e2e.rs` 已经贴着全仓 400 行红线(M190 r1 复审 P1-3),
//! 而签名这一摊只被 `Fixture::publish` 用到。搬出来是纯搬家,语义未改。
//!
//! 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒
//! 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带
//! python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。
use std::path::Path;
/// 测试签名钥的种子(**公开**:只对 `update-test-source` 构建有意义,正式信任表里没有这把钥,
/// 见 `crates/core/src/update/trust.rs::TEST_ONLY`)。
const TEST_KEY_SEED: &str = "ull-m190-test-key-seed-000000001";
/// 测试钥的 `key_id`(与 `trust.rs::TEST_ONLY` 一致)。
const TEST_KEY_ID: &str = "test-only-key";
/// 用 `packaging/sign_manifest.py` 给刚落盘的清单签一份 `.sig`(M190)。
///
/// 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒
/// 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带
/// python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。
pub fn write_signature(manifest_path: &Path) {
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packaging/sign_manifest.py");
assert!(script.is_file(), "找不到签名脚本:{}", script.display());
let seed_hex: String = TEST_KEY_SEED
.as_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
let secret = format!(r#"{{"key_id":"{TEST_KEY_ID}","seed_hex":"{seed_hex}"}}"#);
let mut command = None;
for candidate in ["python3", "python"] {
if std::process::Command::new(candidate)
.arg("--version")
.output()
.is_ok_and(|output| output.status.success())
{
command = Some(candidate);
break;
}
}
let python = command.unwrap_or_else(|| panic!("这套用例需要 python3/python 跑 {script:?}"));
let output = std::process::Command::new(python)
.arg(&script)
.arg("--sign")
.arg(manifest_path)
.env("ULL_MANIFEST_SIGNING_KEY", &secret)
.output()
.expect("跑签名脚本");
assert!(
output.status.success(),
"签名脚本失败:stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
manifest_path
.with_file_name(format!(
"{}.sig",
manifest_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
))
.is_file(),
"签名脚本没产出 .sig"
);
}
+107
View File
@@ -0,0 +1,107 @@
//! 更新通道端到端验收(M169)的**拒绝侧**:篡改摘要、篡改包体、截断包体。
//!
//! 正路与幂等在 `update_e2e_test.rs`(现场说明、抓得住/抓不住、不验的部分都在那边,别重复读)。
//! 这里只钉一条底线:**校验不过就一个字节都不许落到目标上**,而且失败不会把现场搞脏到
//! 「修好清单也装不上」。
//!
//! ⚠ 这三例**抓不住回滚/备份**:摘要与长度失败都发生在 `install_staged` 之前,那一步走不到
//! (r1 复审 ⑤ 的变异实测:去掉 dest 备份后这三例仍全绿)。回滚由 `update_e2e_test.rs` 的备份
//! 断言与 `core::update::tests::install_cases` 守。
//!
//! 一键跑(必须带 feature;tower 定案:验收落在 release profile 上):
//!
//! ```text
//! cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_tamper_test
//! ```
//!
//! 不带 feature 时整个目标 `#![cfg]` 掉(0 个用例)——测试通道在那种构建里不存在。
#![cfg(feature = "update-test-source")]
#[path = "support/mod.rs"]
mod support;
use support::update_e2e::{channel_lock, sha256, Channel, Fixture, INSTALLED};
/// 清单的 `sha256` 与盘上字节不符 ⇒ 拒绝,且**现有安装一个字节都不动**。
///
/// 抓得住:校验和判据被绕过(写成恒真、先搬后校、只比长度)。抓不住:清单与包体一起被换
/// (同时改 sha),那条路上没有可信根。
#[test]
fn a_tampered_digest_is_refused_and_leaves_the_install_intact() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-digest");
Channel::enable(fx.dist.path());
// 长度对、摘要错:先过长度判据,才能测到摘要这一关。
fx.publish(&sha256(b"another build entirely"), fx.payload.len() as u64);
let (code, json) = fx.apply();
assert_ne!(code, 0, "摘要不符必须拒绝(实际退出码 0):{json}");
assert_eq!(json["error"]["code"], "update_apply_failed", "{json}");
assert_eq!(json["error"]["detail"]["kind"], "digest_mismatch", "{json}");
assert_eq!(fx.installed_bytes(), INSTALLED, "校验失败不许动目标文件");
assert!(
fx.backups().is_empty(),
"还没走到替换就失败了,安装目录里不该出现 .ullold:{:?}",
fx.backups()
);
assert!(
fx.staging().exists(),
"下载过才会建暂存目录(说明失败点在双检,不是根本没下)"
);
assert!(
fx.staging_leftovers().is_empty(),
"校验失败的 .ullupd 必须被删掉:{:?}",
fx.staging_leftovers()
);
// 修好清单再装一次:失败没把现场搞脏,这条路还能走通。
fx.publish_good();
let (code, json) = fx.apply();
assert_eq!(code, 0, "修好清单之后应能装上:{json}");
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "installed", "{json}");
assert_eq!(fx.installed_bytes(), fx.payload);
}
/// 产物被换(清单没动)⇒ 一样拒绝:篡改落在哪一侧都能抓到。
#[test]
fn a_tampered_artifact_is_refused_too() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-artifact");
Channel::enable(fx.dist.path());
fx.publish_good();
// 盘上换成同长度的另一份字节:只有摘要能抓(长度判据看不出区别)。
let mut evil = fx.payload.clone();
evil[0] ^= 0xff;
std::fs::write(fx.dist.path().join(&fx.asset), &evil).expect("篡改产物");
let (code, json) = fx.apply();
assert_ne!(code, 0, "篡改的包必须拒绝:{json}");
assert_eq!(
json["error"]["detail"]["kind"], "digest_mismatch",
"篡改产物应当报摘要不符:{json}"
);
assert_eq!(fx.installed_bytes(), INSTALLED);
assert!(fx.staging_leftovers().is_empty());
}
/// 包体被截断(清单长度按完整包算)⇒ 长度判据先拒:不读摘要、不碰目标。
#[test]
fn a_truncated_payload_is_refused_as_size_mismatch() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-truncated");
Channel::enable(fx.dist.path());
fx.publish_good();
let full = fx.payload.clone();
std::fs::write(fx.dist.path().join(&fx.asset), &full[..full.len() / 2]).expect("截断产物");
let (code, json) = fx.apply();
assert_ne!(code, 0, "截断的包必须拒绝:{json}");
assert_eq!(json["error"]["detail"]["kind"], "size_mismatch", "{json}");
assert_eq!(fx.installed_bytes(), INSTALLED);
}
+259
View File
@@ -0,0 +1,259 @@
//! 更新通道端到端验收(M169):**临时数据根 + 本地更新源 + 真形状的清单与产物**,
//! 从 CLI 入口走完「发现新版本 → 下载 → 长度/SHA256 双检 → 就位」。
//!
//! 一键跑(**必须带 feature**;tower 要求验收落在 release profile 上,debug 与 release 的
//! `debug_assertions`、优化与 panic 行为都不同,用 debug 验收等于没验真实产物):
//!
//! ```text
//! cargo test --release -p unlucky-cli --features update-test-source \
//! --test update_e2e_test --test update_e2e_tamper_test
//! ```
//!
//! (两个测试目标各跑一半:本文件是正路 + 幂等,`update_e2e_tamper_test` 是篡改/截断。)
//!
//! 不带 `--features update-test-source` 时整个目标 `#![cfg]` 掉(**0 个用例**,不是绿):测试通道
//! 在那种构建里根本不存在,「环境变量设满也走 GitHub」由 `crates/core` 的
//! `the_test_channel_is_a_compile_time_opt_in` 在**不带 feature 的跑法**下守着。
//!
//! 为什么需要 feature:`ULL_UPDATE_DIR` 曾经只在 `debug_assertions` 下生效,于是发布产物(release)
//! **没法**指向本地源,这条链在 release 上从来没有端到端证据。M169 把判据换成编译期 feature
//! (默认关 ⇒ 官方二进制里没有这条路径),验收构建显式打开它。
//!
//! # 现场
//!
//! 见 `tests/support/update_e2e.rs`:数据根 / 本地源 / 安装目录三份临时目录,产物是现场构建出来的
//! `ull.exe` 的**前 1 MiB**(真 PE 头、真字节;整份 ~400 MB,测材太重在 `PAYLOAD_LIMIT` 的注释里
//! 写了原因),清单与 `packaging/tests/manifest.golden.json` 同字段。**`--dest` 永远指向临时副本**
//! ——这个文件里没有一处会替换本进程的 exe。
//!
//! # 抓得住什么 / 抓不住什么
//!
//! | 本套断言 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | 清单 → 选版本 → 下载 → 双检 → 就位整环跑通 | 任一环断了(字段拼错、渠道不符、没下、没校验、没搬)都会红 | 真 GitHub Release 的下载链路(本套一个字节不出网) |
//! | 清单 `sha256` 与盘上字节不符 ⇒ 拒绝 + 目标不动 | 「校验和判据被绕过/写成恒真」;篡改包体或篡改清单都能抓(见 `update_e2e_tamper_test`) | 「CDN 换了内容**而清单里的 sha 也一起换**」——这条路上没有可信根,只能靠 GitHub 侧 |
//! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 |
//! | 重复 `apply` 后目标字节稳定、暂存无残留、只留一份备份 | 「第二次把 dest 写坏 / 暂存留下垃圾 / 备份无限堆积」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe |
//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷失败路径:本文件只验了跨卷**成功**那一半(见下一行);跨卷下的中途失败由 `install_io_cases` 注入验 |
//! | 跨卷 `apply`(数据根一卷、安装目录另一卷,M171) | 「rename 不能跨卷 ⇒ 更新必然失败」这条已发布版本上的真 bug;用例自己先证明前提(rename 报 `CrossesDevices`) | 只有一块可写卷的机器上**只有显式豁免才允许跳过**(`ULL_XDEV_TEST_WAIVE=1`,探不到又没豁免 ⇒ 红);macOS 的跨卷行为一台没跑过 |
//! | 失败路径三例(`update_e2e_tamper_test`:篡改清单 sha / 篡改包体 / 截断) | 「校验判据被绕过」「失败却已动过目标」「失败留下 `.ullupd`」 | **回滚与备份**:摘要/长度失败发生在 `install_staged` **之前**,那一步根本走不到——回滚靠正路的备份断言 + `install_cases` 单测(r1 复审 ⑤ 的变异实测:去掉 dest 备份后失败路径三例仍全绿,只有正路/幂等变红) |
//!
//! # 不验的部分(如实划界)
//!
//! 1. **真实 GitHub Release 下载**:本套一个字节都不出网(源是本地目录)。`releases/latest` 的
//! 真实响应、镜像/gh-proxy 回退、CDN 的 ETag/断点续传都属于真网络验收。
//! 2. **GUI 的点击与重启替换**:设置页「下载并安装」、提示条、`RestartNow` 之后的进程重启不在
//! 自动化里(替换自身 exe 需要真机,且要人工确认新版本能起来)。
//! 3. **按版本号忽略**:`presenter::host::update_state` 的 `skipped` 是会话级行为,有单测
//! (`update_session_tests.rs`),CLI 侧没有对应开关,所以这里不覆盖。
//! 4. **`--dest` 之外的自替换**(替换本进程 exe):`tests/support/update_e2e.rs` 的文件头写了为什么
//! 这几条用例永远带 `--dest`,以及「去掉它会真的改名换掉测试二进制」这件事。
//! 5. **macOS / Linux 上的跨卷**:跨卷用例只在**本机 Windows**(C: NVMe + D: 机械盘)上真跑过;
//! macOS 的卷(APFS 宗卷之间 `/Volumes` 挂载)与 Linux 的 tmpfs↔ext4 一次都没跑——两边的
//! `EXDEV` 语义相同,但「同目录中转 + 改名」在同一台机器上有没有别的坑,本套没有证据。
#![cfg(feature = "update-test-source")]
#[path = "support/mod.rs"]
mod support;
use support::update_e2e::{channel_lock, Channel, Fixture, INSTALLED};
use unlucky_core::update::Target;
/// 发现新版本 → 下载 → 双检 → 就位:整环从 CLI 入口跑一遍,并核对每一步的产物。
#[test]
fn discovers_downloads_and_installs_from_the_local_channel() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-happy");
Channel::enable(fx.dist.path());
fx.publish_good();
// 1) 发现新版本:源是本地目录,脚本能看出这一份结论来自测试通道。
let (code, out, err) = fx.run(&["check", "--json"]);
assert_eq!(code, 0, "check 应成功:{err}");
let json = support::parse_json(&out);
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "available", "{json}");
assert_eq!(data["version"], fx.version.as_str());
assert_eq!(data["current"], env!("CARGO_PKG_VERSION"));
assert_eq!(
data["size"].as_u64(),
Some(fx.payload.len() as u64),
"清单声明的字节数 = 产物真实字节数:{json}"
);
assert_eq!(data["target"], Target::current().as_str());
assert_eq!(data["notes"], "修掉启动崩溃;实例页加载更快");
assert_eq!(data["forced"], false);
assert_eq!(data["test_source"], true, "测试通道必须在输出里点名");
assert_eq!(
data["source"],
format!("file:{}", fx.dist.path().display()),
"源必须是本地目录(不是 GitHub)"
);
// 2) 人读输出也点名测试通道:人看的那份不能比 JSON 少说一句。
let (code, human, _) = fx.run(&["check"]);
assert_eq!(code, 0);
assert!(human.contains("发现新版本"), "{human}");
assert!(
human.contains("测试通道生效"),
"人读输出要点名「这一份结论来自本地夹具」:{human}"
);
// 3) 下载 → 长度/SHA256 双检 → 就位。
let (code, json) = fx.apply();
assert_eq!(code, 0, "apply 应成功:{json}");
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "installed", "{json}");
assert_eq!(data["version"], fx.version.as_str());
assert_eq!(
data["bytes"].as_u64(),
Some(fx.payload.len() as u64),
"{json}"
);
assert_eq!(data["path"], fx.dest.display().to_string().as_str());
assert_eq!(data["test_source"], true);
let backup = data["backup"]
.as_str()
.expect("旧版本应留备份(回滚用)")
.to_string();
// 4) 盘上事实:目标 = 产物字节,备份 = 旧字节,暂存无残留。
assert_eq!(
fx.installed_bytes(),
fx.payload,
"就位的必须就是清单里那一份字节"
);
assert_eq!(
std::fs::read(&backup).expect("读备份"),
INSTALLED,
"旧版本要在备份里,不能凭空消失"
);
assert!(
fx.staging_leftovers().is_empty(),
"暂存目录里不许留 .ullupd:{:?}",
fx.staging_leftovers()
);
// 5) 装完再查:**仍然报有更新**——因为 current 取的是编译期版本,不读 dest 里的 exe。
// 这不是 bug(真实场景里替换完就会重启),但脚本作者必须知道这一条。
let (code, out, _) = fx.run(&["check", "--json"]);
assert_eq!(code, 0);
let json = support::parse_json(&out);
assert_eq!(support::update_e2e::payload(&json)["status"], "available");
}
/// 没有清单的本地渠道 = 「还没有任何发布」,不是错误、也不是「已是最新」以外的胡话。
///
/// 顺带把「源真的被换成本地目录」在没有产物的情况下也验一遍(全程离线)。
#[test]
fn an_empty_local_channel_is_up_to_date_not_an_error() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-empty");
Channel::enable(fx.dist.path());
let (code, out, err) = fx.run(&["check", "--json"]);
assert_eq!(code, 0, "缺清单不该是错误:{err}");
let json = support::parse_json(&out);
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "up_to_date", "{json}");
assert_eq!(data["latest"], serde_json::Value::Null);
assert_eq!(data["test_source"], true);
}
/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾、备份只有一份。
///
/// 如实记两条:`apply` 每次都留一份备份(CLI 从不删,等下一次启动/人工清理),但**只留最近一份**
/// (`install::prune_stale_artifacts`,M171);第二次之所以还会「装上」,是因为 `current` 是编译期
/// 版本——重复执行不是空操作,只是结果幂等。
#[test]
fn applying_twice_leaves_the_same_target_bytes() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-twice");
Channel::enable(fx.dist.path());
fx.publish_good();
for round in 1..=2 {
let (code, json) = fx.apply();
assert_eq!(code, 0, "第 {round} 次 apply 应成功:{json}");
assert_eq!(support::update_e2e::payload(&json)["status"], "installed");
assert_eq!(
fx.installed_bytes(),
fx.payload,
"第 {round} 次 apply 之后目标字节必须仍是产物"
);
assert!(
fx.staging_leftovers().is_empty(),
"第 {round} 次 apply 之后暂存目录还有 {:?}",
fx.staging_leftovers()
);
}
assert_eq!(
fx.backups().len(),
1,
"只留最近一份备份(每次 apply 建一份、成功即清更早的):{:?}",
fx.backups()
);
}
/// 跨卷(M171):数据根在一卷、安装目录在另一卷时,整环从 CLI 入口仍然跑通。
///
/// 这正是已发布版本上必然失败的那个场景(便携版放 U 盘、数据根在 D 盘而程序在 C 盘):
/// `stage()` 把包落在 `<数据根>/cache/update/staging`,`install_staged()` 要把包搬进 exe 目录,
/// 而 `rename` 不能跨卷。修法是先落到**目标同目录**的中转名(同卷改名)再顶上去。
///
/// 用例先自己证明**前提成立**(从数据根到安装目录 rename 必须报 `CrossesDevices`),否则
/// 「跨卷成功」这句话就是假的。本机只有一块可写卷时**只有显式豁免才允许跳过**
/// (`ULL_XDEV_TEST_WAIVE=1`,见 `support::cross_volume_waived`):探不到又没豁免 ⇒ 红。
/// 跨卷逻辑本身另由 `crates/core` 的 `install_xdev_cases`(真跨卷)与 `install_io_cases`(注入)守着。
#[test]
fn installs_across_volumes_from_the_cli() {
let _serial = channel_lock();
let _restore = Channel::capture();
let Some(fx) = Fixture::cross_volume("e2e-xdev") else {
support::cross_volume_waived("cli-e2e");
return;
};
Channel::enable(fx.dist.path());
fx.publish_good();
// 前提:数据根(暂存所在地)与安装目录**真的**不同卷。
let install_dir = fx.dest.parent().expect("安装目录").to_path_buf();
let probe = fx.data.path().join("volume-probe.bin");
std::fs::write(&probe, b"probe").expect("写探针");
let cross = std::fs::rename(&probe, install_dir.join("volume-probe.bin"))
.expect_err("跨卷 rename 必须失败");
assert_eq!(
cross.kind(),
std::io::ErrorKind::CrossesDevices,
"前提不成立:数据根与安装目录同卷({cross})"
);
let (code, json) = fx.apply();
assert_eq!(code, 0, "跨卷 apply 应成功:{json}");
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "installed", "{json}");
assert_eq!(data["path"], fx.dest.display().to_string().as_str());
assert_eq!(
fx.installed_bytes(),
fx.payload,
"装上去的必须是清单里那一份完整字节(不是半截)"
);
assert!(
fx.staging_leftovers().is_empty(),
"暂存目录里不许留 .ullupd:{:?}",
fx.staging_leftovers()
);
let backups = fx.backups();
assert_eq!(backups.len(), 1, "旧版本要留一份备份:{backups:?}");
let backup = install_dir.join(&backups[0]);
assert_eq!(
std::fs::read(&backup).expect("读备份"),
INSTALLED,
"旧字节必须完整留在备份里"
);
}