feat(addon): Modrinth 前置依赖 BFS 解析与两档失败名单
- model 纳入 `dependencies`(project_id / version_id / file_name + required / optional / incompatible / embedded / unknown 五值),只跟 required 递归; - 新增 `online::dependency`(请求/计划/跳过的形状)与 `online::resolve` (BFS:规范项目 id 去重防环、每项目只解一次版本、同层按项目键字典序、 默认深度 6 且截断写进计划、父子关系进 `required_by`); - 失败从类型上分两档:`Incompatible`(所选版本/加载器没有匹配项)与 `Unreachable`(并保留叶子成因 NotFound/NotConnected/…),`retry_request()` 只含失败项,解析永不返回 Err(失败是数据,不连坐); - 盘上第二道去重:`present_key` 归一化文件名(小写、砍路径、去 `.disabled`), 命中即跳过下载,但依赖图仍然展开; - presenter 装入路径改成「解析计划 → 逐个下载」,文案把两档分开写并列出 「可只重试这 N 个」;`KNOWN_LOADERS` 补 iris/optifine(光影的加载器); - 离线夹具覆盖:嵌套两层、环、扇入、深度截断、两档失败、已存在跳过、光影加载器。
This commit is contained in:
1 parent
94f792e978
commit
011d525e8d
14 files changed
+1787
-97
No files matched your search
@@ -0,0 +1,287 @@
|
||||
//! 依赖图谱的**形状**:一次解析要什么([`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,
|
||||
}
|
||||
|
||||
/// 一次 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>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条失败都没有吗(空循环也算干净)。
|
||||
#[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)
|
||||
}
|
||||
|
||||
/// 「可重试名单」:失败项原样重启一次解析的请求。
|
||||
///
|
||||
/// 只含失败项 ⇒ 用户只重试这一部分,**不重下**已经装好的文件(`present` 沿用)。
|
||||
/// 两档都在名单里,但每条自带 [`FailureKind`]:不兼容那一档重试前得换版本/加载器,
|
||||
/// 换完再调它才有意义。**这不是「后台自动重试」**——本模块只给请求,什么时候发由上层决定。
|
||||
#[must_use]
|
||||
pub fn retry_request(&self) -> ResolveRequest {
|
||||
ResolveRequest {
|
||||
roots: self
|
||||
.failures
|
||||
.iter()
|
||||
.map(|failure| failure.requested_as.clone())
|
||||
.collect(),
|
||||
..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())
|
||||
}
|
||||
}
|
||||
@@ -46,23 +46,43 @@
|
||||
//! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) |
|
||||
//! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 |
|
||||
//! | [`complete`] | 按摘要认文件([`identity_of`]):站点返回的版本里哪条才是我们要的那个 |
|
||||
//! | [`dependency`] | 依赖图谱的形状(请求 / 计划 / **两档失败**) |
|
||||
//! | [`resolve`] | BFS 解析(只跟 `required`):去重、按项目缓存、深度上限、确定性顺序 |
|
||||
//!
|
||||
//! # 一次「装一个模组」到底发几个请求
|
||||
//!
|
||||
//! M186 之前的实测口径(也是现在仍然成立的基座):`GET /v2/project/{id}/version` 一次
|
||||
//! (带 `game_versions` / `loaders` 两个数组参数)+ 文件下载一次 = **2 个请求**,
|
||||
//! 其中**没有任何**依赖解析——`dependencies` 字段根本没进模型,所以「装了模组但进游戏崩」
|
||||
//! 这条路只能靠用户自己发现缺前置。
|
||||
//!
|
||||
//! M186 之后:根项目同样是 2 个请求(版本 + 下载),每个 `required` 前置再各加 1 次版本
|
||||
//! 请求 + 1 次下载。与参考实现相比省掉了「每个前置再查一次 `/v2/project/{id}` 换 slug」,
|
||||
//! 因为依赖里给的就是项目 id,而 `/project/{id}/version` 认 id。
|
||||
|
||||
pub mod complete;
|
||||
pub mod dependency;
|
||||
pub mod download;
|
||||
pub mod model;
|
||||
pub mod modrinth;
|
||||
pub mod resolve;
|
||||
pub mod transport;
|
||||
|
||||
pub use complete::identity_of;
|
||||
pub use dependency::{
|
||||
present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile,
|
||||
ResolveRequest, SkipReason, SkippedNode, DEFAULT_MAX_DEPTH,
|
||||
};
|
||||
pub use download::{
|
||||
digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher,
|
||||
FetchArgs, RETRY_ATTEMPTS,
|
||||
};
|
||||
pub use model::{
|
||||
ContentType, FileHashes, ProjectDetail, ProjectHit, SearchPage, SearchQuery, SortIndex,
|
||||
Version, VersionFile, VersionQuery, VersionType,
|
||||
ContentType, Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage,
|
||||
SearchQuery, SortIndex, Version, VersionFile, VersionQuery, VersionType,
|
||||
};
|
||||
pub use modrinth::{HashAlgorithm, Modrinth, MODRINTH_API};
|
||||
pub use resolve::resolve;
|
||||
pub use transport::{EngineTransport, OnlineError, OnlineErrorKind, OnlineFuture, Transport};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -32,6 +32,6 @@ mod types;
|
||||
pub use parse::{known_loaders, parse_project, parse_search, parse_version, parse_versions};
|
||||
pub use query::{ContentType, SearchQuery, SortIndex, KNOWN_LOADERS};
|
||||
pub use types::{
|
||||
FileHashes, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionQuery,
|
||||
VersionType,
|
||||
Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage, Version,
|
||||
VersionFile, VersionQuery, VersionType,
|
||||
};
|
||||
@@ -8,7 +8,10 @@ use std::collections::BTreeMap;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::query::KNOWN_LOADERS;
|
||||
use super::types::{ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionType};
|
||||
use super::types::{
|
||||
Dependency, DependencyType, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile,
|
||||
VersionType,
|
||||
};
|
||||
use crate::addon::online::transport::{OnlineError, OnlineErrorKind};
|
||||
|
||||
// --------------------------------------------------------------- 解析
|
||||
@@ -112,6 +115,21 @@ struct RawVersion {
|
||||
downloads: u64,
|
||||
#[serde(default)]
|
||||
files: Vec<RawFile>,
|
||||
#[serde(default)]
|
||||
dependencies: Vec<RawDependency>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawDependency {
|
||||
#[serde(default)]
|
||||
project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
version_id: Option<String>,
|
||||
#[serde(default)]
|
||||
file_name: Option<String>,
|
||||
// 站点给的是 `required` / `optional` / `incompatible` / `embedded`;认不出 ⇒ `Unknown`
|
||||
#[serde(default)]
|
||||
dependency_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -238,6 +256,16 @@ fn version_of(raw: RawVersion) -> Version {
|
||||
primary: file.primary,
|
||||
})
|
||||
.collect(),
|
||||
dependencies: raw
|
||||
.dependencies
|
||||
.into_iter()
|
||||
.map(|dependency| Dependency {
|
||||
project_id: non_empty(dependency.project_id),
|
||||
version_id: non_empty(dependency.version_id),
|
||||
file_name: non_empty(dependency.file_name),
|
||||
dependency_type: DependencyType::parse(&dependency.dependency_type),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,20 @@ impl SortIndex {
|
||||
|
||||
/// 站点认的加载器 facet 值(`categories` 里混着 `optimization` 这种**分类标签**,
|
||||
/// 直接整份当加载器会画出假加载器)。
|
||||
pub const KNOWN_LOADERS: [&str; 6] = ["fabric", "forge", "neoforge", "quilt", "liteloader", "rift"];
|
||||
///
|
||||
/// 后两项是**光影**的加载器(Modrinth 把 shader 的 `iris` / `optifine` 也放在 `categories`
|
||||
/// 里):不登记它们,光影检索结果的 `loaders` 就会恒为空,页面既画不出「支持 Iris」,
|
||||
/// 也用不上「只看 Iris」这种判断。
|
||||
pub const KNOWN_LOADERS: [&str; 8] = [
|
||||
"fabric",
|
||||
"forge",
|
||||
"neoforge",
|
||||
"quilt",
|
||||
"liteloader",
|
||||
"rift",
|
||||
"iris",
|
||||
"optifine",
|
||||
];
|
||||
|
||||
/// 一次检索请求(分页游标也在里面:换页就是换一份 query)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -140,6 +140,67 @@ impl VersionType {
|
||||
}
|
||||
}
|
||||
|
||||
/// 前置依赖的类型(`/version` 的 `dependencies[].dependency_type`)。
|
||||
///
|
||||
/// # 为什么要留 `Unknown` 而不是「认不出就当可选」
|
||||
///
|
||||
/// 站点以后加新取值时,**只有 `Required` 才该触发递归**。认不出的值落进 `Unknown`,
|
||||
/// 于是「站点改名」的下场是「少装一个前置」(可观察、可补),而不是「把所有建议项都当必备项
|
||||
/// 递归拉一遍」(一次装模组变成几十个请求,还装了一堆不兼容的东西)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum DependencyType {
|
||||
/// 必须装(BFS **只跟这一档**)
|
||||
Required,
|
||||
/// 可选(装不装都能跑)
|
||||
#[default]
|
||||
Optional,
|
||||
/// 明确不兼容(**不要**和它一起装)
|
||||
Incompatible,
|
||||
/// 已内嵌在被依赖的文件里(不用另外装)
|
||||
Embedded,
|
||||
/// 站点给了个我们不认识的取值
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl DependencyType {
|
||||
/// 站点原文。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Required => "required",
|
||||
Self::Optional => "optional",
|
||||
Self::Incompatible => "incompatible",
|
||||
Self::Embedded => "embedded",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从站点原文解析;认不出(含空串)返回 [`Self::Unknown`]。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Self {
|
||||
match raw.trim() {
|
||||
"required" => Self::Required,
|
||||
"optional" => Self::Optional,
|
||||
"incompatible" => Self::Incompatible,
|
||||
"embedded" => Self::Embedded,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本声明的一条前置(`dependencies[]`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Dependency {
|
||||
/// 依赖的**项目** id(BFS 的地址;有了它就不用像参考实现那样再查一次 slug)
|
||||
pub project_id: Option<String>,
|
||||
/// 依赖的版本 id(站点基本只对 `embedded` 给)
|
||||
pub version_id: Option<String>,
|
||||
/// 站点给的文件名(只对 `embedded` / `incompatible` 有意义)
|
||||
pub file_name: Option<String>,
|
||||
/// 依赖类型
|
||||
pub dependency_type: DependencyType,
|
||||
}
|
||||
|
||||
/// 一个版本(`/v2/project/{id}/version[]` 与 `/v2/version_file/{hash}` 同一形状)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Version {
|
||||
@@ -163,6 +224,8 @@ pub struct Version {
|
||||
pub downloads: u64,
|
||||
/// 文件列表
|
||||
pub files: Vec<VersionFile>,
|
||||
/// 前置声明(站点原文顺序;空 = 站点没说 / 没有)
|
||||
pub dependencies: Vec<Dependency>,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
@@ -176,6 +239,25 @@ impl Version {
|
||||
.find(|file| file.primary)
|
||||
.or_else(|| self.files.first())
|
||||
}
|
||||
|
||||
/// 要跟下去的**前置项目 id**:只取 `required`,去掉空值,**排序去重**。
|
||||
///
|
||||
/// 排序在这里做(不是让调用方排):BFS 的展开顺序必须是数据本身决定的,
|
||||
/// 否则站点把 `dependencies` 换个顺序、或者用 `HashMap` 收一遍,测试就会随机红。
|
||||
#[must_use]
|
||||
pub fn required_project_ids(&self) -> Vec<&str> {
|
||||
let mut ids: Vec<&str> = self
|
||||
.dependencies
|
||||
.iter()
|
||||
.filter(|dependency| dependency.dependency_type == DependencyType::Required)
|
||||
.filter_map(|dependency| dependency.project_id.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本里的一个文件。
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! BFS 依赖解析(**只跟 `required`**)。形状在 [`super::dependency`],这里只有算法。
|
||||
//!
|
||||
//! # 与参考实现(Python 版 Modrinth 下载器)的四处差别
|
||||
//!
|
||||
//! | 参考实现 | 这里 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | 每个前置再 `GET /v2/project/{id}` 换 slug | **不查**,直接拿 `dependencies[].project_id` 当地址 | 依赖里给的就是项目 id,`/project/{id}/version` 认它;一次安装省掉每个前置一个请求 |
|
||||
//! | 失败名单是字符串,靠括号里的中文区分「不兼容」与「网络异常」 | [`FailureKind`] 枚举 | 「哪一档」要能从类型上判,不能靠 `contains("网络")` |
|
||||
//! | 一个 dict 兼做缓存与去重 | 键 = **规范项目 id**,别名单设一张表 | 同一项目可能一次以 slug、一次以 id 出现;只按请求串去重会把它装两遍 |
|
||||
//! | 队列按 `dependencies` 原文顺序展开 | 每层按项目键**字典序**处理 | 顺序必须是数据决定的,否则测试靠 HashMap 运气 |
|
||||
//!
|
||||
//! # 失败是**数据**,不是 `Err`
|
||||
//!
|
||||
//! [`resolve`] 永远返回一个 [`DependencyPlan`]:某个前置取不到版本时,它进 `failures`,
|
||||
//! 解析继续做别的分支。若中途 `return Err`,用户看到的是「装这个模组失败」,而真正该看到的
|
||||
//! 是「主文件装上了,缺 fabric-api(不兼容 1.20.1/fabric)」+一份可单独重试的名单。
|
||||
//!
|
||||
//! # 两张去重网
|
||||
//!
|
||||
//! 1. **图内**:同一项目只解一次版本(`nodes` 里每个项目最多一条),多条依赖路径折进
|
||||
//! [`PlannedNode::required_by`];
|
||||
//! 2. **盘上**:目标目录里已经有同名文件(`.disabled` 也算)就进 `skipped`、**不再下载**。
|
||||
//! 这是「已装过的同一项目跳过并提示」里我们真能判的那一半;另一半(同项目**另一个版本**
|
||||
//! 已装)需要实例侧的已装清单,本模块判不出来(见报告)。
|
||||
//!
|
||||
//! 盘上判定只比文件名(`present` 由调用方扫目录得到,core 不读盘):内容级去重需要先算一份
|
||||
//! sha1,为了「跳过」把整个 `mods/` 哈希一遍不划算;同名文件就是同一份 jar 的最常见形态
|
||||
//! (我们落盘用站点给的文件名,不重命名)。
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::dependency::{
|
||||
present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile,
|
||||
ResolveRequest, SkipReason, SkippedNode,
|
||||
};
|
||||
use super::model::Version;
|
||||
use super::modrinth::Modrinth;
|
||||
use super::transport::Transport;
|
||||
|
||||
/// 一层里还没处理的一项。
|
||||
struct Wave {
|
||||
key: String,
|
||||
depth: u8,
|
||||
parents: Vec<String>,
|
||||
}
|
||||
|
||||
/// BFS 解析。**不返回 `Err`**:失败进 [`DependencyPlan::failures`](见模块文档)。
|
||||
pub async fn resolve<T: Transport + ?Sized>(
|
||||
client: &Modrinth<'_, T>,
|
||||
request: &ResolveRequest,
|
||||
) -> DependencyPlan {
|
||||
let mut plan = DependencyPlan::new(request);
|
||||
let present: BTreeSet<String> = request.present.iter().map(PresentFile::key).collect();
|
||||
// 请求键 → 规范 id(同一项目可能一次给 slug、一次给 id)
|
||||
let mut alias: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut resolved: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
let mut layer: Vec<Wave> = Vec::new();
|
||||
for root in &request.roots {
|
||||
let key = root.trim();
|
||||
if !key.is_empty() {
|
||||
wave_in(&mut layer, key, 0, None);
|
||||
}
|
||||
}
|
||||
|
||||
while !layer.is_empty() {
|
||||
// 同层按项目键字典序:顺序由数据决定,不靠插入顺序或 HashMap 的运气。
|
||||
layer.sort_by(|left, right| left.key.cmp(&right.key));
|
||||
let mut next: Vec<Wave> = Vec::new();
|
||||
for mut item in layer.drain(..) {
|
||||
normalize(&mut item.parents);
|
||||
// ① 已经解过(含「A 依赖 B、B 又依赖 A」的环)⇒ 只把父边折进已有节点
|
||||
if let Some(canonical) = alias.get(&item.key).cloned() {
|
||||
fold_parents(&mut plan, &canonical, &item.parents);
|
||||
continue;
|
||||
}
|
||||
let version = match client.versions(&request.version_query(&item.key)).await {
|
||||
Ok(versions) => match versions.into_iter().next() {
|
||||
Some(version) => version,
|
||||
None => {
|
||||
plan.failures.push(DependencyFailure {
|
||||
requested_as: item.key,
|
||||
required_by: item.parents,
|
||||
depth: item.depth,
|
||||
kind: FailureKind::Incompatible,
|
||||
cause: None,
|
||||
message: filter_message(request),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
plan.failures.push(DependencyFailure {
|
||||
requested_as: item.key,
|
||||
required_by: item.parents,
|
||||
depth: item.depth,
|
||||
kind: FailureKind::Unreachable,
|
||||
cause: Some(error.kind()),
|
||||
message: format!(
|
||||
"取版本失败({}):{}",
|
||||
error.kind().as_str(),
|
||||
error.message()
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(file) = version
|
||||
.primary_file()
|
||||
.filter(|file| !file.url.trim().is_empty())
|
||||
.cloned()
|
||||
else {
|
||||
let message = format!(
|
||||
"{} 的版本 {} 里没有可下载的主文件",
|
||||
item.key,
|
||||
version_label(&version)
|
||||
);
|
||||
plan.failures.push(DependencyFailure {
|
||||
requested_as: item.key,
|
||||
required_by: item.parents,
|
||||
depth: item.depth,
|
||||
kind: FailureKind::Incompatible,
|
||||
cause: None,
|
||||
message,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
let canonical = canonical_of(&item.key, &version);
|
||||
alias.insert(item.key.clone(), canonical.clone());
|
||||
alias
|
||||
.entry(canonical.clone())
|
||||
.or_insert_with(|| canonical.clone());
|
||||
// ② 同一项目走了两条路(slug 与 id 各一次)⇒ 折边,不重复装
|
||||
if !resolved.insert(canonical.clone()) {
|
||||
fold_parents(&mut plan, &canonical, &item.parents);
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_name = file.filename.clone();
|
||||
if present.contains(&present_key(&file_name)) {
|
||||
plan.skipped.push(SkippedNode {
|
||||
project: canonical.clone(),
|
||||
requested_as: item.key.clone(),
|
||||
file_name,
|
||||
depth: item.depth,
|
||||
required_by: item.parents.clone(),
|
||||
reason: SkipReason::AlreadyPresent,
|
||||
});
|
||||
} else {
|
||||
plan.nodes.push(PlannedNode {
|
||||
project: canonical.clone(),
|
||||
requested_as: item.key.clone(),
|
||||
version_id: version.id.clone(),
|
||||
version_name: version.name.clone(),
|
||||
version_number: version.version_number.clone(),
|
||||
file,
|
||||
depth: item.depth,
|
||||
required_by: item.parents.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// 子项:只跟 required(排序去重由 `required_project_ids` 保证)
|
||||
for dependency in version.required_project_ids() {
|
||||
if item.depth >= request.max_depth {
|
||||
plan.truncated.push(dependency.to_string());
|
||||
} else {
|
||||
wave_in(&mut next, dependency, item.depth + 1, Some(&canonical));
|
||||
}
|
||||
}
|
||||
}
|
||||
plan.truncated.sort();
|
||||
plan.truncated.dedup();
|
||||
layer = next;
|
||||
}
|
||||
|
||||
plan
|
||||
}
|
||||
|
||||
/// 把一项放进这一层的待处理表(同一个键已经排队 ⇒ 只追加父项目)。
|
||||
fn wave_in(layer: &mut Vec<Wave>, key: &str, depth: u8, parent: Option<&str>) {
|
||||
if let Some(existing) = layer.iter_mut().find(|item| item.key == key) {
|
||||
if let Some(parent) = parent {
|
||||
existing.parents.push(parent.to_string());
|
||||
}
|
||||
return;
|
||||
}
|
||||
layer.push(Wave {
|
||||
key: key.to_string(),
|
||||
depth,
|
||||
parents: parent
|
||||
.map(|parent| vec![parent.to_string()])
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 往已经建好的节点上折一条父边(节点可能在被跳过的那一档,也可能只出现在失败名单里)。
|
||||
fn fold_parents(plan: &mut DependencyPlan, project: &str, parents: &[String]) {
|
||||
if parents.is_empty() {
|
||||
return;
|
||||
}
|
||||
let node = plan
|
||||
.nodes
|
||||
.iter_mut()
|
||||
.find(|node| node.project == project)
|
||||
.map(|node| &mut node.required_by);
|
||||
let node = node.or_else(|| {
|
||||
plan.skipped
|
||||
.iter_mut()
|
||||
.find(|node| node.project == project)
|
||||
.map(|node| &mut node.required_by)
|
||||
});
|
||||
if let Some(required_by) = node {
|
||||
required_by.extend(parents.iter().cloned());
|
||||
normalize(required_by);
|
||||
return;
|
||||
}
|
||||
if let Some(failure) = plan
|
||||
.failures
|
||||
.iter_mut()
|
||||
.find(|failure| failure.requested_as == project)
|
||||
{
|
||||
failure.required_by.extend(parents.iter().cloned());
|
||||
normalize(&mut failure.required_by);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(list: &mut Vec<String>) {
|
||||
list.sort();
|
||||
list.dedup();
|
||||
}
|
||||
|
||||
/// 规范身份:站点回的 `project_id` 才是身份;没给就退回请求键。
|
||||
fn canonical_of(key: &str, version: &Version) -> String {
|
||||
let project_id = version.project_id.trim();
|
||||
if project_id.is_empty() {
|
||||
key.to_string()
|
||||
} else {
|
||||
project_id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 「哪里没有匹配项」这句话(第一档的固定说法,带出用的是什么过滤条件)。
|
||||
fn filter_message(request: &ResolveRequest) -> String {
|
||||
let game_version = request.game_version.as_deref().unwrap_or("不限");
|
||||
let loader = request.loader.as_deref().unwrap_or("不限");
|
||||
format!("没有匹配所选游戏版本/加载器({game_version} / {loader})的版本")
|
||||
}
|
||||
|
||||
fn version_label(version: &Version) -> String {
|
||||
let number = version.version_number.trim();
|
||||
if number.is_empty() {
|
||||
version.id.clone()
|
||||
} else {
|
||||
number.to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
//! 依赖解析的离线用例(录制 JSON 夹具,**不打真实网络**)。
|
||||
//!
|
||||
//! 三条守门对应任务书的三条:
|
||||
//! 1. [`two_nested_levels_are_pulled_in_a_predictable_order`] —— 嵌套两层能拉全,顺序确定;
|
||||
//! 2. [`a_dependency_cycle_terminates_and_each_project_appears_once`] —— 环不无限递归;
|
||||
//! 3. [`an_incompatible_dependency_lands_in_the_retry_list`] —— 不兼容进重试名单,不静默丢。
|
||||
//!
|
||||
//! 每条都配过一次「把实现改坏就该红」的变异(过程写在 M186 报告里),所以断言不是装饰。
|
||||
|
||||
use crate::addon::online::dependency::{
|
||||
present_key, DependencyPlan, FailureKind, PresentFile, ResolveRequest, SkipReason,
|
||||
};
|
||||
use crate::addon::online::model::parse_versions;
|
||||
use crate::addon::online::{resolve, Modrinth, OnlineErrorKind, MODRINTH_API};
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::fixtures_deps as deps;
|
||||
use super::support::FixtureTransport;
|
||||
|
||||
const SODIUM_VERSIONS: &str = "https://api.modrinth.com/v2/project/sodium/version";
|
||||
const FABRIC_API_VERSIONS: &str = "https://api.modrinth.com/v2/project/P7dR8mSH/version";
|
||||
const YACL_VERSIONS: &str = "https://api.modrinth.com/v2/project/1eAoo2KR/version";
|
||||
|
||||
fn request(root: &str) -> ResolveRequest {
|
||||
ResolveRequest {
|
||||
roots: vec![root.to_string()],
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
loader: Some("fabric".to_string()),
|
||||
..ResolveRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 三层链的传输层(sodium → fabric-api → yacl)。
|
||||
fn chain_transport() -> FixtureTransport {
|
||||
FixtureTransport::new()
|
||||
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
|
||||
.with(FABRIC_API_VERSIONS, deps::FABRIC_API_WITH_DEPENDENCIES)
|
||||
.with(YACL_VERSIONS, deps::YACL)
|
||||
}
|
||||
|
||||
fn resolve_with(transport: &FixtureTransport, request: &ResolveRequest) -> DependencyPlan {
|
||||
let client = Modrinth::new(transport);
|
||||
block_on(resolve(&client, request))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ① 嵌套两层
|
||||
|
||||
#[test]
|
||||
fn two_nested_levels_are_pulled_in_a_predictable_order() {
|
||||
let transport = chain_transport();
|
||||
let plan = resolve_with(&transport, &request("sodium"));
|
||||
|
||||
assert!(plan.is_clean(), "不该有失败:{:?}", plan.failures);
|
||||
// 顺序 = BFS 层序,同层按项目键字典序:根 → fabric-api → yacl
|
||||
let order: Vec<(&str, u8)> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| (node.project.as_str(), node.depth))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
order,
|
||||
vec![("AANobbMI", 0), ("P7dR8mSH", 1), ("1eAoo2KR", 2)]
|
||||
);
|
||||
|
||||
// 「为什么装」:父子关系逐条对得上
|
||||
assert!(plan.required_by("AANobbMI").expect("根在计划里").is_empty());
|
||||
assert_eq!(
|
||||
plan.required_by("P7dR8mSH").expect("fabric-api 在计划里"),
|
||||
["AANobbMI".to_string()].as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
plan.required_by("1eAoo2KR").expect("yacl 在计划里"),
|
||||
["P7dR8mSH".to_string()].as_slice()
|
||||
);
|
||||
|
||||
// 只跟 required:optional / incompatible / embedded 三个 id 一次请求都没发
|
||||
let seen = transport.seen();
|
||||
assert_eq!(seen.len(), 3, "只该有 3 次版本请求:{seen:?}");
|
||||
for quiet in ["OCS3z7f6", "AAAABBBB", "embedded-lib"] {
|
||||
assert!(
|
||||
!seen.iter().any(|url| url.contains(quiet)),
|
||||
"{quiet} 不该被跟:{seen:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// 主文件取 primary(第一个是 sources.jar)
|
||||
assert_eq!(plan.nodes[0].file.filename, "sodium-fabric-0.6.0.jar");
|
||||
assert_eq!(plan.nodes[1].file.filename, "fabric-api-0.92.2+1.20.1.jar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_required_dependencies_are_followed() {
|
||||
// 模型层:四值都解析得出来,`required_project_ids` 只吐 required 那一条。
|
||||
let versions = parse_versions(deps::SODIUM_WITH_DEPENDENCIES).expect("夹具该解析成功");
|
||||
let version = &versions[0];
|
||||
assert_eq!(version.dependencies.len(), 4);
|
||||
let kinds: Vec<&str> = version
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(|dependency| dependency.dependency_type.as_str())
|
||||
.collect();
|
||||
assert_eq!(kinds, ["required", "optional", "incompatible", "embedded"]);
|
||||
assert_eq!(version.required_project_ids(), ["P7dR8mSH"]);
|
||||
// embedded 那条只有 file_name、没有 project_id,不该冒出一个空 id
|
||||
assert!(version.dependencies[3].project_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_depth_limit_is_reported_instead_of_silently_cutting_the_chain() {
|
||||
let transport = chain_transport();
|
||||
let mut request = request("sodium");
|
||||
request.max_depth = 1;
|
||||
let plan = resolve_with(&transport, &request);
|
||||
|
||||
assert_eq!(plan.nodes.len(), 2, "根 + 第一层");
|
||||
assert_eq!(plan.truncated, ["1eAoo2KR"], "截断必须写在计划里");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ② 环
|
||||
|
||||
#[test]
|
||||
fn a_dependency_cycle_terminates_and_each_project_appears_once() {
|
||||
let transport = FixtureTransport::new()
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/cycle-a/version",
|
||||
deps::CYCLE_A,
|
||||
)
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/cycBid01/version",
|
||||
deps::CYCLE_B,
|
||||
);
|
||||
let plan = resolve_with(&transport, &request("cycle-a"));
|
||||
|
||||
let projects: Vec<&str> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| node.project.as_str())
|
||||
.collect();
|
||||
assert_eq!(projects, ["cycAid01", "cycBid01"], "每个项目只出现一次");
|
||||
assert_eq!(transport.seen().len(), 2, "环不许把请求数放大");
|
||||
// B 是被 A 要来的;A 又被 B 要(环)——这条边折回已有节点,不新建节点
|
||||
assert_eq!(
|
||||
plan.required_by("cycBid01").expect("B 在计划里"),
|
||||
["cycAid01".to_string()].as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
plan.required_by("cycAid01").expect("A 在计划里"),
|
||||
["cycBid01".to_string()].as_slice()
|
||||
);
|
||||
assert!(plan.nodes[0].is_root(), "根判据是 depth,不是 required_by");
|
||||
assert!(!plan.nodes[1].is_root());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ③ 两档失败
|
||||
|
||||
#[test]
|
||||
fn an_incompatible_dependency_lands_in_the_retry_list() {
|
||||
// fabric-api 在 1.20.1/fabric 下没有条目(站点回空数组)
|
||||
let transport = FixtureTransport::new()
|
||||
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
|
||||
.with(FABRIC_API_VERSIONS, "[]");
|
||||
let plan = resolve_with(&transport, &request("sodium"));
|
||||
|
||||
// 根还在计划里(失败不连坐),缺的那条单独立案
|
||||
assert_eq!(plan.nodes.len(), 1);
|
||||
assert_eq!(plan.failures.len(), 1, "不许静默丢:{:?}", plan.failures);
|
||||
let failure = &plan.failures[0];
|
||||
assert_eq!(failure.kind, FailureKind::Incompatible);
|
||||
assert_eq!(failure.requested_as, "P7dR8mSH");
|
||||
assert_eq!(failure.depth, 1);
|
||||
assert_eq!(failure.required_by, ["AANobbMI".to_string()]);
|
||||
assert!(failure.cause.is_none(), "这一档不是网络成因");
|
||||
assert!(failure.message.contains("1.20.1"), "文案要带过滤条件");
|
||||
assert!(plan.failures_of(FailureKind::Unreachable).next().is_none());
|
||||
|
||||
// 可重试名单只含失败项,过滤条件沿用
|
||||
let retry = plan.retry_request();
|
||||
assert_eq!(retry.roots, ["P7dR8mSH"]);
|
||||
assert_eq!(retry.game_version.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(retry.loader.as_deref(), Some("fabric"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_project_is_unreachable_and_keeps_its_leaf_cause() {
|
||||
let transport = FixtureTransport::new()
|
||||
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
|
||||
.missing(FABRIC_API_VERSIONS);
|
||||
let plan = resolve_with(&transport, &request("sodium"));
|
||||
|
||||
let failure = &plan.failures[0];
|
||||
assert_eq!(failure.kind, FailureKind::Unreachable);
|
||||
assert_eq!(failure.cause, Some(OnlineErrorKind::NotFound));
|
||||
// 两档从类型上就分得开:同一个计划里两种成因不会挤进一条
|
||||
assert_eq!(plan.failures_of(FailureKind::Incompatible).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_without_a_usable_file_is_incompatible_not_a_network_error() {
|
||||
let transport = FixtureTransport::new()
|
||||
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
|
||||
.with(
|
||||
FABRIC_API_VERSIONS,
|
||||
r#"[{"id": "ver-empty", "project_id": "P7dR8mSH",
|
||||
"version_number": "0.0", "files": []}]"#,
|
||||
);
|
||||
let plan = resolve_with(&transport, &request("sodium"));
|
||||
assert_eq!(plan.failures[0].kind, FailureKind::Incompatible);
|
||||
assert!(plan.failures[0].message.contains("没有可下载的主文件"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 两道去重
|
||||
|
||||
#[test]
|
||||
fn one_project_required_by_two_parents_is_planned_once_with_both_parents() {
|
||||
let transport = FixtureTransport::new()
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/fanin-root/version",
|
||||
deps::FANIN_ROOT,
|
||||
)
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/BRANCHAA/version",
|
||||
deps::FANIN_A,
|
||||
)
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/BRANCHBB/version",
|
||||
deps::FANIN_B,
|
||||
)
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/SHARED01/version",
|
||||
deps::SHARED_LIB,
|
||||
);
|
||||
let plan = resolve_with(&transport, &request("fanin-root"));
|
||||
|
||||
let projects: Vec<&str> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| node.project.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
projects,
|
||||
["ROOT0001", "BRANCHAA", "BRANCHBB", "SHARED01"],
|
||||
"同层按项目键字典序,公共前置只出现一次"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.required_by("SHARED01").expect("公共前置在计划里"),
|
||||
["BRANCHAA".to_string(), "BRANCHBB".to_string()].as_slice(),
|
||||
"两条依赖路径都要留下"
|
||||
);
|
||||
let shared_requests = transport
|
||||
.seen()
|
||||
.iter()
|
||||
.filter(|url| url.contains("SHARED01"))
|
||||
.count();
|
||||
assert_eq!(shared_requests, 1, "一个项目只解一次版本");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_already_exists_is_skipped_but_its_dependencies_are_still_walked() {
|
||||
let mut request = request("sodium");
|
||||
// 禁用中的 jar 也算「装过」(本仓用 `.disabled` 后缀表示禁用)
|
||||
request.present = vec![PresentFile::new("fabric-api-0.92.2+1.20.1.jar.disabled")];
|
||||
let transport = chain_transport();
|
||||
let plan = resolve_with(&transport, &request);
|
||||
|
||||
assert_eq!(plan.skipped.len(), 1);
|
||||
let skipped = &plan.skipped[0];
|
||||
assert_eq!(skipped.project, "P7dR8mSH");
|
||||
assert_eq!(skipped.reason, SkipReason::AlreadyPresent);
|
||||
assert_eq!(skipped.file_name, "fabric-api-0.92.2+1.20.1.jar");
|
||||
// 跳过的是**下载**,不是图:fabric-api 的前置照样解析出来
|
||||
let projects: Vec<&str> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| node.project.as_str())
|
||||
.collect();
|
||||
assert_eq!(projects, ["AANobbMI", "1eAoo2KR"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_keys_normalize_paths_case_and_the_disabled_suffix() {
|
||||
assert_eq!(present_key("mods/Sodium-0.6.0.jar"), "sodium-0.6.0.jar");
|
||||
assert_eq!(present_key("a\\b\\x.jar.disabled"), "x.jar");
|
||||
assert_eq!(present_key(" y.jar "), "y.jar");
|
||||
assert_eq!(present_key(""), "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 光影过滤器
|
||||
|
||||
#[test]
|
||||
fn shader_loaders_are_recognised_in_search_hits() {
|
||||
let transport =
|
||||
FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), deps::SEARCH_SHADER);
|
||||
let client = Modrinth::new(&transport);
|
||||
let page = block_on(client.search(&crate::addon::online::SearchQuery {
|
||||
text: String::new(),
|
||||
project_type: Some(crate::addon::online::ContentType::Shader),
|
||||
limit: 1,
|
||||
..crate::addon::online::SearchQuery::default()
|
||||
}))
|
||||
.expect("解析应当成功");
|
||||
assert_eq!(
|
||||
page.hits[0].loaders,
|
||||
vec!["iris".to_string(), "optifine".to_string()],
|
||||
"光影的加载器不该被当成分类标签丢掉"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! 依赖解析用的录制响应(形状按站点原文;数值是示意,不是某天的真值)。
|
||||
//!
|
||||
//! 从 `fixtures.rs` 分出来的原因很实在:那里已经在钉「字段名与 `primary`」这类解析细节,
|
||||
//! 再往里塞四五个带 `dependencies` 的版本对象就会贴到 400 行硬线。
|
||||
//!
|
||||
//! 三条刻意的布置:
|
||||
//! - 根项目(`sodium`)的版本里**同时有四类依赖**:只有 `required` 该被跟;
|
||||
//! - 每个版本都有 `primary: false` 的 `sources.jar`,取第一个就会装错;
|
||||
//! - 环(`cycle-a` ⇄ `cycle-b`)与扇入(两个分支都要 `shared-lib`)各有一组独立夹具。
|
||||
|
||||
/// 根项目 `sodium` 的版本:一条 required + 一条 optional + 一条 incompatible + 一条 embedded。
|
||||
pub(super) const SODIUM_WITH_DEPENDENCIES: &str = r#"[
|
||||
{
|
||||
"id": "ver-sodium",
|
||||
"project_id": "AANobbMI",
|
||||
"name": "Sodium 0.6.0 for 1.20.1",
|
||||
"version_number": "mc1.20.1-0.6.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"date_published": "2026-02-01T12:00:00Z",
|
||||
"downloads": 4321,
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "1111111111111111111111111111111111111111", "sha512": "aa"},
|
||||
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-sources.jar",
|
||||
"filename": "sodium-sources.jar",
|
||||
"size": 1000,
|
||||
"primary": false
|
||||
},
|
||||
{
|
||||
"hashes": {"sha1": "2222222222222222222222222222222222222222", "sha512": "bb"},
|
||||
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-fabric-0.6.0.jar",
|
||||
"filename": "sodium-fabric-0.6.0.jar",
|
||||
"size": 2048,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"version_id": null, "project_id": "P7dR8mSH", "file_name": null, "dependency_type": "required"},
|
||||
{"version_id": null, "project_id": "OCS3z7f6", "file_name": null, "dependency_type": "optional"},
|
||||
{"version_id": null, "project_id": "AAAABBBB", "file_name": null, "dependency_type": "incompatible"},
|
||||
{"version_id": "ver-embedded", "project_id": null, "file_name": "embedded-lib.jar", "dependency_type": "embedded"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// `fabric-api`(`P7dR8mSH`):它自己又要求 `yacl`(`1eAoo2KR`)⇒ 第二层嵌套。
|
||||
pub(super) const FABRIC_API_WITH_DEPENDENCIES: &str = r#"[
|
||||
{
|
||||
"id": "ver-fabric-api",
|
||||
"project_id": "P7dR8mSH",
|
||||
"name": "Fabric API 0.92.2 for 1.20.1",
|
||||
"version_number": "0.92.2+1.20.1",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"date_published": "2025-12-01T00:00:00Z",
|
||||
"downloads": 9999,
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "3333333333333333333333333333333333333333", "sha512": "cc"},
|
||||
"url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2-sources.jar",
|
||||
"filename": "fabric-api-0.92.2-sources.jar",
|
||||
"size": 512,
|
||||
"primary": false
|
||||
},
|
||||
{
|
||||
"hashes": {"sha1": "4444444444444444444444444444444444444444", "sha512": "dd"},
|
||||
"url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2+1.20.1.jar",
|
||||
"filename": "fabric-api-0.92.2+1.20.1.jar",
|
||||
"size": 4096,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"version_id": null, "project_id": "1eAoo2KR", "file_name": null, "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// `yacl`(`1eAoo2KR`):链的末端,没有依赖。
|
||||
pub(super) const YACL: &str = r#"[
|
||||
{
|
||||
"id": "ver-yacl",
|
||||
"project_id": "1eAoo2KR",
|
||||
"name": "YetAnotherConfigLib 3.3.2",
|
||||
"version_number": "3.3.2+1.20.1",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"date_published": "2025-11-01T00:00:00Z",
|
||||
"downloads": 1234,
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "5555555555555555555555555555555555555555", "sha512": "ee"},
|
||||
"url": "https://cdn.modrinth.com/data/1eAoo2KR/versions/ver-yacl/yacl-3.3.2.jar",
|
||||
"filename": "yacl-3.3.2+1.20.1.jar",
|
||||
"size": 8192,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": []
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 扇入的根:两个 required 分支。
|
||||
pub(super) const FANIN_ROOT: &str = r#"[
|
||||
{
|
||||
"id": "ver-root",
|
||||
"project_id": "ROOT0001",
|
||||
"name": "Fan-in Root 1.0",
|
||||
"version_number": "1.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "6666666666666666666666666666666666666666", "sha512": "ff"},
|
||||
"url": "https://cdn.modrinth.com/data/ROOT0001/versions/ver-root/fanin-root-1.0.jar",
|
||||
"filename": "fanin-root-1.0.jar",
|
||||
"size": 1024,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"project_id": "BRANCHAA", "dependency_type": "required"},
|
||||
{"project_id": "BRANCHBB", "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 分支 A:也要 `shared-lib`。
|
||||
pub(super) const FANIN_A: &str = r#"[
|
||||
{
|
||||
"id": "ver-branch-a",
|
||||
"project_id": "BRANCHAA",
|
||||
"name": "Branch A 2.0",
|
||||
"version_number": "2.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "7777777777777777777777777777777777777777", "sha512": "ab"},
|
||||
"url": "https://cdn.modrinth.com/data/BRANCHAA/versions/ver-branch-a/branch-a-2.0.jar",
|
||||
"filename": "branch-a-2.0.jar",
|
||||
"size": 1024,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"project_id": "SHARED01", "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 分支 B:也要 `shared-lib`(同一项目两条路径 ⇒ 只装一次)。
|
||||
pub(super) const FANIN_B: &str = r#"[
|
||||
{
|
||||
"id": "ver-branch-b",
|
||||
"project_id": "BRANCHBB",
|
||||
"name": "Branch B 3.0",
|
||||
"version_number": "3.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "8888888888888888888888888888888888888888", "sha512": "ac"},
|
||||
"url": "https://cdn.modrinth.com/data/BRANCHBB/versions/ver-branch-b/branch-b-3.0.jar",
|
||||
"filename": "branch-b-3.0.jar",
|
||||
"size": 1024,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"project_id": "SHARED01", "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 扇入的公共前置。
|
||||
pub(super) const SHARED_LIB: &str = r#"[
|
||||
{
|
||||
"id": "ver-shared",
|
||||
"project_id": "SHARED01",
|
||||
"name": "Shared Lib 1.5",
|
||||
"version_number": "1.5",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "9999999999999999999999999999999999999999", "sha512": "ad"},
|
||||
"url": "https://cdn.modrinth.com/data/SHARED01/versions/ver-shared/shared-lib-1.5.jar",
|
||||
"filename": "shared-lib-1.5.jar",
|
||||
"size": 2048,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": []
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 环的一端:`cycle-a` 要求 `cycle-b`。
|
||||
pub(super) const CYCLE_A: &str = r#"[
|
||||
{
|
||||
"id": "ver-cycle-a",
|
||||
"project_id": "cycAid01",
|
||||
"name": "Cycle A 1.0",
|
||||
"version_number": "1.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "sha512": "a1"},
|
||||
"url": "https://cdn.modrinth.com/data/cycAid01/versions/ver-cycle-a/cycle-a-1.0.jar",
|
||||
"filename": "cycle-a-1.0.jar",
|
||||
"size": 1024,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"project_id": "cycBid01", "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 环的另一端:`cycle-b` 又要求 `cycle-a`(不设防就是无限递归)。
|
||||
pub(super) const CYCLE_B: &str = r#"[
|
||||
{
|
||||
"id": "ver-cycle-b",
|
||||
"project_id": "cycBid01",
|
||||
"name": "Cycle B 1.0",
|
||||
"version_number": "1.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sha512": "b1"},
|
||||
"url": "https://cdn.modrinth.com/data/cycBid01/versions/ver-cycle-b/cycle-b-1.0.jar",
|
||||
"filename": "cycle-b-1.0.jar",
|
||||
"size": 1024,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"project_id": "cycAid01", "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 光影检索的一页:`categories` 里是 `iris` / `optifine`(光影的加载器)。
|
||||
pub(super) const SEARCH_SHADER: &str = r#"{
|
||||
"hits": [
|
||||
{
|
||||
"project_id": "shader01",
|
||||
"project_type": "shader",
|
||||
"slug": "complementary-reimagined",
|
||||
"author": "EminGT",
|
||||
"title": "Complementary Reimagined",
|
||||
"description": "A shader pack",
|
||||
"categories": ["iris", "optifine", "shaders"],
|
||||
"versions": ["1.20.1"],
|
||||
"downloads": 1234567,
|
||||
"follows": 4242,
|
||||
"icon_url": "",
|
||||
"date_modified": "2026-01-02T03:04:05Z"
|
||||
}
|
||||
],
|
||||
"offset": 0,
|
||||
"limit": 1,
|
||||
"total_hits": 1
|
||||
}"#;
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
mod api;
|
||||
mod classify;
|
||||
mod dependency;
|
||||
mod download;
|
||||
mod fixtures;
|
||||
mod fixtures_deps;
|
||||
mod support;
|
||||
Reference in new issue
Block a user