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:
1 parent
e4b819a4cc
commit
9ead4ae892
3 files changed
+329
-2
No files matched your search
@@ -62,7 +62,14 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
||||
%prog% instances list
|
||||
|
||||
列出 profiles.json 里的实例与 versions/ 下已安装的版本。
|
||||
--json 输出 {\"data\":{\"instances\":[…],\"installed_versions\":[…],\"data_root\":…}}",
|
||||
--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 / liteloader 只能由别的
|
||||
启动器装好后继承进来,install --loader 不接受这两个值;kind=vanilla 只表示 id 里没有加载器
|
||||
痕迹(整包导入被重命名的实例也会落成 vanilla),权威结论要读版本 JSON 的 libraries[]。",
|
||||
),
|
||||
"pack" => Some(
|
||||
"\
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,250 @@
|
||||
//! [`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`(只在「认得出但装不了」的那两家会红)。
|
||||
/// 抓不住:解析规则本身退化(那是 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` 只在
|
||||
/// help 里说明它们装不了)。
|
||||
/// 抓不住:措辞本身——下面只按键词(`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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user