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:
1 parent
a4f096eea3
commit
4defc41e8d
64 files changed
+3819
-442
No files matched your search
@@ -12,17 +12,92 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
|
||||
use unlucky_core::addon::import::{
|
||||
ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportError, ImportProgress,
|
||||
ImportReport, LoaderTarget, MissingReason,
|
||||
ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource,
|
||||
ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason,
|
||||
};
|
||||
|
||||
use crate::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
|
||||
ExportReasonView, FileActionView, ImportErrorView, ImportProgressView, ImportReportView,
|
||||
ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView,
|
||||
ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView,
|
||||
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportScanView,
|
||||
ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
||||
PackFormatView,
|
||||
};
|
||||
|
||||
/// 一次导入**预检** → DTO(M160:冲突清单)。
|
||||
pub(super) fn scan_view(scan: &ImportScan) -> ImportScanView {
|
||||
ImportScanView {
|
||||
target: scan.target.clone(),
|
||||
format: scan.format.as_deref().and_then(PackFormatView::from_slug),
|
||||
pack_name: scan.pack_name.clone(),
|
||||
pack_version: scan.pack_version.clone(),
|
||||
game_version: scan.game_version.clone(),
|
||||
conflicts: scan
|
||||
.conflicts
|
||||
.iter()
|
||||
.map(|conflict| ImportConflictView {
|
||||
path: conflict.path.clone(),
|
||||
source: source_view(conflict.source),
|
||||
reason: reason_of(conflict.reason),
|
||||
disabled: conflict.disabled,
|
||||
pack_sha1: conflict.pack_sha1.clone(),
|
||||
pack_size: conflict.pack_size,
|
||||
disk_sha1: conflict.disk_sha1.clone(),
|
||||
disk_size: conflict.disk_size,
|
||||
base_sha1: conflict.base_sha1.clone(),
|
||||
})
|
||||
.collect(),
|
||||
unknown_remote: scan.unknown_remote,
|
||||
lost_bytes: scan.lost_bytes(),
|
||||
incoming_bytes: scan.incoming_bytes(),
|
||||
notes: scan
|
||||
.notes
|
||||
.iter()
|
||||
.map(|issue| issue.message.clone())
|
||||
.collect(),
|
||||
error: scan.error.as_ref().map(error_view),
|
||||
}
|
||||
}
|
||||
|
||||
/// DTO 的决策 → core 的决策表(**形状一换,语义不换**)。
|
||||
///
|
||||
/// 三条入口一一对应;没点名的路径在 core 侧落到"保留"(`ConflictDecisions` 的缺省档),
|
||||
/// 所以"页面少传一条"永远不会变成"覆盖"。
|
||||
pub(super) fn decisions_of(view: &ConflictDecisionsView) -> ConflictDecisions {
|
||||
let mut decisions = match view.default_choice() {
|
||||
ConflictChoiceView::KeepUser => ConflictDecisions::keep_all(),
|
||||
ConflictChoiceView::Overwrite => ConflictDecisions::overwrite_all(),
|
||||
};
|
||||
for item in view.items() {
|
||||
decisions.set(item.path.clone(), choice_of(item.choice));
|
||||
}
|
||||
decisions
|
||||
}
|
||||
|
||||
const fn choice_of(choice: ConflictChoiceView) -> ConflictChoice {
|
||||
match choice {
|
||||
ConflictChoiceView::KeepUser => ConflictChoice::KeepUser,
|
||||
ConflictChoiceView::Overwrite => ConflictChoice::Overwrite,
|
||||
}
|
||||
}
|
||||
|
||||
const fn source_view(source: FileSource) -> ConflictSourceView {
|
||||
match source {
|
||||
FileSource::Overrides => ConflictSourceView::Overrides,
|
||||
FileSource::ClientOverrides => ConflictSourceView::ClientOverrides,
|
||||
FileSource::CurseForge => ConflictSourceView::CurseForge,
|
||||
FileSource::Modrinth => ConflictSourceView::Modrinth,
|
||||
}
|
||||
}
|
||||
|
||||
const fn reason_of(reason: ConflictReason) -> ConflictReasonView {
|
||||
match reason {
|
||||
ConflictReason::UserModified => ConflictReasonView::UserModified,
|
||||
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
|
||||
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导入结果 → DTO。
|
||||
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
|
||||
ImportReportView {
|
||||
@@ -114,15 +189,8 @@ fn error_view(error: &ImportError) -> ImportErrorView {
|
||||
fn conflict_view(file: &ConflictFile) -> ConflictFileView {
|
||||
ConflictFileView {
|
||||
path: file.path.clone(),
|
||||
reason: match file.reason {
|
||||
ConflictReason::UserModified => ConflictReasonView::UserModified,
|
||||
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
|
||||
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
|
||||
},
|
||||
choice: match file.choice {
|
||||
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
|
||||
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
|
||||
},
|
||||
reason: reason_of(file.reason),
|
||||
choice: choice_view(file.choice),
|
||||
pack_sha1: file.pack_sha1.clone(),
|
||||
base_sha1: file.base_sha1.clone(),
|
||||
disk_sha1: file.disk_sha1.clone(),
|
||||
@@ -130,6 +198,14 @@ fn conflict_view(file: &ConflictFile) -> ConflictFileView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条 `ConflictChoice` → DTO(预检与事后账共用一张翻译表)。
|
||||
const fn choice_view(choice: ConflictChoice) -> ConflictChoiceView {
|
||||
match choice {
|
||||
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
|
||||
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
|
||||
}
|
||||
}
|
||||
|
||||
const fn action_view(action: FileAction) -> FileActionView {
|
||||
match action {
|
||||
FileAction::Installed => FileActionView::Installed,
|
||||
|
||||
Reference in new issue
Block a user