fix(addon): 复审 r1 —— 失败去重、null 载荷、完成态进度、顺序守门、下载失败归并

- 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 另一个版本在目录里仍要下载」的负向用例。
This commit is contained in:
AskaEth committed 2026-11-08 20:23:56 +08:00
1 parent 011d525e8d
commit aa33210bb6
12 files changed
+686 -77

No files matched your search

+48 -5
View File
@@ -220,6 +220,20 @@ pub struct DependencyFailure {
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 {
@@ -233,6 +247,8 @@ pub struct DependencyPlan {
pub failures: Vec<DependencyFailure>,
/// 撞到 `max_depth` 而没展开的项目键(排序去重)
pub truncated: Vec<String>,
/// 有 `required` 但没有项目 id、解析不了的前置(排序去重)
pub unresolved_required: Vec<UnresolvedRequired>,
}
impl DependencyPlan {
@@ -245,6 +261,7 @@ impl DependencyPlan {
skipped: Vec::new(),
failures: Vec::new(),
truncated: Vec::new(),
unresolved_required: Vec::new(),
}
}
@@ -259,19 +276,45 @@ impl DependencyPlan {
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
.failures
.iter()
.map(|failure| failure.requested_as.clone())
.collect(),
roots,
..self.request.clone()
}
}