- P2-1 失败分支不再「直接 continue」:`fail()` 记一条失败(`record_failure` 按 `requested_as` 去重)并把请求键写进 `alias`,后续同键走现成的 `fold_parents` 折父边 ⇒ 同一项目只取一次版本、失败只一条、重试名单不重复; - P2-2 `RawDependency.dependency_type` 改 `Option<String>`:`#[serde(default)]` 兜不住 `null`,以前一份 `"dependency_type": null` 就让整份 `/version` 报 Malformed、整个项目装不上; 现在 null/未知一律 `Unknown`(不递归); - P2-3 完成态用 `finish()` 把 `files_done` 对齐 `files_total`(曾写死 `1`,装 3 个停在 1/3); 解析前 `files_total` 归 0(未知态),不再假装「已知 1 个文件」; - P2-4 新增 `tests/order.rs`:跨根的层内顺序 + 多根正序/逆序对照,删 `layer.sort_by` 会红; 另加 `required_project_ids` 自己的排序去重契约用例(`tests/regressions.rs`); - P2-5 下载阶段失败记进同一套两档结构(`Unreachable` + 叶子成因)并继续下一个文件: 已下好的不回滚、失败项进可重试名单、文案保留「已装入 N 个」的上下文;`retry_request` 自动覆盖它们; - nit① 在 `plan.rs` 写清「不兼容」为什么落在 DTO 的 `Unsupported`(不是笔误,是缺档); nit② 新增 `DependencyPlan::unresolved_required`(只有版本 id 的必备前置不再静默忽略); nit③ 补「同 mod 另一个版本在目录里仍要下载」的负向用例。
331 lines
13 KiB
Rust
331 lines
13 KiB
Rust
//! 依赖图谱的**形状**:一次解析要什么([`ResolveRequest`])、出什么([`DependencyPlan`])。
|
||
//!
|
||
//! 算法在 [`super::resolve`];本文件只放类型与不变量,于是「两档失败从类型上可分辨」这件事
|
||
//! 不必读算法也能核对。
|
||
//!
|
||
//! # 三份名单,一份都不许丢
|
||
//!
|
||
//! | 名单 | 含义 | 上层该做什么 |
|
||
//! |------|------|--------------|
|
||
//! | [`DependencyPlan::nodes`] | 要下的(BFS 顺序、父子关系齐全) | 逐个下载 |
|
||
//! | [`DependencyPlan::skipped`] | 目标目录里已经有同名文件 | 提示「已存在,跳过」 |
|
||
//! | [`DependencyPlan::failures`] | 装不了的,**每条自带 [`FailureKind`]** | 两档分开说;进可重试名单 |
|
||
//!
|
||
//! `truncated` 是第四样东西:撞到 [`DEFAULT_MAX_DEPTH`] 而没展开的项目。它既不是成功也不是
|
||
//! 失败(站点没拒绝,我们主动停),但**必须**露出来——静默截断会让用户看着「装好了」而缺前置。
|
||
//!
|
||
//! # 两档为什么不能合成一句
|
||
//!
|
||
//! 「所选版本/加载器没有匹配项」与「网络异常 / 项目不存在」要用户做的事完全不同:
|
||
//! 前者换版本或换加载器,后者检查网络再试。字符串错误只能靠 `contains("网络")` 猜,
|
||
//! 一改文案就失效;[`FailureKind`] 把判据放在类型上,叶子成因(404 / 连不上 / 5xx)
|
||
//! 另存在 [`DependencyFailure::cause`] 上,允许上层再细分而不用重新判。
|
||
|
||
use super::model::{VersionFile, VersionQuery};
|
||
|
||
/// 默认最大深度(根 = 0)。六层足够覆盖真实套娃(前置的前置的前置……),
|
||
/// 同时给环与异常链一个硬边界——撞上就记 [`DependencyPlan::truncated`],**不静默截断**。
|
||
pub const DEFAULT_MAX_DEPTH: u8 = 6;
|
||
|
||
/// 目标目录里已存在的文件(调用方扫描后给;core 不读盘)。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||
pub struct PresentFile {
|
||
/// 文件名(可以带路径,比对时会砍掉)
|
||
pub name: String,
|
||
}
|
||
|
||
impl PresentFile {
|
||
/// 记一个已存在的文件名。
|
||
#[must_use]
|
||
pub fn new(name: impl Into<String>) -> Self {
|
||
Self { name: name.into() }
|
||
}
|
||
|
||
/// 归一化后的比对键(见 [`present_key`])。
|
||
#[must_use]
|
||
pub fn key(&self) -> String {
|
||
present_key(&self.name)
|
||
}
|
||
}
|
||
|
||
/// 文件名 → 比对键:只留文件名、小写、去掉 `.disabled` 后缀。
|
||
///
|
||
/// 为什么带 `.disabled`:本仓用**路径后缀**表示禁用(`mods/x.jar.disabled`,见
|
||
/// `core::addon::export`),禁用中的 jar 也是「装过」,再下一份会和它并存。
|
||
#[must_use]
|
||
pub fn present_key(name: &str) -> String {
|
||
let base = name
|
||
.rsplit(['/', '\\'])
|
||
.next()
|
||
.unwrap_or(name)
|
||
.trim()
|
||
.to_ascii_lowercase();
|
||
base.strip_suffix(".disabled").unwrap_or(&base).to_string()
|
||
}
|
||
|
||
/// 一次解析请求。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct ResolveRequest {
|
||
/// 起点(项目 id 或 slug);同层处理时按项目键字典序,与这里给的顺序无关
|
||
pub roots: Vec<String>,
|
||
/// 目标游戏版本(选版本用;`None` = 不限)
|
||
pub game_version: Option<String>,
|
||
/// 目标加载器(选版本用;`None` = 不限)
|
||
pub loader: Option<String>,
|
||
/// 最大深度(根 = 0,见 [`DEFAULT_MAX_DEPTH`])
|
||
pub max_depth: u8,
|
||
/// 目标目录里已存在的文件(盘上去重的依据;空 = 目录是空的)
|
||
pub present: Vec<PresentFile>,
|
||
}
|
||
|
||
impl Default for ResolveRequest {
|
||
fn default() -> Self {
|
||
Self {
|
||
roots: Vec::new(),
|
||
game_version: None,
|
||
loader: None,
|
||
max_depth: DEFAULT_MAX_DEPTH,
|
||
present: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ResolveRequest {
|
||
/// 单根请求(内容库「装进实例」的入口形状)。
|
||
#[must_use]
|
||
pub fn single(project: impl Into<String>) -> Self {
|
||
Self {
|
||
roots: vec![project.into()],
|
||
..Self::default()
|
||
}
|
||
}
|
||
|
||
/// 这个项目的版本请求(过滤条件相同,只换项目)。
|
||
#[must_use]
|
||
pub fn version_query(&self, project: &str) -> VersionQuery {
|
||
VersionQuery {
|
||
project: project.to_string(),
|
||
game_version: self.game_version.clone(),
|
||
loader: self.loader.clone(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 计划里的一条「要装什么」。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct PlannedNode {
|
||
/// 规范项目 id(站点回的 `project_id`;空则用请求时的键)
|
||
pub project: String,
|
||
/// 当初请求它用的键(slug 或 id)——重试要用同一个
|
||
pub requested_as: String,
|
||
/// 版本 id
|
||
pub version_id: String,
|
||
/// 版本名
|
||
pub version_name: String,
|
||
/// 版本号
|
||
pub version_number: String,
|
||
/// 要下载的那个文件(`primary` 优先,见 `Version::primary_file`)
|
||
pub file: VersionFile,
|
||
/// 距根的层数(根 = 0)
|
||
pub depth: u8,
|
||
/// 谁要求装它(规范项目 id,排序去重;根为空 = 用户自己点的)
|
||
pub required_by: Vec<String>,
|
||
}
|
||
|
||
impl PlannedNode {
|
||
/// 是用户点的那个项目吗(根 = 层数 0)。
|
||
///
|
||
/// 判据是 `depth` 而不是 `required_by.is_empty()`:环里根也会被别人「要求」
|
||
/// (A ⇄ B 时 A 的 `required_by` 里有 B),但它在计划里仍是起点。
|
||
#[must_use]
|
||
pub fn is_root(&self) -> bool {
|
||
self.depth == 0
|
||
}
|
||
}
|
||
|
||
/// 为什么跳过。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum SkipReason {
|
||
/// 目标目录里已经有同名文件(含 `.disabled`)
|
||
AlreadyPresent,
|
||
}
|
||
|
||
impl SkipReason {
|
||
/// 稳定字符串 id(日志 / DTO 用,**不要改名**)。
|
||
#[must_use]
|
||
pub const fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::AlreadyPresent => "already_present",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 一条被跳过的(本来要下载的)项目。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct SkippedNode {
|
||
/// 规范项目 id
|
||
pub project: String,
|
||
/// 请求它用的键
|
||
pub requested_as: String,
|
||
/// 计划里的文件名(在目录里找到了同名的那个)
|
||
pub file_name: String,
|
||
/// 距根的层数
|
||
pub depth: u8,
|
||
/// 谁要求装它
|
||
pub required_by: Vec<String>,
|
||
/// 跳过原因
|
||
pub reason: SkipReason,
|
||
}
|
||
|
||
/// 失败的两档(**类型上就分开,不许拼成一句「下载失败」**)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum FailureKind {
|
||
/// 站点有这个项目,但所选游戏版本 / 加载器下没有可装的条目
|
||
/// (版本列表为空,或选中的版本没有可下载的主文件)。
|
||
///
|
||
/// 用户该做的动作是**换版本或换加载器**;原样重试得到的还是这一档。
|
||
Incompatible,
|
||
/// 取不到:没联网 / 项目不存在 / 站点出错 / 响应坏。
|
||
///
|
||
/// 用户该做的动作是**检查网络后再试**;具体叶子成因保留在
|
||
/// [`DependencyFailure::cause`] 上(`NotFound` 与 `NotConnected` 不能混为一谈)。
|
||
Unreachable,
|
||
}
|
||
|
||
impl FailureKind {
|
||
/// 稳定字符串 id(日志 / 命令行输出用,**不要改名**)。
|
||
#[must_use]
|
||
pub const fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::Incompatible => "incompatible",
|
||
Self::Unreachable => "unreachable",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 一条解析失败(进「可重试名单」的就是它们)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct DependencyFailure {
|
||
/// 请求它用的键(重试也用它)
|
||
pub requested_as: String,
|
||
/// 当初因为谁而被要求(规范项目 id;根为空)
|
||
pub required_by: Vec<String>,
|
||
/// 距根的层数
|
||
pub depth: u8,
|
||
/// 哪一档
|
||
pub kind: FailureKind,
|
||
/// 叶子成因(只有 [`FailureKind::Unreachable`] 有)
|
||
pub cause: Option<super::transport::OnlineErrorKind>,
|
||
/// 人读说明(站点/引擎原话 + 上下文,不是界面文案)
|
||
pub message: String,
|
||
}
|
||
|
||
/// 声明了 `required`、但**只给了 `version_id`**(没有 `project_id`)的依赖。
|
||
///
|
||
/// BFS 需要项目 id 才能取版本(`/v2/project/{id}/version`),所以这条既装不了、也不该被
|
||
/// 假装没看见:留在计划里,上层才能说「有 N 条前置没法解析」而不是默默少装一个。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct UnresolvedRequired {
|
||
/// 声明它的项目(规范 id)
|
||
pub parent: String,
|
||
/// 站点给的版本 id(`None` = 连版本 id 都没有,只有文件名)
|
||
pub version_id: Option<String>,
|
||
/// 站点给的文件名
|
||
pub file_name: Option<String>,
|
||
}
|
||
|
||
/// 一次 BFS 的完整结果(成功、跳过、失败三份名单,都**不丢**)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct DependencyPlan {
|
||
/// 这次用的请求(重试要沿用同样的过滤条件与同一份 `present`)
|
||
pub request: ResolveRequest,
|
||
/// 要装的(BFS 顺序:层号小的在前,同层按项目键字典序)
|
||
pub nodes: Vec<PlannedNode>,
|
||
/// 目录里已经有了、不用再下的
|
||
pub skipped: Vec<SkippedNode>,
|
||
/// 装不了的(两档混在同一个列表里,但每条自带 [`FailureKind`])
|
||
pub failures: Vec<DependencyFailure>,
|
||
/// 撞到 `max_depth` 而没展开的项目键(排序去重)
|
||
pub truncated: Vec<String>,
|
||
/// 有 `required` 但没有项目 id、解析不了的前置(排序去重)
|
||
pub unresolved_required: Vec<UnresolvedRequired>,
|
||
}
|
||
|
||
impl DependencyPlan {
|
||
/// 空计划(`resolve` 从这里开始填)。
|
||
#[must_use]
|
||
pub fn new(request: &ResolveRequest) -> Self {
|
||
Self {
|
||
request: request.clone(),
|
||
nodes: Vec::new(),
|
||
skipped: Vec::new(),
|
||
failures: Vec::new(),
|
||
truncated: Vec::new(),
|
||
unresolved_required: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// 一条失败都没有吗(空循环也算干净)。
|
||
#[must_use]
|
||
pub fn is_clean(&self) -> bool {
|
||
self.failures.is_empty()
|
||
}
|
||
|
||
/// 某一档的失败(两档分开看时用)。
|
||
pub fn failures_of(&self, kind: FailureKind) -> impl Iterator<Item = &DependencyFailure> {
|
||
self.failures.iter().filter(move |item| item.kind == kind)
|
||
}
|
||
|
||
/// 记一条失败——**按 `requested_as` 去重**,父边合并。
|
||
///
|
||
/// 为什么要有这道去重:同一个项目可能被两条路径要求(A→GONE、B→C→GONE)。若不去重,
|
||
/// 「可只重试这 N 个」会写出 `GONE、GONE`,用户按名单重试等于发两次一样的请求。
|
||
/// 解析阶段与下载阶段的失败都走这里,于是名单里一个项目只出现一次。
|
||
pub fn record_failure(&mut self, failure: DependencyFailure) {
|
||
if let Some(existing) = self
|
||
.failures
|
||
.iter_mut()
|
||
.find(|item| item.requested_as == failure.requested_as)
|
||
{
|
||
existing.required_by.extend(failure.required_by);
|
||
existing.required_by.sort();
|
||
existing.required_by.dedup();
|
||
// 深度取更浅的那条:越靠近根越该先修
|
||
existing.depth = existing.depth.min(failure.depth);
|
||
return;
|
||
}
|
||
self.failures.push(failure);
|
||
}
|
||
|
||
/// 「可重试名单」:失败项原样重启一次解析的请求。
|
||
///
|
||
/// 只含失败项 ⇒ 用户只重试这一部分,**不重下**已经装好的文件(`present` 沿用)。
|
||
/// 两档都在名单里,但每条自带 [`FailureKind`]:不兼容那一档重试前得换版本/加载器,
|
||
/// 换完再调它才有意义。**这不是「后台自动重试」**——本模块只给请求,什么时候发由上层决定。
|
||
///
|
||
/// 调用方如果刚跑完一轮下载,重试前应**重新扫一遍目录**再填 `present`:这一轮新落盘的
|
||
/// 文件不在旧的 `present` 里,不重扫就会把它们当「还没有」再下一份。
|
||
#[must_use]
|
||
pub fn retry_request(&self) -> ResolveRequest {
|
||
let mut roots: Vec<String> = Vec::new();
|
||
for failure in &self.failures {
|
||
if !roots.contains(&failure.requested_as) {
|
||
roots.push(failure.requested_as.clone());
|
||
}
|
||
}
|
||
ResolveRequest {
|
||
roots,
|
||
..self.request.clone()
|
||
}
|
||
}
|
||
|
||
/// 这个项目为什么在计划里(规范 id → 要求装它的项目)。`None` = 不在计划里。
|
||
#[must_use]
|
||
pub fn required_by(&self, project: &str) -> Option<&[String]> {
|
||
self.nodes
|
||
.iter()
|
||
.find(|node| node.project == project)
|
||
.map(|node| node.required_by.as_slice())
|
||
}
|
||
}
|