feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一

主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
  逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
  缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
  全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
  spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
  预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。

限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
  (20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
  不等式在 pack/tests/limits.rs 里用 const 断言钉死。

测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
  under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
  multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。

测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
  以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。

证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
This commit is contained in:
worker-modpackpick authored and AskaEth committed 2026-11-07 13:52:43 +08:00
1 parent a4f096eea3
commit 4defc41e8d
64 files changed
+3819 -442

No files matched your search

+58 -6
View File
@@ -6,6 +6,21 @@
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md`(施工口径)。
//! 格式模型与安全闸是 M110 的 [`crate::addon::pack`]:本模块**只消费,不重写**。
//!
//! # 逐文件冲突决策(M160):预检 → 用户点头 → 导入
//!
//! | 步骤 | 入口 | 写盘吗 |
//! |------|------|--------|
//! | 预检:算出"哪几个文件会盖掉用户的" | [`scan_pack`] | **不写**(只读盘算摘要) |
//! | 用户点「全部保留 / 全部覆盖 / 逐项」 | [`ConflictDecisions`] | — |
//! | 导入 | [`import_pack`] | 写(按决策处置冲突) |
//!
//! 三条硬口径(改它们之前先看 [`scan`] 与 [`decision`] 的模块文档):
//!
//! 1. 进冲突清单的**只有**"目标里已存在同名普通文件且内容(SHA-1)不同"的路径;内容相同、
//! 目标不存在、以及用户删过的文件都不打扰用户;
//! 2. 决策按**路径**给,逐项优先于整批档,整批档缺省 = 保留;
//! 3. 缺省**绝不覆盖**:没收到决策的冲突一律保留用户那一侧([`ConflictChoice::default`])。
//!
//! # 三条硬要求落在哪
//!
//! | 要求 | 落点 |
@@ -63,6 +78,7 @@
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
pub mod baseline;
pub mod decision;
pub mod error;
pub mod fetch;
pub mod merge;
@@ -70,9 +86,11 @@ pub mod phase;
pub mod pipeline;
pub mod plan;
pub mod report;
pub mod scan;
pub mod stage;
pub use baseline::{PackBaseline, BASELINE_FILE};
pub use decision::ConflictDecisions;
pub use error::{ImportError, ImportErrorKind};
pub use fetch::{
CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest,
@@ -86,6 +104,7 @@ pub use report::{
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
};
pub use scan::{scan_pack, ImportConflict, ImportScan};
pub use stage::{RollbackOutcome, Staging};
use std::path::Path;
@@ -93,7 +112,15 @@ use std::path::Path;
use crate::addon::pack::PackLimits;
use crate::platform::layout::LauncherLayout;
/// 冲突处置策略(默认保留用户文件)。
/// 整次导入的**缺省**冲突档(逐项决策见 [`ConflictDecisions`])。
///
/// 保留这个名字是因为 CLI 已经按它调用(`pack import --overwrite`);语义上它就是
/// [`ConflictDecisions::default_choice`] 的构造器:
///
/// - [`ConflictPolicy::KeepUser`](缺省)= 逐项决策的缺省档是"保留";
/// - [`ConflictPolicy::Overwrite`] = 逐项决策的缺省档是"覆盖"。
///
/// **逐项决策优先于它**,所以"整批覆盖 + 某几条保留"是可表达的。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConflictPolicy {
/// 保留用户那一侧(不覆盖、不补回被删的文件),冲突逐条进报告。
@@ -103,6 +130,17 @@ pub enum ConflictPolicy {
Overwrite,
}
impl ConflictPolicy {
/// 对应的逐项决策缺省档。
#[must_use]
const fn choice(self) -> ConflictChoice {
match self {
Self::KeepUser => ConflictChoice::KeepUser,
Self::Overwrite => ConflictChoice::Overwrite,
}
}
}
/// 一次导入请求。
pub struct ImportRequest<'a> {
/// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。
@@ -115,8 +153,8 @@ pub struct ImportRequest<'a> {
pub register: Option<&'a LauncherLayout>,
/// 实例展示名(缺省用包名)。
pub instance_name: Option<&'a str>,
/// 冲突策略。
pub conflict_policy: ConflictPolicy,
/// 逐文件冲突决策(含整批缺省档,**缺省 = 全部保留**)。
pub decisions: ConflictDecisions,
/// 包读取限额(条数 / 单条体积 / 解压总量)。
pub limits: PackLimits,
/// 进度回调。
@@ -133,7 +171,7 @@ impl<'a> ImportRequest<'a> {
fetcher: None,
register: None,
instance_name: None,
conflict_policy: ConflictPolicy::KeepUser,
decisions: ConflictDecisions::default(),
limits: PackLimits::default(),
progress: None,
}
@@ -154,10 +192,24 @@ impl<'a> ImportRequest<'a> {
self
}
/// 改冲突策略。
/// 整批缺省档(逐项决策仍然优先)。
#[must_use]
pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
self.conflict_policy = policy;
self.decisions = std::mem::take(&mut self.decisions).with_default(policy.choice());
self
}
/// 整表逐文件决策(「全部保留 / 全部覆盖 / 逐项」三条入口的产物)。
#[must_use]
pub fn with_decisions(mut self, decisions: ConflictDecisions) -> Self {
self.decisions = decisions;
self
}
/// 挂一条逐项决策(链式,便于测试与命令行逐条给)。
#[must_use]
pub fn with_decision(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
self.decisions.set(path, choice);
self
}