fix(coexist): r2 返工——帮助写真实例路径 + 加载器安装也说出「原版清单被替换」+ --json 给出绑定成败(M216)

r2 复审两条 P2 必修 + 一条「能一行接上就接」的 P3。

P2-1(`ull help pack` 的落点少一层 `.ULL/data`)
- 「数据根」在这份帮助自己的定义里是「含 `.ULL/` 与 `.minecraft/` 的父目录」(`--data-dir` 那行),
  所以真路径是 `<数据根>/.ULL/data/instances/<实例目录名>`。r1 写成 `<数据根>/instances/<实例名>`,
  照读会去一个不存在的位置找包内容。
- help.rs 与 model.rs 的字段文档改成真路径,并点明目录名是归一化后的结果(`Better-MC`)。
- 用例改成 `help_describes_the_new_default_target_with_the_real_path`:先断言真路径整串在、
  再断言 `<数据根>/instances/` **不在**(这一条就是抓「少写 .ULL/data」那种错法)。

P2-2(装加载器时原版那一步的「替换了清单」没人说)
- `loader.rs`:`plan.vanilla_install_needed` 那条路以前只拿 `vanilla_report` 判 `is_complete()`
  就丢掉;现在把它留在 `vanilla_report` 里传给 `report_loader::build`。
- `report_loader.rs`:`build` / `human_lines` 各多一个 `vanilla: Option<&InstallReport>`;
  新增 `vanilla_lines`(人读:`告警(原版): …` + `原版版本清单已替换(旧的那份备份在 …)`)与
  `vanilla_json`(`--json` 的 `vanilla` 段:warnings / version_json / manifest{kind,backup})。
- 用例 `a_replaced_vanilla_manifest_is_said_out_loud_in_the_loader_output`:用真
  `LoaderInstallPlan`(最小 Fabric,零网络)调 `human_lines`,断言告警与备份路径都在;
  未先装原版时不多说话;`vanilla_json` 的 `kind`/`backup` 结构正确、`None` 时没有该段。

P3(复审「能一行接上就接」):`--json` 给出导入绑定的**结构化成败**
- core `pack_dir::bind_created_instance` 返回 `Option<Result<String, String>>`(None=不需要绑 /
  Ok=已绑 / Err=绑失败),一句话就能分出成败,不必猜文案。
- CLI:`outcome` 多写一个 `"instance_bound": true|false`(人读行照旧给原因);
  GUI:`import.rs` 按 Ok/Err 分别记 `instance_bound` / `instance_binding_failed` 注记。
- 端到端用例里补断言 `data.instance_bound == true`。
This commit is contained in:
AskaEth committed 2026-11-09 20:43:46 +08:00
1 parent 4e1323212b
commit aa61565fe7
8 files changed
+188 -27

No files matched your search

