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
@@ -24,7 +24,8 @@
|
||||
//! |---|---|
|
||||
//! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] |
|
||||
//! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 |
|
||||
//! | `install` | 一键装入:解析文件 → 下载到实例目录 → 进度回流 |
|
||||
//! | [`install`] | 一键装入:解析文件(含 `required` 前置)→ 下载到实例目录 → 进度回流 |
|
||||
//! | [`plan`] | 依赖计划的宿主侧:扫目录、两档失败 → DTO 大类、人读文案 |
|
||||
//!
|
||||
//! ## 整合包与 CF 的边界(都写进报告)
|
||||
//!
|
||||
@@ -34,6 +35,7 @@
|
||||
|
||||
mod http;
|
||||
mod install;
|
||||
mod plan;
|
||||
// M113: import/export —— 整合包导入/导出(拆成子目录:父文件只留既有内容 + mod)
|
||||
mod pack;
|
||||
mod sources;
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
//! 「一键装入指定实例」(M109)。
|
||||
//! 「一键装入指定实例」(M109;M186 起把前置一起装上)。
|
||||
//!
|
||||
//! ## 网络只走 code 的在线内容源(M162 r1)
|
||||
//!
|
||||
//! 解析「哪个文件」用 `core::addon::online::Modrinth::versions`(同一个引擎传输层 ⇒ 信号量、
|
||||
//! 源健康度、退避都在引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。
|
||||
//! 本文件**不再**自己拼 `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。
|
||||
//! 解析「哪个文件」用 `core::addon::online`(同一个引擎传输层 ⇒ 信号量、源健康度、退避都在
|
||||
//! 引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。本文件**不再**自己拼
|
||||
//! `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。
|
||||
//!
|
||||
//! ## 它到底做了什么(诚实版)
|
||||
//!
|
||||
//! | 内容类型 | 做什么 | 状态 |
|
||||
//! |---|---|---|
|
||||
//! | Mod / 资源包 / 光影 | 解析「该项目下匹配目标实例(版本 + 加载器)的那个文件」→ core 下载引擎下载到实例运行目录的 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流(**复用下载页那块面板**) | 已接 |
|
||||
//! | Mod / 资源包 / 光影 | 解析「该项目 + 它的 `required` 前置」在目标实例(版本 + 加载器)下的文件 → core 下载引擎下到 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流 | 已接(M186 起含前置) |
|
||||
//! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) |
|
||||
//! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** |
|
||||
//!
|
||||
//! ## 前置的两档失败与重试名单(M186)
|
||||
//!
|
||||
//! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」
|
||||
//! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项
|
||||
//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次,
|
||||
//! `DependencyPlan::retry_request()` 已经把请求备好了。
|
||||
//!
|
||||
//! ## 光影与数据包:支持到哪一步(M186 核对过,别写成「都支持」)
|
||||
//!
|
||||
//! | 内容 | 检索 | 装入 | 差在哪 |
|
||||
//! |---|---|---|---|
|
||||
//! | 光影 | 支持(`project_type:shader`);命中项的加载器标签现在认得 `iris` / `optifine`(`KNOWN_LOADERS`) | 支持(落 `shaderpacks/`) | 加载器**过滤**仍筛不了:`LoaderKind::OptiFine` 没有 facet(`loader_facet` 返回 `None`),所以「只看 Iris 光影」只能靠标签目测 |
|
||||
//! | 数据包 | core 的 `ContentType::Datapack` 有 `datapack` facet | **不支持** | DTO 的 `ContentKind` 没有这一项;数据包还要落到「某个世界的 `datapacks/`」,现在没有选择世界这一步 |
|
||||
//!
|
||||
//! ## 为什么进度只有阶段级,没有字节条
|
||||
//!
|
||||
//! 字节级进度要走 `ProgressEmitter`(core 的 tokio 广播),而 presenter 的依赖表里没有
|
||||
@@ -26,7 +40,7 @@ use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
download_verified, EngineArtifactFetcher, EngineTransport, Modrinth, VersionQuery,
|
||||
download_verified, resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
@@ -39,7 +53,7 @@ use crate::dto::{
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
use super::sources::failure_of;
|
||||
use super::{plan, sources::failure_of};
|
||||
|
||||
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
|
||||
pub(super) fn install(
|
||||
@@ -112,7 +126,10 @@ fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailu
|
||||
});
|
||||
}
|
||||
|
||||
/// 真正干活的那一段,返回一句人读的落盘描述(失败带**大类**,离线不等同于「查无此项」)。
|
||||
/// 真正干活的那一段:解析(含前置)→ 逐个下载 → 汇总。
|
||||
///
|
||||
/// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由
|
||||
/// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。
|
||||
fn run(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
@@ -130,33 +147,90 @@ fn run(
|
||||
let target_dir = game_dir.join(dir);
|
||||
std::fs::create_dir_all(&target_dir)
|
||||
.map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?;
|
||||
|
||||
let file = resolve_file(env, request)?;
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(local("已取消(下载开始前)"));
|
||||
if request.source != ContentSource::Modrinth {
|
||||
return Err(ContentFailure::unsupported(
|
||||
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)",
|
||||
));
|
||||
}
|
||||
|
||||
// 解析:根项目 + 只跟 `required` 的前置(顺序与父子关系由 core 给,这里不重排)。
|
||||
let resolve_request = plan::request(
|
||||
&request.project,
|
||||
&request.game_version,
|
||||
loader_facet(request.loader),
|
||||
plan::present_files(&target_dir),
|
||||
);
|
||||
let core_engine = super::super::engine::engine(env);
|
||||
let dependency_plan = {
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
block_on(resolve(&client, &resolve_request))
|
||||
};
|
||||
|
||||
progress.stage = InstallStage::Downloading;
|
||||
progress.message = file.name.clone();
|
||||
progress.files_total = dependency_plan.nodes.len() as u64;
|
||||
progress.files_done = 0;
|
||||
progress.message = if dependency_plan.nodes.is_empty() {
|
||||
"没有要下载的文件".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{} 个文件(含 {} 个前置)",
|
||||
dependency_plan.nodes.len(),
|
||||
dependency_plan
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| !node.is_root())
|
||||
.count()
|
||||
)
|
||||
};
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
|
||||
let dest = target_dir.join(&file.name);
|
||||
// M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核,
|
||||
// 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。
|
||||
let core_engine = super::super::engine::engine(env);
|
||||
let fetcher = EngineArtifactFetcher::new(&core_engine);
|
||||
block_on(download_verified(
|
||||
&fetcher,
|
||||
&file.url,
|
||||
file.sha1.as_deref(),
|
||||
file.sha512.as_deref(),
|
||||
file.size,
|
||||
&dest,
|
||||
))
|
||||
.map_err(failure_of)?;
|
||||
Ok(format!("{}({})", file.name, dest.display()))
|
||||
for node in &dependency_plan.nodes {
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(local("已取消(下载中途)"));
|
||||
}
|
||||
progress.message = node_label(node);
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
// M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核,
|
||||
// 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。
|
||||
let dest = target_dir.join(safe_file_name(&node.file.filename));
|
||||
block_on(download_verified(
|
||||
&fetcher,
|
||||
&node.file.url,
|
||||
node.file.sha1(),
|
||||
node.file.sha512(),
|
||||
(node.file.size > 0).then_some(node.file.size),
|
||||
&dest,
|
||||
))
|
||||
.map_err(failure_of)?;
|
||||
progress.files_done += 1;
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
|
||||
// 解析阶段的失败在这里收口:两档分成两句话(不合并成「下载失败」),并列出可只重试的项目。
|
||||
if let Some(failure) = plan::failure_of_plan(&dependency_plan) {
|
||||
return Err(failure);
|
||||
}
|
||||
Ok(plan::summary(&dependency_plan))
|
||||
}
|
||||
|
||||
/// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。
|
||||
fn node_label(node: &PlannedNode) -> String {
|
||||
if node.is_root() {
|
||||
node.file.filename.clone()
|
||||
} else {
|
||||
format!("{}(前置)", node.file.filename)
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。
|
||||
@@ -187,69 +261,6 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, Con
|
||||
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
|
||||
}
|
||||
|
||||
/// 要下载的那个文件(摘要两档都带着:mrpack 的必填摘要是 sha512)。
|
||||
struct RemoteFile {
|
||||
url: String,
|
||||
name: String,
|
||||
sha1: Option<String>,
|
||||
sha512: Option<String>,
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
/// 解析「这个项目下最适合目标实例的那个文件」。
|
||||
fn resolve_file(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
) -> Result<RemoteFile, ContentFailure> {
|
||||
match request.source {
|
||||
ContentSource::Modrinth => modrinth_file(env, request),
|
||||
ContentSource::Curseforge => Err(ContentFailure::unsupported(
|
||||
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本列表 → 目标文件:走 core 的在线内容源(**本文件不再拼 URL、不再自己解析 JSON**)。
|
||||
///
|
||||
/// 选 `primary: true` 的那个(Modrinth 官方口径):直接取第一个会拿到 `sources.jar` 这类坑,
|
||||
/// `Version::primary_file()` 就是为这件事存在的。
|
||||
fn modrinth_file(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
) -> Result<RemoteFile, ContentFailure> {
|
||||
let core_engine = super::super::engine::engine(env);
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
let versions = block_on(client.versions(&VersionQuery {
|
||||
project: request.project.clone(),
|
||||
game_version: Some(request.game_version.clone()),
|
||||
loader: loader_facet(request.loader).map(str::to_string),
|
||||
}))
|
||||
.map_err(failure_of)?;
|
||||
let version = versions.first().ok_or_else(|| {
|
||||
ContentFailure::new(
|
||||
ContentFailureKind::NotFound,
|
||||
"这个项目没有匹配目标实例(版本 / 加载器)的文件",
|
||||
)
|
||||
})?;
|
||||
let file = version
|
||||
.primary_file()
|
||||
.ok_or_else(|| ContentFailure::new(ContentFailureKind::NotFound, "版本里没有可用文件"))?;
|
||||
if file.url.trim().is_empty() {
|
||||
return Err(ContentFailure::new(
|
||||
ContentFailureKind::Unsupported,
|
||||
"版本里的主文件没有下载地址",
|
||||
));
|
||||
}
|
||||
Ok(RemoteFile {
|
||||
url: file.url.clone(),
|
||||
name: safe_file_name(&file.filename),
|
||||
sha1: file.sha1().map(ToString::to_string),
|
||||
sha512: file.sha512().map(ToString::to_string),
|
||||
size: (file.size > 0).then_some(file.size),
|
||||
})
|
||||
}
|
||||
|
||||
/// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉
|
||||
/// (与 `core::addon::pack::path` 的穿越双检同一条纪律)。
|
||||
pub(super) fn safe_file_name(raw: &str) -> String {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! 依赖计划的**宿主侧那一半**:扫目录、把两档失败映成 DTO 大类、拼给用户看的那句话。
|
||||
//!
|
||||
//! # 为什么判定在 core、说话在 presenter
|
||||
//!
|
||||
//! 「哪个前置不兼容、哪个前置取不到」由 `unlucky_core::addon::online::resolve` 判定并可离线
|
||||
//! 测试(录制 JSON 夹具)。本文件只做三件宿主才知道的事:
|
||||
//!
|
||||
//! | 事 | 为什么只能在这里 |
|
||||
//! |---|---|
|
||||
//! | 目标目录里有什么 | core 不读盘(`PresentFile` 由调用方扫) |
|
||||
//! | 失败大类怎么映 | DTO 在 presenter(`ContentFailureKind`) |
|
||||
//! | 文案怎么说 | 界面文案不归 core |
|
||||
//!
|
||||
//! # 两档失败**分开说**
|
||||
//!
|
||||
//! 「不兼容所选版本/加载器」与「取不到(网络 / 项目不存在)」是两句话、两种用户动作,
|
||||
//! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说
|
||||
//! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
DependencyFailure, DependencyPlan, FailureKind, OnlineError, PresentFile, ResolveRequest,
|
||||
};
|
||||
|
||||
use crate::dto::ContentFailureKind;
|
||||
|
||||
use super::sources::failure_of;
|
||||
|
||||
/// 扫目标目录(不递归)里已存在的文件名。
|
||||
///
|
||||
/// 只给名字,不算摘要:为了「跳过」把整个 `mods/` 哈希一遍不划算,同名就是同一份 jar 的
|
||||
/// 最常见形态(我们落盘用站点给的文件名,不重命名)。
|
||||
pub(super) fn present_files(dir: &Path) -> Vec<PresentFile> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|entry| entry.path().is_file())
|
||||
.map(|entry| PresentFile::new(entry.file_name().to_string_lossy()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 一次装入要用的解析请求(过滤条件与落盘目录一致)。
|
||||
pub(super) fn request(
|
||||
project: &str,
|
||||
game_version: &str,
|
||||
loader: Option<&str>,
|
||||
present: Vec<PresentFile>,
|
||||
) -> ResolveRequest {
|
||||
ResolveRequest {
|
||||
roots: vec![project.to_string()],
|
||||
game_version: Some(game_version.to_string()),
|
||||
loader: loader.map(str::to_string),
|
||||
present,
|
||||
..ResolveRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 计划里有失败吗 ⇒ 一条带大类的失败(`None` = 干净)。
|
||||
pub(super) fn failure_of_plan(plan: &DependencyPlan) -> Option<crate::dto::ContentFailure> {
|
||||
if plan.is_clean() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::dto::ContentFailure::new(
|
||||
overall_kind(plan),
|
||||
report(plan),
|
||||
))
|
||||
}
|
||||
|
||||
/// 整体大类:按「用户接下来该做什么」排序,离线 > 查无此项 > 站点错 > 其它 > 不兼容。
|
||||
fn overall_kind(plan: &DependencyPlan) -> ContentFailureKind {
|
||||
let kinds: Vec<ContentFailureKind> = plan.failures.iter().map(kind_of).collect();
|
||||
for wanted in [
|
||||
ContentFailureKind::Offline,
|
||||
ContentFailureKind::NotFound,
|
||||
ContentFailureKind::Server,
|
||||
ContentFailureKind::Other,
|
||||
ContentFailureKind::Unsupported,
|
||||
] {
|
||||
if kinds.contains(&wanted) {
|
||||
return wanted;
|
||||
}
|
||||
}
|
||||
ContentFailureKind::Unsupported
|
||||
}
|
||||
|
||||
/// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。
|
||||
fn kind_of(failure: &DependencyFailure) -> ContentFailureKind {
|
||||
match failure.cause {
|
||||
Some(cause) => failure_of(OnlineError::new(cause, failure.message.clone())).kind,
|
||||
// 不兼容那一档没有网络成因:它要用户换版本/加载器,不是「再试一次」
|
||||
None => ContentFailureKind::Unsupported,
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败清单(两档各起一段,各自的条数分开数)。
|
||||
fn report(plan: &DependencyPlan) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let incompatible: Vec<String> = plan
|
||||
.failures_of(FailureKind::Incompatible)
|
||||
.map(describe)
|
||||
.collect();
|
||||
let unreachable: Vec<String> = plan
|
||||
.failures_of(FailureKind::Unreachable)
|
||||
.map(describe)
|
||||
.collect();
|
||||
if !incompatible.is_empty() {
|
||||
lines.push(format!(
|
||||
"不兼容所选版本/加载器 {} 个:{}",
|
||||
incompatible.len(),
|
||||
incompatible.join(";")
|
||||
));
|
||||
}
|
||||
if !unreachable.is_empty() {
|
||||
lines.push(format!(
|
||||
"取不到(网络 / 项目不存在){} 个:{}",
|
||||
unreachable.len(),
|
||||
unreachable.join(";")
|
||||
));
|
||||
}
|
||||
if !plan.nodes.is_empty() {
|
||||
lines.push(format!("已装入 {} 个文件", plan.nodes.len()));
|
||||
}
|
||||
if !plan.skipped.is_empty() {
|
||||
lines.push(format!("跳过 {} 个已存在的文件", plan.skipped.len()));
|
||||
}
|
||||
if !plan.truncated.is_empty() {
|
||||
lines.push(format!(
|
||||
"依赖层数超过上限,未展开:{}",
|
||||
plan.truncated.join("、")
|
||||
));
|
||||
}
|
||||
// 可重试名单:只列失败项(重试时也只重解这些,不重下已装好的)
|
||||
let retry: Vec<&str> = plan
|
||||
.failures
|
||||
.iter()
|
||||
.map(|failure| failure.requested_as.as_str())
|
||||
.collect();
|
||||
if !retry.is_empty() {
|
||||
lines.push(format!(
|
||||
"可只重试这 {} 个:{}",
|
||||
retry.len(),
|
||||
retry.join("、")
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn describe(failure: &DependencyFailure) -> String {
|
||||
if failure.required_by.is_empty() {
|
||||
format!("{}({})", failure.requested_as, failure.message)
|
||||
} else {
|
||||
format!(
|
||||
"{}({};被 {} 要求)",
|
||||
failure.requested_as,
|
||||
failure.message,
|
||||
failure.required_by.join("、")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。
|
||||
pub(super) fn summary(plan: &DependencyPlan) -> String {
|
||||
if plan.nodes.is_empty() && plan.skipped.is_empty() {
|
||||
return "没有要装的文件".to_string();
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !plan.nodes.is_empty() {
|
||||
let dependencies = plan.nodes.iter().filter(|node| !node.is_root()).count();
|
||||
// 「为什么装」:前置标出要求它的项目(根是用户自己点的,不标)
|
||||
let names: Vec<String> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| {
|
||||
if node.is_root() {
|
||||
node.file.filename.clone()
|
||||
} else {
|
||||
format!(
|
||||
"{}(前置,被 {} 要求)",
|
||||
node.file.filename,
|
||||
node.required_by.join("、")
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let head = if dependencies == 0 {
|
||||
format!("装了 {} 个文件", plan.nodes.len())
|
||||
} else {
|
||||
format!(
|
||||
"装了 {} 个文件(含 {} 个前置)",
|
||||
plan.nodes.len(),
|
||||
dependencies
|
||||
)
|
||||
};
|
||||
parts.push(format!("{head}:{}", names.join("、")));
|
||||
}
|
||||
if !plan.skipped.is_empty() {
|
||||
let names: Vec<&str> = plan
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|node| node.file_name.as_str())
|
||||
.collect();
|
||||
parts.push(format!(
|
||||
"跳过 {} 个(目录里已存在,没有重复下载):{}",
|
||||
plan.skipped.len(),
|
||||
names.join("、")
|
||||
));
|
||||
}
|
||||
if !plan.truncated.is_empty() {
|
||||
parts.push(format!(
|
||||
"依赖层数超过上限,未展开:{}",
|
||||
plan.truncated.join("、")
|
||||
));
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
@@ -115,3 +115,190 @@ fn core_errors_map_to_dto_failure_kinds() {
|
||||
"connect timed out"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------ M186:前置解析的两档失败与可重试名单
|
||||
|
||||
use super::plan::{failure_of_plan, summary};
|
||||
use unlucky_core::addon::online::{
|
||||
DependencyFailure, DependencyPlan, FailureKind, PlannedNode, ResolveRequest, SkipReason,
|
||||
SkippedNode, VersionFile,
|
||||
};
|
||||
|
||||
fn node(project: &str, file: &str, depth: u8, required_by: &[&str]) -> PlannedNode {
|
||||
PlannedNode {
|
||||
project: project.to_string(),
|
||||
requested_as: project.to_string(),
|
||||
version_id: format!("ver-{project}"),
|
||||
version_name: project.to_string(),
|
||||
version_number: "1.0".to_string(),
|
||||
file: VersionFile {
|
||||
url: format!("https://cdn.modrinth.com/data/{project}/1.0/{file}"),
|
||||
filename: file.to_string(),
|
||||
size: 1024,
|
||||
..VersionFile::default()
|
||||
},
|
||||
depth,
|
||||
required_by: required_by
|
||||
.iter()
|
||||
.map(|parent| (*parent).to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(
|
||||
project: &str,
|
||||
kind: FailureKind,
|
||||
cause: Option<OnlineErrorKind>,
|
||||
message: &str,
|
||||
) -> DependencyFailure {
|
||||
DependencyFailure {
|
||||
requested_as: project.to_string(),
|
||||
required_by: vec!["AANobbMI".to_string()],
|
||||
depth: 1,
|
||||
kind,
|
||||
cause,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan(
|
||||
failures: Vec<DependencyFailure>,
|
||||
nodes: Vec<PlannedNode>,
|
||||
skipped: Vec<SkippedNode>,
|
||||
) -> DependencyPlan {
|
||||
DependencyPlan {
|
||||
request: ResolveRequest::single("sodium"),
|
||||
nodes,
|
||||
skipped,
|
||||
failures,
|
||||
truncated: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 全是「不兼容」时:说换版本/加载器,而不是「下载失败」,并且照样给重试名单。
|
||||
#[test]
|
||||
fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() {
|
||||
let plan = plan(
|
||||
vec![failure(
|
||||
"P7dR8mSH",
|
||||
FailureKind::Incompatible,
|
||||
None,
|
||||
"没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本",
|
||||
)],
|
||||
vec![node("AANobbMI", "sodium.jar", 0, &[])],
|
||||
Vec::new(),
|
||||
);
|
||||
let failure = failure_of_plan(&plan).expect("有失败就该给一条");
|
||||
assert_eq!(failure.kind, ContentFailureKind::Unsupported);
|
||||
assert!(failure.detail.contains("不兼容所选版本/加载器 1 个"));
|
||||
assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH"));
|
||||
assert!(!failure.detail.contains("下载失败"), "{}", failure.detail);
|
||||
assert!(
|
||||
!failure.detail.contains("取不到"),
|
||||
"两档不许混:{}",
|
||||
failure.detail
|
||||
);
|
||||
}
|
||||
|
||||
/// 两档同时出现时:两段都写,大类按「用户接下来该做什么」选(查无此项 > 不兼容)。
|
||||
#[test]
|
||||
fn a_mixed_plan_keeps_both_buckets_and_picks_the_actionable_kind() {
|
||||
let plan = plan(
|
||||
vec![
|
||||
failure(
|
||||
"P7dR8mSH",
|
||||
FailureKind::Incompatible,
|
||||
None,
|
||||
"没有匹配的版本",
|
||||
),
|
||||
failure(
|
||||
"1eAoo2KR",
|
||||
FailureKind::Unreachable,
|
||||
Some(OnlineErrorKind::NotFound),
|
||||
"取版本失败(online_not_found):HTTP 404",
|
||||
),
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
let failure = failure_of_plan(&plan).expect("有失败就该给一条");
|
||||
assert_eq!(failure.kind, ContentFailureKind::NotFound);
|
||||
assert!(failure.detail.contains("不兼容所选版本/加载器 1 个"));
|
||||
assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个"));
|
||||
assert!(failure.detail.contains("可只重试这 2 个"));
|
||||
}
|
||||
|
||||
/// 没网那一档:大类是离线(页面据此画「检查网络」而不是「换个关键词」)。
|
||||
#[test]
|
||||
fn an_offline_jump_wins_over_everything_else() {
|
||||
let plan = plan(
|
||||
vec![
|
||||
failure(
|
||||
"P7dR8mSH",
|
||||
FailureKind::Incompatible,
|
||||
None,
|
||||
"没有匹配的版本",
|
||||
),
|
||||
failure(
|
||||
"1eAoo2KR",
|
||||
FailureKind::Unreachable,
|
||||
Some(OnlineErrorKind::NotConnected),
|
||||
"连不上 api.modrinth.com",
|
||||
),
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
let failure = failure_of_plan(&plan).expect("有失败就该给一条");
|
||||
assert_eq!(failure.kind, ContentFailureKind::Offline);
|
||||
assert!(failure.is_offline());
|
||||
}
|
||||
|
||||
/// 全装成的那句话要点出「含 N 个前置」与「跳过 M 个已存在」。
|
||||
#[test]
|
||||
fn the_success_message_counts_dependencies_and_skipped_files() {
|
||||
let plan = plan(
|
||||
Vec::new(),
|
||||
vec![
|
||||
node("AANobbMI", "sodium.jar", 0, &[]),
|
||||
node("P7dR8mSH", "fabric-api.jar", 1, &["AANobbMI"]),
|
||||
],
|
||||
vec![SkippedNode {
|
||||
project: "1eAoo2KR".to_string(),
|
||||
requested_as: "1eAoo2KR".to_string(),
|
||||
file_name: "yacl.jar".to_string(),
|
||||
depth: 2,
|
||||
required_by: vec!["P7dR8mSH".to_string()],
|
||||
reason: SkipReason::AlreadyPresent,
|
||||
}],
|
||||
);
|
||||
assert!(failure_of_plan(&plan).is_none());
|
||||
let message = summary(&plan);
|
||||
assert!(
|
||||
message.contains("装了 2 个文件(含 1 个前置)"),
|
||||
"{message}"
|
||||
);
|
||||
// 「为什么装」:前置那句要带出要求它的项目
|
||||
assert!(
|
||||
message.contains("fabric-api.jar(前置,被 AANobbMI 要求)"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("跳过 1 个(目录里已存在,没有重复下载):yacl.jar"));
|
||||
}
|
||||
|
||||
/// 扫目录只认文件、不认子目录(子目录不是 jar,混进来会让去重误判)。
|
||||
#[test]
|
||||
fn present_files_lists_only_files_in_the_target_directory() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-m186-present-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("nested")).expect("建临时目录");
|
||||
std::fs::write(dir.join("sodium.jar"), b"x").expect("写文件");
|
||||
std::fs::write(dir.join("yacl.jar.disabled"), b"x").expect("写文件");
|
||||
let mut names: Vec<String> = super::plan::present_files(&dir)
|
||||
.into_iter()
|
||||
.map(|file| file.name)
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["sodium.jar", "yacl.jar.disabled"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in new issue
Block a user