fix(content): 不兼容档在失败路径上取项目名 + 重试轮不再贴错根的标题 (M191 r2)

复审 r2 两条 p2,都修了,并按复审给的口径验过红。

**P2-1(续):不兼容档不再只有裸 id**
`FailureKind::Incompatible` 的项目**确实存在**(站点只是没有匹配目标版本/加载器的条目),
所以失败路径上值得现取一次名字——`plan::names_for_incompatible` 按失败项去重、一项一次 GET
(`Modrinth::project`),`fetch_title` 把任何失败都折成 `None`:
**拿名字失败不许影响安装结果**,取不到 / 拿到空标题就退回 id。只有这一档发请求;
「取不到」档 404 那部分项目根本不存在,再发一次只会再吃 404。

**P2-2(新):重试轮把根的标题贴到了前置头上**
根因是判「这条是不是根项目自己失败」用了 `plan.request.roots.first()`,而重试那一轮的 plan
来自 `retry_request()`,它的 `roots` 就是上一轮的失败名单——第一个是前置。
现在判据改成 **id 比对**(`Root{id,title}`,id 由 `remember` 一起记下来):
`label_of` 只在这条失败的 id 真的等于原始根 id 时才用根的标题。复审给的那个形状
(roots=["P7dR8mSH","1eAoo2KR"] + root_title="Sodium")现在画 `P7dR8mSH`,不再是
`Sodium (P7dR8mSH)`。

**守门(每条都真跑红过,且在拆完模块的最终代码上复跑)**
- 去掉不兼容档的名字来源 ⇒ `an_incompatible_item_shows_the_project_name_once_it_is_known` 红
  (`left: "P7dR8mSH"` / `right: "Entity Culling (P7dR8mSH)"`)。
- `label_of` 换回 `roots.first()` ⇒ `the_retry_round_does_not_paste_the_root_title_onto_a_dependency` 红
  (`left: ["Sodium (P7dR8mSH)","1eAoo2KR"]`,正是复审实测的那一行)。
- 取名字不降级(`expect`)⇒ `a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything` 红。

**行数**:`plan.rs` 加完这套到 414 行、`install.rs` 到 403 行,都越过 400 硬线 ⇒
把「名字从哪来」整块(`Root`/`Names`/`names_for_incompatible`/`label_of`/`fetch_title`)
纯搬移到 `plan/labels.rs`(111 行):`plan.rs` 336、`install.rs` 388。
This commit is contained in:
AskaEth committed 2026-11-09 05:06:06 +08:00
1 parent 961ff453a0
commit 26e7b7333e
7 files changed
+313 -77

No files matched your search

