diff --git a/crates/cli/src/commands/pack.rs b/crates/cli/src/commands/pack.rs index 001aa8e..b762652 100644 --- a/crates/cli/src/commands/pack.rs +++ b/crates/cli/src/commands/pack.rs @@ -126,7 +126,7 @@ fn outcome( } human.push(format!( "结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}", - status_label(report.status), + status_label(report), count_action(report, FileAction::Installed), count_action(report, FileAction::Replaced), count_action(report, FileAction::SkippedIdentical), @@ -207,11 +207,13 @@ fn outcome( Outcome::new(Report { data, human }).with_exit_code(report.status.exit_code()) } -fn status_label(status: ImportStatus) -> &'static str { - match status { +/// 状态的人读名。**失败要按回滚是否干净分叉**:不完整时不能说"已回滚"。 +fn status_label(report: &ImportReport) -> &'static str { + match report.status { ImportStatus::Success => "成功", ImportStatus::Partial => "部分成功", - ImportStatus::Failed => "失败(已回滚)", + ImportStatus::Failed if report.rolled_back => "失败(已回滚)", + ImportStatus::Failed => "失败(回滚不完整)", } } diff --git a/crates/core/src/addon/import/pipeline.rs b/crates/core/src/addon/import/pipeline.rs index 8aad4a3..22c2d04 100644 --- a/crates/core/src/addon/import/pipeline.rs +++ b/crates/core/src/addon/import/pipeline.rs @@ -17,7 +17,7 @@ //! | 某条远端文件摸不到(离线 / CF 无客户端 / 404) | `Partial`,逐条进 `missing`,实例照装 | //! | 下载到的内容与声明摘要不符 | `Failed`(这是**损坏或被换过**,不是"暂时搞不到") | //! | 本地写盘失败 | `Failed` | -//! | 实例登记失败 | `Failed`(连内容一起回滚,不留"有目录没档案"的半个实例) | +//! | 实例登记失败 | `Failed`(连内容一起回滚,不留"有目录没档案"的半个实例;回滚不完整时如实列出) | //! //! **回滚也要如实**:回滚本身可能失败(文件被占用、权限),这时 `rolled_back` 为 `false` //! 并在 `rollback.failed` 里逐项列出——不许在"可能没回干净"时宣称一致。 @@ -45,14 +45,14 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { tell(&request.progress, ImportStage::Probe, 0, 0, None); let (archive, plan) = match probe_and_plan(request) { Ok(pair) => pair, - Err(error) => return failed(report, error), + Err(error) => return abandoned(report, error), }; account_plan(&mut report, &plan); let baseline = load_baseline(request.target, &plan, &mut report); let staging = match Staging::begin(request.target) { Ok(staging) => staging, - Err(error) => return failed(report, error), + Err(error) => return abandoned(report, error), }; let mut next = baseline.clone(); @@ -73,7 +73,7 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { .await; if let Err(error) = staged { staging.abort(); - return failed(report, error); + return abandoned(report, error); } // 引擎的续传残片(`.ullpart-*`)与 meta 不是整合包内容,提交前剔掉。 @@ -85,18 +85,14 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { )), Err(error) => { staging.abort(); - return failed(report, error); + return abandoned(report, error); } } tell(&request.progress, ImportStage::Commit, 0, 0, None); let guard = match staging.commit() { Ok(guard) => guard, - Err(failure) => { - let mut report = failed(report, failure.error); - record_rollback(&mut report, failure.rollback); - return report; - } + Err(failure) => return record_rollback(failed(report, failure.error), failure.rollback), }; if let Some(layout) = request.register { @@ -124,9 +120,10 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { Err(error) => { // 内容落盘了但档案没建起来 = 用户看不见的半个实例 ⇒ 连内容一起回滚。 let outcome = guard.rollback(); - let mut report = failed(report, ImportError::instance(error.to_string())); - record_rollback(&mut report, outcome); - return report; + return record_rollback( + failed(report, ImportError::instance(error.to_string())), + outcome, + ); } } } @@ -198,9 +195,19 @@ fn load_baseline( } } -/// 把一次回滚的结果写进报告:不完整时**不许**继续宣称"目标目录与导入前一致"。 -fn record_rollback(report: &mut ImportReport, outcome: RollbackOutcome) { - if !outcome.is_complete() { +/// 把一次回滚的结果写进报告:**文案与机器位同源**,只有完整回滚才宣称"与导入前一致"。 +fn record_rollback(mut report: ImportReport, outcome: RollbackOutcome) -> ImportReport { + if outcome.is_complete() { + report.rolled_back = true; + report.warnings.push(ImportIssue::new( + keys::ROLLBACK_DONE, + format!( + "已回滚 {} 项:目标目录恢复为导入前的样子({})", + outcome.attempted, + report.target.display() + ), + )); + } else { report.rolled_back = false; report.warnings.push(ImportIssue::new( keys::NOTE_ROLLBACK_INCOMPLETE, @@ -212,6 +219,7 @@ fn record_rollback(report: &mut ImportReport, outcome: RollbackOutcome) { )); } report.rollback = Some(outcome); + report } /// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。 @@ -255,16 +263,127 @@ pub(super) fn tell( } } -/// 失败收尾:走到这里的失败都**没有写过目标目录**(写盘在 `staging` 里),因此可以宣称一致。 +/// 失败收尾:**只落错误,不宣称目标目录状态**。 +/// +/// 口径由调用方补:从未写过目标目录 ⇒ [`abandoned`];已经写过 ⇒ [`record_rollback`]。 +/// 早先这里无条件推"目标目录保持导入前的样子",而随后的 `record_rollback` 可能再推 +/// "有 N 项未能回滚"——同一份 warnings 自相矛盾(复审 r6 抓到)。 fn failed(mut report: ImportReport, error: ImportError) -> ImportReport { - report.warnings.push(ImportIssue::new( - keys::ROLLBACK_DONE, - format!( - "导入失败,已清理临时目录;目标目录保持导入前的样子({})", - report.target.display() - ), - )); - report.rolled_back = true; + report.rolled_back = false; report.fail(error); report } + +/// 失败发生在**碰目标目录之前**(暂存目录已清):这时才可以宣称"目标目录未被写入"。 +fn abandoned(report: ImportReport, error: ImportError) -> ImportReport { + let mut report = failed(report, error); + report.rolled_back = true; + report.warnings.push(ImportIssue::new( + keys::ROLLBACK_DONE, + format!( + "导入失败,已清理临时目录;目标目录未被写入({})", + report.target.display() + ), + )); + report +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::addon::import::ImportStatus; + + fn base() -> ImportReport { + ImportReport::empty(std::path::PathBuf::from("instance")) + } + + fn messages(report: &ImportReport) -> Vec<&str> { + report + .warnings + .iter() + .map(|note| note.message.as_str()) + .collect() + } + + fn incomplete() -> RollbackOutcome { + RollbackOutcome { + attempted: 3, + failed: vec!["删除 config/a.toml 失败:拒绝访问".to_string()], + } + } + + #[test] + fn incomplete_rollback_never_claims_the_target_is_untouched() { + // 复审 r6 第 1 条:机器位(rolled_back=false)诚实,文案位也必须诚实——同一份 warnings + // 里不许同时出现"保持导入前的样子"与"有 N 项未能回滚"。 + let report = record_rollback(failed(base(), ImportError::fetch("提交失败")), incomplete()); + + assert_eq!(report.status, ImportStatus::Failed); + assert!(!report.rolled_back, "回滚不完整时不许宣称一致"); + assert_eq!( + report.rollback.as_ref().map(RollbackOutcome::failure_count), + Some(1) + ); + let messages = messages(&report); + assert!( + messages + .iter() + .any(|line| line.contains("未能回滚") && line.contains("config/a.toml")), + "失败项要逐条列出:{messages:?}" + ); + assert!( + !messages.iter().any(|line| line.contains("保持导入前的样子") + || line.contains("未被写入") + || line.contains("恢复为导入前的样子")), + "回滚不完整却宣称一致(自相矛盾):{messages:?}" + ); + } + + #[test] + fn complete_rollback_claims_consistency_exactly_once() { + let report = record_rollback( + failed(base(), ImportError::fetch("提交失败")), + RollbackOutcome { + attempted: 2, + failed: Vec::new(), + }, + ); + + assert!(report.rolled_back); + assert_eq!( + report.rollback.as_ref().map(RollbackOutcome::failure_count), + Some(0) + ); + let messages = messages(&report); + assert!( + messages + .iter() + .any(|line| line.contains("恢复为导入前的样子")), + "{messages:?}" + ); + assert!( + !messages.iter().any(|line| line.contains("未能回滚")), + "{messages:?}" + ); + } + + #[test] + fn pre_commit_failure_claims_untouched_only_via_abandoned() { + // `failed()` 本身不宣称任何目标目录状态;"从未写过目标目录"这条口径只由 `abandoned()` 给。 + let bare = failed(base(), ImportError::fetch("开包失败")); + assert!(!bare.rolled_back); + assert!(bare.rollback.is_none()); + assert!(!messages(&bare) + .iter() + .any(|line| line.contains("保持导入前的样子") || line.contains("未被写入"))); + + let report = abandoned(base(), ImportError::fetch("开包失败")); + assert!(report.rolled_back); + assert!(report.rollback.is_none()); + let messages = messages(&report); + assert!( + messages.iter().any(|line| line.contains("未被写入")), + "{messages:?}" + ); + } +} diff --git a/crates/core/src/addon/import/report/keys.rs b/crates/core/src/addon/import/report/keys.rs index d565ee8..e373a37 100644 --- a/crates/core/src/addon/import/report/keys.rs +++ b/crates/core/src/addon/import/report/keys.rs @@ -22,7 +22,7 @@ pub const STAGE_REGISTER: &str = "pack.import.stage.register"; pub const STATUS_SUCCESS: &str = "pack.import.status.success"; /// 状态:部分成功。 pub const STATUS_PARTIAL: &str = "pack.import.status.partial"; -/// 状态:失败(已回滚)。 +/// 状态:失败(是否回滚干净看 `ImportReport::rolled_back`)。 pub const STATUS_FAILED: &str = "pack.import.status.failed"; /// 注记:`server-overrides/` 客户端不解压。