+3 -2
View File
@@ -104,8 +104,9 @@ pub fn topic(name: &str) -> Option<&'static str> {
MultiMC mmc-pack.json(formatVersion 必须为 1)+ instance.cfg + .minecraft/ MultiMC mmc-pack.json(formatVersion 必须为 1)+ instance.cfg + .minecraft/
(Prism 实例;非加载器 component 会逐项列进报告,不静默丢) (Prism 实例;非加载器 component 会逐项列进报告,不静默丢)
目标目录缺省 = 新实例目录 `<数据根>/instances/<实例名>`(并登记实例,M216 起不再落共享 目标目录缺省 = 新实例目录 `<数据根>/.ULL/data/instances/<实例目录名>`(并登记实例,M216 起
.minecraft 根);`--target` 可钉到别处(例如某个实例的运行目录)。 不再落共享 .minecraft 根;目录名是包名/`--name` 经 ASCII 归一化后的结果,如 `Better-MC`);
`--target` 可钉到别处(例如某个实例的运行目录)。
两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。 两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。
· 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖 · 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖
· 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」 · 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」
+2 -2
View File
@@ -157,8 +157,8 @@ pub struct InstallArgs {
pub struct PackImportArgs { pub struct PackImportArgs {
/// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。 /// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。
pub path: PathBuf, pub path: PathBuf,
/// 目标实例目录(缺省 = 新实例目录 `<数据根>/instances/<实例名>`,M216 前是共享游戏目录 /// 目标实例目录(缺省 = 新实例目录 `<数据根>/.ULL/data/instances/<实例目录名>`,M216 前是
/// `game_home`)。 /// 共享游戏目录 `game_home`)。
pub target: Option<PathBuf>, pub target: Option<PathBuf>,
/// 实例显示名(缺省 = 包名)。 /// 实例显示名(缺省 = 包名)。
pub name: Option<String>, pub name: Option<String>,
+8 -3
View File
@@ -85,12 +85,13 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
skipped: false, skipped: false,
}); });
return Ok(report_loader::build( return Ok(report_loader::build(
ctx, &engine, now, true, &plan, None, None, &name, section, ctx, &engine, now, true, &plan, None, None, None, &name, section,
)); ));
} }
let mut vanilla_report = None;
if plan.vanilla_install_needed { if plan.vanilla_install_needed {
let (_, vanilla_report) = vanilla::plan_and_install( let (_, report) = vanilla::plan_and_install(
ctx, ctx,
&engine, &engine,
&version, &version,
@@ -98,11 +99,14 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
args.progress, args.progress,
args.json_progress, args.json_progress,
)?; )?;
if let Some(report) = &vanilla_report { if let Some(report) = &report {
if !report.is_complete() { if !report.is_complete() {
return Err(failure_error(report, ctx)); return Err(failure_error(report, ctx));
} }
} }
// **M216 r2 P2-2**:原版那段 report 不能在这里丢掉——它的 `warnings` / `manifest`
// (可能替换了共享 `.minecraft` 里 `versions/<原版>/<原版>.json`)必须进人读与 `--json`。
vanilla_report = report;
} }
// installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。 // installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。
@@ -143,6 +147,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
false, false,
&plan, &plan,
Some(&install_report), Some(&install_report),
vanilla_report.as_ref(),
Some(&registration), Some(&registration),
&name, &name,
section, section,
@@ -3,7 +3,7 @@
//! 与 [`super::report`] 的分工:那边是"原版安装"(客户端 jar + 资源 + 库),这边是 //! 与 [`super::report`] 的分工:那边是"原版安装"(客户端 jar + 资源 + 库),这边是
//! "叠在原版上的 loader 层"(只有库)。共用同一套进度回调与字节格式化。 //! "叠在原版上的 loader 层"(只有库)。共用同一套进度回调与字节格式化。
use serde_json::json; use serde_json::{json, Value};
use unlucky_core::addon::loader::processors::{ProcessorPlan, ProcessorRun}; use unlucky_core::addon::loader::processors::{ProcessorPlan, ProcessorRun};
use unlucky_core::addon::loader::LoaderInstallPlan; use unlucky_core::addon::loader::LoaderInstallPlan;
use unlucky_core::download::DownloadEngine; use unlucky_core::download::DownloadEngine;
@@ -39,6 +39,7 @@ pub fn build(
dry_run: bool, dry_run: bool,
plan: &LoaderInstallPlan, plan: &LoaderInstallPlan,
report: Option<&InstallReport>, report: Option<&InstallReport>,
vanilla: Option<&InstallReport>,
registration: Option<&Registration>, registration: Option<&Registration>,
name: &str, name: &str,
processors: Option<ProcessorSection<'_>>, processors: Option<ProcessorSection<'_>>,
@@ -78,6 +79,9 @@ pub fn build(
if let Some(section) = &processors { if let Some(section) = &processors {
data["processors"] = processor_json(section); data["processors"] = processor_json(section);
} }
if let Some(block) = vanilla_json(vanilla) {
data["vanilla"] = block;
}
Report::new( Report::new(
data, data,
@@ -85,6 +89,7 @@ pub fn build(
dry_run, dry_run,
plan, plan,
report, report,
vanilla,
registration, registration,
&samples, &samples,
name, name,
@@ -115,10 +120,47 @@ fn processor_json(section: &ProcessorSection<'_>) -> serde_json::Value {
node 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( fn human_lines(
dry_run: bool, dry_run: bool,
plan: &LoaderInstallPlan, plan: &LoaderInstallPlan,
report: Option<&InstallReport>, report: Option<&InstallReport>,
vanilla: Option<&InstallReport>,
registration: Option<&Registration>, registration: Option<&Registration>,
samples: &[serde_json::Value], samples: &[serde_json::Value],
name: &str, name: &str,
@@ -181,6 +223,7 @@ fn human_lines(
for warning in plan.warnings() { for warning in plan.warnings() {
human.push(format!("告警: {warning}")); human.push(format!("告警: {warning}"));
} }
human.extend(vanilla_lines(vanilla));
if let Some(section) = processors { if let Some(section) = processors {
human.extend(processor_lines(section)); human.extend(processor_lines(section));
} }
@@ -272,3 +315,85 @@ fn processor_lines(section: &ProcessorSection<'_>) -> Vec<String> {
} }
lines 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(),
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(),
vanilla_installed: true,
})
}
/// **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();
}
}
+10 -4
View File
@@ -99,7 +99,7 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
&target, &target,
&seen, &seen,
args, args,
binding.as_deref(), binding.as_ref(),
)) ))
} }
@@ -110,7 +110,7 @@ fn outcome(
target: &Path, target: &Path,
stages: &[&'static str], stages: &[&'static str],
args: &PackImportArgs, args: &PackImportArgs,
binding: Option<&str>, binding: Option<&Result<String, String>>,
) -> Outcome { ) -> Outcome {
let mut data = serde_json::to_value(report).unwrap_or_else(|_| { let mut data = serde_json::to_value(report).unwrap_or_else(|_| {
json!({ json!({
@@ -144,8 +144,14 @@ fn outcome(
if !stages.is_empty() { if !stages.is_empty() {
human.push(format!("阶段:{}", stages.join(" → "))); human.push(format!("阶段:{}", stages.join(" → ")));
} }
if let Some(note) = binding { if let Some(binding) = binding {
human.push(note.to_string()); // **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!( human.push(format!(
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}", "结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
+24 -7
View File
@@ -221,6 +221,14 @@ fn the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it() {
]); ]);
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); 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"); let instance_dir = data.path().join(".ULL/data/instances/My-Pack");
assert!( assert!(
instance_dir.join("mods/a.jar").is_file(), instance_dir.join("mods/a.jar").is_file(),
@@ -266,18 +274,27 @@ fn the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it() {
); );
} }
/// **M216 P2-1**:`--help` 里的缺省落点必须与新行为一致(写旧行为 = 用户照读会以为文件进了 /// **M216 P2-1 / r2**:`--help` 里的缺省落点必须是**真路径**。
/// `.minecraft`)。
/// ///
/// 抓得住:文案退回「共享游戏目录」;新落点没写进去。抓不住:`model.rs` 的字段文档 /// 「数据根」在这份帮助自己的定义里是「含 `.ULL/` 与 `.minecraft/` 的父目录」(`--data-dir` 那行),
/// (那是编译期文档,测试读不到)——所以断言落在这里只覆盖用户能看见的那一份。 /// 所以实例目录的真路径是 `<数据根>/.ULL/data/instances/<目录名>`。r1 写成了
/// `<数据根>/instances/<实例名>` —— 少一层 `.ULL/data`,照读会去一个不存在的位置找包内容。
///
/// 抓得住:缺 `.ULL/data` 这一层(下面第二条断言就是抓它的——少写时该子串恰好出现);
/// 文案退回「共享游戏目录」;目录名归一化这件事没提。抓不住:`model.rs` 的字段文档
/// (编译期文档,测试读不到),以及真实 `--data-dir` 下的绝对路径拼接(那由上面那条端到端
/// 用例按真目录断言)。
#[test] #[test]
fn help_describes_the_new_default_target() { fn help_describes_the_new_default_target_with_the_real_path() {
let (code, stdout, _) = run_cli(&["help", "pack"]); let (code, stdout, _) = run_cli(&["help", "pack"]);
assert_eq!(code, 0); assert_eq!(code, 0);
assert!( assert!(
stdout.contains("instances/<实例名>"), stdout.contains("<数据根>/.ULL/data/instances/<实例目录名>"),
"帮助里没写新缺省落点:{stdout}" "帮助里没写真路径 <数据根>/.ULL/data/instances/<实例目录名>:{stdout}"
);
assert!(
!stdout.contains("<数据根>/instances/"),
"帮助里的落点少了 `.ULL/data` 这一层(r2 抓的就是这个错法):{stdout}"
); );
assert!( assert!(
!stdout.contains("缺省 = 数据根的共享游戏目录"), !stdout.contains("缺省 = 数据根的共享游戏目录"),
+8 -5
View File
@@ -132,6 +132,9 @@ pub fn bind_instance_dir(
/// 导入收尾:把「我们新建出来的实例」绑到它的内容目录,返回一句给人读的结论。 /// 导入收尾:把「我们新建出来的实例」绑到它的内容目录,返回一句给人读的结论。
/// ///
/// 返回 `None` = 不需要绑;`Some(Ok(说明))` = 已绑;`Some(Err(说明))` = 绑失败(档案在、内容在,
/// 只是运行目录没指过来——调用方**别**把它当整次导入失败,但要让人看见)。
///
/// 判据(CLI 与 GUI 共用一份,别自己再写一遍): /// 判据(CLI 与 GUI 共用一份,别自己再写一遍):
/// - `auto_bound == false` ⇒ 不绑(用户显式给了目标目录 / `--no-register`:那目录可能是别人的 /// - `auto_bound == false` ⇒ 不绑(用户显式给了目标目录 / `--no-register`:那目录可能是别人的
/// 运行目录,也可能被用户自己指过); /// 运行目录,也可能被用户自己指过);
@@ -146,23 +149,23 @@ pub fn bind_created_instance(
target: &Path, target: &Path,
auto_bound: bool, auto_bound: bool,
report: &crate::addon::import::ImportReport, report: &crate::addon::import::ImportReport,
) -> Option<String> { ) -> Option<Result<String, String>> {
if !auto_bound { if !auto_bound {
return None; return None;
} }
let registered = report.registered.as_ref().filter(|item| item.created)?; let registered = report.registered.as_ref().filter(|item| item.created)?;
Some( Some(
match bind_instance_dir(layout, &registered.id, &registered.version_id, target) { match bind_instance_dir(layout, &registered.id, &registered.version_id, target) {
Ok(()) => format!( Ok(()) => Ok(format!(
"运行目录已指向 {}(实例 {})", "运行目录已指向 {}(实例 {})",
target.display(), target.display(),
registered.name registered.name
), )),
// 内容与档案都已就位,只是目录没指过来 ⇒ 不把整次导入判失败,但必须说出来。 // 内容与档案都已就位,只是目录没指过来 ⇒ 不把整次导入判失败,但必须说出来。
Err(error) => format!( Err(error) => Err(format!(
"实例已登记但没能绑到导入目录({error}):启动前请把该实例的运行目录指到 {}", "实例已登记但没能绑到导入目录({error}):启动前请把该实例的运行目录指到 {}",
target.display() target.display()
), )),
}, },
) )
} }
@@ -202,10 +202,14 @@ pub(super) fn run(
// **§5**:导入建出来的实例要绑到它的内容目录(`isolation=custom` + `gameDir`),否则运行 // **§5**:导入建出来的实例要绑到它的内容目录(`isolation=custom` + `gameDir`),否则运行
// 目录由全局档派生成 `<game>/versions/<version_id>`,这批 mods/config 永远不会被启动用到。 // 目录由全局档派生成 `<game>/versions/<version_id>`,这批 mods/config 永远不会被启动用到。
// 判据与文案在 core(与 CLI 同一份);失败不把整次导入判失败,但要让用户看见。 // 判据与文案在 core(与 CLI 同一份);失败不把整次导入判失败,但要让用户看见。
if let Some(note) = pack_dir::bind_created_instance(&env.layout, &target, register, &report) { match pack_dir::bind_created_instance(&env.layout, &target, register, &report) {
report Some(Ok(note)) => report
.warnings .warnings
.push(ImportIssue::new("instance_binding", note)); .push(ImportIssue::new("instance_bound", note)),
Some(Err(note)) => report
.warnings
.push(ImportIssue::new("instance_binding_failed", note)),
None => {}
} }
// 取消标记要**在管线返回之后**再读一次:取消可能来得太晚(管线已经跑完),这一读就是 // 取消标记要**在管线返回之后**再读一次:取消可能来得太晚(管线已经跑完),这一读就是
// "太晚"的唯一证据(`finish` 用它把报告标成 `TooLate`)。 // "太晚"的唯一证据(`finish` 用它把报告标成 `TooLate`)。