@@ -0,0 +1,111 @@
//! 「这条失败该显示什么名字」——**语言中立**的标签来源(M191 r1 P2-1 / r2 P2-1 续)。
//!
//! 从 `plan.rs` 拆出(加完失败路径上取名字那次 GET 之后父文件到 414 行,越过 400 行硬线)。
//! 拆的口径是「一件事一个文件」:这里只管**名字从哪来**,判定与说话仍在 `plan.rs`。
//!
//! 三个来源,按可信度排(细节在 [`label_of`]):原始根项目的标题、失败文件的文件名、
//! 失败路径上现取的项目名;都没有就留空,界面只画项目 id。
//!
//! **不许**拿 `DependencyFailure::message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。
use unlucky_core::addon::online::{
DependencyFailure, DependencyPlan, EngineTransport, FailureKind, Modrinth,
};
use unlucky_core::download::blocking::block_on;
use super::super::super::env::HostEnv;
/// 这一次装入的**原始根项目**(id + 显示名)。
///
/// 「是不是根」只能按 `id` 比(r2 复审 P2-2):重试那一轮的 plan 来自
/// `DependencyPlan::retry_request()`,它的 `request.roots` **就是上一轮的失败名单**——
/// 第一个是前置,拿它当「根」会把根的标题贴到前置头上。所以判据是「这条失败的 id 是不是
/// 当初那次装入的根 id」,两个值都由 [`remembered`] / `ContentInstallRequest` 提供。
#[derive(Clone, Copy)]
pub(in crate::host::addon) struct Root<'a> {
/// 当初那次装入的根项目 id(`ContentInstallRequest.project`)
pub id: &'a str,
/// 它的显示名(`ContentInstallRequest.title`;只配给 `id` 用)
pub title: &'a str,
}
/// 失败路径上现取的项目名(id → title)。取不到的项目不在表里。
pub(in crate::host::addon) type Names = Vec<(String, String)>;
/// 为「不兼容」档现取项目名(**只在失败路径上**,一个项目一次 GET)。
///
/// 为什么只给这一档:`FailureKind::Incompatible` 是「站点**有这个项目**,但目标版本/加载器下
/// 没有条目」——项目确实存在,所以这次 GET 有东西可拿。「取不到」档里 404 的那部分项目根本
/// 不存在,给它发请求只会再吃一个 404,于是那一档仍显示 id(合理兜底)。
///
/// `lookup` 由调用方注入(生产是 `Modrinth::project`,用例是闭包):于是「取名字失败也要能降级」
/// 这件事在没有网络的用例里也能钉住。取不到 / 拿到空串的项一律**不进**名单。
pub(in crate::host::addon) fn names_for_incompatible(
plan: &DependencyPlan,
lookup: impl Fn(&str) -> Option<String>,
) -> Names {
plan.failures_of(FailureKind::Incompatible)
.filter_map(|failure| {
let title = lookup(&failure.requested_as)?;
let title = title.trim();
(!title.is_empty()).then(|| (failure.requested_as.clone(), title.to_string()))
})
.collect()
}
/// 一条失败的可读标签(**语言中立**:站点标题、文件名或现取的项目名;拿不到就是空串)。
///
/// 三个来源,**按可信度排**:
///
/// | 情况 | 标签 | 为什么 |
/// |---|---|---|
/// | 这条失败的 id == 原始根 id | 请求里的 `title` | 用户刚才点的就是它 |
/// | 计划里有这个节点(下载失败) | `node.file.filename` | 「哪个文件没下来」比项目名更具体 |
/// | 不兼容档 | [`names_for_incompatible`] 现取的名字 | 项目存在,一次 GET 拿得到 |
/// | 其余(解析 404 等) | 空 | 项目不存在,显示 id 是合理兜底 |
///
/// **不许**拿 `failure.message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。
#[must_use]
pub(super) fn label_of(
plan: &DependencyPlan,
failure: &DependencyFailure,
root: Root<'_>,
names: &Names,
) -> String {
// ① 根项目自己失败:**按 id 比**,不看 `plan.request.roots`(重试轮里那是失败名单)
if failure.requested_as == root.id {
return root.title.to_string();
}
// ② 计划里有这个节点(下载失败):文件名最具体
if let Some(node) = plan
.nodes
.iter()
.find(|node| node.requested_as == failure.requested_as)
{
return node.file.filename.clone();
}
// ③ 不兼容档:失败路径上现取的名字;取不到就空 ⇒ 界面只画 id
if failure.kind == FailureKind::Incompatible {
return names
.iter()
.find(|(id, _)| id == &failure.requested_as)
.map(|(_, title)| title.clone())
.unwrap_or_default();
}
String::new()
}
/// 取一个项目的显示名(**只在失败路径上**发)。
///
/// 失败一律回 `None`:**拿名字失败不许影响安装结果**——这条是「只重试这些」的补充信息,
/// 坏一次网络不该把「装了什么、缺了什么」这整件事变成一条错误。
pub(in crate::host::addon) fn fetch_title(env: &HostEnv, id: &str) -> Option<String> {
let core_engine = crate::host::engine::engine(env);
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
let id = id.to_string();
block_on(async move { client.project(&id).await })
.ok()
.map(|project| project.title)
.filter(|title| !title.trim().is_empty())
}
@@ -3,7 +3,6 @@
//! 从 `plan.rs` 拆出(父文件加了 `label_of` 之后顶到 429 行,越过 400 行硬线)。
//! 拆法与本仓既有 `-p1/-p2` 一致:**纯搬移**,一行不改语义;调用方路径不变
//! (`plan.rs` 仍然 `pub(super) use` 这三个函数)。
//!
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -12,7 +11,7 @@ use unlucky_core::addon::online::DependencyPlan;
use super::super::super::env::HostEnv;
/// 「上一次装入留下的计划」表(M191):`只重试这些` 的输入。
/// 「上一次装入留下的计划」表的一行(M191):`只重试这些` 的输入。
///
/// ## 为什么要一张静态表(说清楚这笔债)
///
@@ -23,23 +22,45 @@ use super::super::super::env::HostEnv;
///
/// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。
/// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。
struct Remembered {
///
/// ## 为什么连**原始根项目 id** 一起记(r2 复审 P2-2)
///
/// 「这一条是不是根项目自己失败」不能靠 `plan.request.roots.first()` 判:重试那一轮的 plan 来自
/// `DependencyPlan::retry_request()`,它的 `roots` 就是**上一轮的失败名单**——第一个是前置,
/// 拿它跟根的显示名一比,就会把 `Sodium` 贴到一个前置头上。所以判据只能是「这个 id 是不是
/// 当初那次装入的根 id」,那个 id 得跟计划一起记下来。
struct Entry {
host: PathBuf,
instance: String,
/// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录)
dir: PathBuf,
/// 那次装入的显示名(重试时面板头与失败名单的可读名都沿用它,不再退回项目 id)
/// 当初那次装入的根项目 id(`ContentInstallRequest.project`,判「是不是根」的唯一依据)
root: String,
/// 当初那次装入的显示名(只配给 `root` 用)
title: String,
plan: DependencyPlan,
}
static REMEMBERED: Mutex<Vec<Remembered>> = Mutex::new(Vec::new());
static REMEMBERED: Mutex<Vec<Entry>> = Mutex::new(Vec::new());
/// 取回的那一份记录(拆成结构体而不是四元组:调用点读得懂哪一个是哪个)。
pub(in crate::host::addon) struct Remembered {
/// 落盘目录
pub dir: PathBuf,
/// 当初那次装入的根项目 id
pub root: String,
/// 当初那次装入的显示名
pub title: String,
/// 上一轮的依赖计划(`retry_request()` 的来源)
pub plan: DependencyPlan,
}
/// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。
pub(in crate::host::addon) fn remember(
env: &HostEnv,
instance: &str,
dir: &Path,
root: &str,
title: &str,
plan: &DependencyPlan,
) {
@@ -48,10 +69,11 @@ pub(in crate::host::addon) fn remember(
.lock()
.unwrap_or_else(|poison| poison.into_inner());
table.retain(|item| !(item.host == host && item.instance == instance));
table.push(Remembered {
table.push(Entry {
host,
instance: instance.to_string(),
dir: dir.to_path_buf(),
root: root.to_string(),
title: title.to_string(),
plan: plan.clone(),
});
@@ -66,11 +88,8 @@ pub(in crate::host::addon) fn forget(env: &HostEnv, instance: &str) {
table.retain(|item| !(item.host == host && item.instance == instance));
}
/// 这个实例上一次失败的计划(+ 当时的落盘目录 + 显示名);`None` = 没有可重试的记录。
pub(in crate::host::addon) fn remembered(
env: &HostEnv,
instance: &str,
) -> Option<(PathBuf, String, DependencyPlan)> {
/// 这个实例上一次失败的计划(+ 落盘目录 + 原始根 id 与显示名);`None` = 没有可重试的记录。
pub(in crate::host::addon) fn remembered(env: &HostEnv, instance: &str) -> Option<Remembered> {
let host = env.layout.home().to_path_buf();
let table = REMEMBERED
.lock()
@@ -78,5 +97,10 @@ pub(in crate::host::addon) fn remembered(
table
.iter()
.find(|item| item.host == host && item.instance == instance)
.map(|item| (item.dir.clone(), item.title.clone(), item.plan.clone()))
.map(|item| Remembered {
dir: item.dir.clone(),
root: item.root.clone(),
title: item.title.clone(),
plan: item.plan.clone(),
})
}