feat(cli): M124 instances list --json 补 loader 字段,只加不改

`ull instances list` 的人读输出历来只逐字打 `version_id`,脚本/agent 要判断
「这是不是 Fabric」只能自己猜 id 形状——正是 M122 收敛掉的第二份真相。本提交只补
输出面:

- 每行实例加 `loader: {"kind": <稳定 id>, "version": <版本段>}`,解析一律调
  `unlucky_core::game::InstanceLoader::parse`(M122 的全仓唯一入口),CLI 里没有
  任何新的 id 匹配;CLI 只做「种类 → 稳定 id」的穷尽翻译。
- 原版恒为 `{"kind":"vanilla","version":""}`:键恒存在、不为 null,脚本不必判空。
- `kind` 是识别口径(7 值,含只能继承不能安装的 optifine/liteloader),不是
  `install --loader` 的安装口径(5 值)——这条连同「vanilla 只表示 id 里没有加载器
  痕迹」一起写进模块文档与 `ull help instances`(`--json` 是事实对外契约,字段说明
  跟着契约走)。
- 向后兼容:`data.instances[]` 原有七个键一个没动,人类可读输出逐字不变。
- 测试(`commands::instances::tests`,走真入口 `crate::run`):四家自家 id + 原版 +
  两家第三方前缀的真值表、原版非 null 的反向锚、人读输出与旧字段不变的兼容锚、
  help 里有契约说明的文档锚。
This commit is contained in:
AskaEth committed 2026-11-05 15:23:37 +08:00
1 parent e4b819a4cc
commit 9ead4ae892
3 files changed
+329 -2

No files matched your search

+71 -1
View File
@@ -3,12 +3,48 @@
//! 安装/登记在 [`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 收敛)。CLI 只做「种类 → 稳定 id」的翻译,**不重写
//! 任何 id 匹配**:那份规则一旦两处各算一遍就会漂(M120 的「刚装的 Fabric 显示成原版」)。
//!
//! ## 约定一:`loader` 恒存在,原版是 `{"kind":"vanilla","version":""}`
//!
//! 原版实例**不会**缺这个键,也**不会**是 `null`——脚本不必先判空,`kind` 恒是七个闭集值
//! 之一(`vanilla` / `fabric` / `quilt` / `forge` / `neoforge` / `optifine` / `liteloader`)。
//! 与 `version` 的关系:原版与「id 里没写加载器版本」的两家第三方(`1.12.2-LiteLoader`)
//! 拿到空串,不是缺失。
//!
//! ## 约定二:`kind` 是**识别**口径,不是**安装**口径
//!
//! 七个值里 `optifine` / `liteloader` 只出现在「本启动器**认得出**但**装不了**」的情形:
//! 它们只能由别的启动器(PCL / HMCL)装好后继承进来,`ull install --loader` 的五个取值
//! (`vanilla` / `fabric` / `quilt` / `neoforge` / `forge`,见 [`crate::cli::model`])
//! 里没有它们。看到 `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::ProfileList;
use unlucky_core::game::{InstanceLoader, LoaderKind, ProfileList};
use crate::context::Ctx;
use crate::error::CliError;
@@ -43,6 +79,7 @@ pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
"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),
}));
}
@@ -88,3 +125,36 @@ pub fn read_profiles(ctx: &Ctx) -> Result<ProfileList, CliError> {
fn option_path(path: Option<&PathBuf>) -> Option<String> {
path.map(|path| path.display().to_string())
}
/// 版本 id → `loader` 对象(`{"kind": …, "version": …}`)。
///
/// 解析走 [`InstanceLoader::parse`](全仓唯一入口);这里**不写任何 id 匹配**,只把解析结果
/// 翻成 JSON。原版同样给出对象(`kind = vanilla`、`version = ""`),不缺键、不为 `null`
/// ——见模块文档的「约定一」。
fn loader_json(version_id: &str) -> Value {
let parsed = InstanceLoader::parse(version_id);
json!({
"kind": loader_kind_id(parsed.kind),
"version": parsed.version,
})
}
/// 加载器种类 → 稳定 id。
///
/// 词表与 `--loader` 同源(`vanilla` / `fabric` / `quilt` / `forge` / `neoforge`),另加两个
/// **只能识别、不能安装**的 `optifine` / `liteloader`。穷尽 match:core 加一支就编不过,
/// 不会静默漏报成 `vanilla`。
fn loader_kind_id(kind: LoaderKind) -> &'static str {
match kind {
LoaderKind::Vanilla => "vanilla",
LoaderKind::Fabric => "fabric",
LoaderKind::Quilt => "quilt",
LoaderKind::Forge => "forge",
LoaderKind::NeoForge => "neoforge",
LoaderKind::OptiFine => "optifine",
LoaderKind::LiteLoader => "liteloader",
}
}
#[cfg(test)]
mod tests;