1use std::fs::{self, File, OpenOptions};
10use std::io::{self, Write};
11use std::path::{Path, PathBuf};
12
13use crate::backup::{new_op_id, BackupStore};
14use crate::hashline::apply::{
15 apply_section_ops, commit_registers_if_complete, FileClassification, FileResult, MutationState,
16 PlannedFile, RegisterStore, SectionPlanInput, StagedRegisters,
17};
18use crate::hashline::scan::Snapshot;
19use crate::hashline::snapshot::{
20 invalidate_removed_source, publish_edit_response_snapshot, AffectedRegion,
21 EditResponseSnapshot, SnapshotStore,
22};
23use crate::hashline::syntax::{
24 Baseline, HashlineRejection, MvOperation, Operation, ResolvedOperation,
25};
26
27#[derive(Clone, Debug)]
29pub struct MvDestinationInput<'a> {
30 pub canonical_path: &'a Path,
31 pub requested_path: &'a str,
32 pub baseline_bytes: Option<&'a [u8]>,
34}
35
36#[derive(Clone, Debug)]
38pub struct TransactionSectionInput<'a> {
39 pub canonical_path: &'a Path,
40 pub requested_path: &'a str,
41 pub baseline: &'a Baseline,
42 pub snapshot: &'a Snapshot,
43 pub operations: &'a [Operation],
44 pub resolved: &'a [ResolvedOperation],
45 pub mv_destination: Option<MvDestinationInput<'a>>,
46}
47
48#[derive(Clone, Debug)]
50pub struct TransactionPlan {
51 pub steps: Vec<PlannedStep>,
52 pub staged_registers: StagedRegisters,
53}
54
55#[derive(Clone, Debug)]
57pub enum PlannedStep {
58 Mutate(PlannedFile),
60 Mv(PlannedMv),
62}
63
64#[derive(Clone, Debug)]
67pub struct PlannedMv {
68 pub source_canonical: PathBuf,
69 pub source_requested: String,
70 pub source_baseline_bytes: Vec<u8>,
71 pub dest_canonical: PathBuf,
72 pub dest_requested: String,
73 pub dest_existed: bool,
74 pub dest_baseline_bytes: Option<Vec<u8>>,
75 pub final_bytes: Vec<u8>,
76 pub affected: AffectedRegion,
77 pub warnings: Vec<String>,
78 pub repair_layers: Vec<&'static str>,
79}
80
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum FileRole {
84 Primary,
85 MvDestination,
86 MvSource,
87}
88
89impl FileRole {
90 pub const fn as_str(self) -> &'static str {
91 match self {
92 Self::Primary => "primary",
93 Self::MvDestination => "mv_destination",
94 Self::MvSource => "mv_source",
95 }
96 }
97}
98
99#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct FileOutcome {
102 pub canonical_path: PathBuf,
103 pub requested_path: String,
104 pub role: FileRole,
105 pub classification: FileClassification,
106 pub mutation_state: MutationState,
107 pub final_bytes: Option<Vec<u8>>,
108 pub final_tag: Option<String>,
109 pub affected: AffectedRegion,
110 pub warnings: Vec<String>,
111 pub format_skipped_reason: Option<String>,
112 pub backup_id: Option<String>,
113 pub remove_file: bool,
114 pub tag_notice: Option<String>,
115}
116
117#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct TransactionEnvelope {
120 pub success: bool,
121 pub complete: bool,
122 pub files: Vec<FileOutcome>,
123 pub op_id: Option<String>,
125 pub stop_reason: Option<&'static str>,
126 pub registers_committed: bool,
127 pub preview: bool,
128 pub summary_text: String,
130}
131
132impl TransactionEnvelope {
133 pub fn to_apply_envelope(&self) -> crate::hashline::apply::ApplyResultEnvelope {
135 crate::hashline::apply::ApplyResultEnvelope {
136 success: self.success,
137 complete: self.complete,
138 files: self
139 .files
140 .iter()
141 .filter(|file| file.role != FileRole::MvSource || file.remove_file)
142 .map(|file| FileResult {
143 canonical_path: file.canonical_path.clone(),
144 requested_path: file.requested_path.clone(),
145 classification: file.classification,
146 mutation_state: file.mutation_state,
147 final_bytes: file.final_bytes.clone(),
148 affected: file.affected.clone(),
149 warnings: file.warnings.clone(),
150 remove_file: file.remove_file,
151 })
152 .collect(),
153 registers_committed: self.registers_committed,
154 }
155 }
156}
157
158#[derive(Clone, Debug, Eq, PartialEq)]
160pub enum ExecuteFault {
161 BaselineDrift { step: usize },
162 Backup { step: usize },
163 Write { step: usize },
164 Durability { step: usize },
165 SourceUnlink { step: usize },
166 FinalTagUnavailable { step: usize },
167 ValidationFailure { step: usize },
168}
169
170pub struct ExecuteContext<'a> {
172 pub session: &'a str,
173 pub backups: &'a mut BackupStore,
174 pub snapshots: &'a mut SnapshotStore,
175 pub registers: &'a mut RegisterStore,
176 pub backups_enabled: bool,
179 pub fault: Option<ExecuteFault>,
180}
181
182pub fn plan_transaction(
189 sections: &[TransactionSectionInput<'_>],
190 session_registers: &RegisterStore,
191 backups_enabled: bool,
192) -> Result<TransactionPlan, HashlineRejection> {
193 let mut staged = session_registers.stage();
194 let mut steps = Vec::with_capacity(sections.len());
195
196 for section in sections {
197 let (line_ops, line_resolved, mv_op) = split_mv(section.operations, section.resolved)?;
198 if mv_op.is_some() && section.mv_destination.is_none() {
199 return Err(HashlineRejection::parse(
200 "MV section is missing resolved destination coordinates",
201 ));
202 }
203 if mv_op.is_none() && section.mv_destination.is_some() {
204 return Err(HashlineRejection::parse(
205 "destination coordinates supplied without an MV operation",
206 ));
207 }
208
209 let planned_source = if line_ops.is_empty() {
210 PlannedFile {
212 canonical_path: section.canonical_path.to_path_buf(),
213 requested_path: section.requested_path.to_string(),
214 baseline_bytes: section.baseline.bytes.clone(),
215 final_bytes: section.baseline.bytes.clone(),
216 affected: AffectedRegion::default(),
217 remove_file: false,
218 warnings: Vec::new(),
219 repair_layers: Vec::new(),
220 }
221 } else {
222 let input = SectionPlanInput {
226 canonical_path: section.canonical_path,
227 requested_path: section.requested_path,
228 baseline: section.baseline,
229 snapshot: section.snapshot,
230 operations: line_ops,
231 resolved: line_resolved,
232 };
233 let plan = plan_line_section(&input, &mut staged)?;
234 plan
235 };
236
237 let step = if let Some(dest) = section.mv_destination.as_ref() {
238 let dest_existed = dest.baseline_bytes.is_some();
239 PlannedStep::Mv(PlannedMv {
240 source_canonical: planned_source.canonical_path,
241 source_requested: planned_source.requested_path,
242 source_baseline_bytes: planned_source.baseline_bytes,
243 dest_canonical: dest.canonical_path.to_path_buf(),
244 dest_requested: dest.requested_path.to_string(),
245 dest_existed,
246 dest_baseline_bytes: dest.baseline_bytes.map(|bytes| bytes.to_vec()),
247 final_bytes: planned_source.final_bytes,
248 affected: planned_source.affected,
249 warnings: planned_source.warnings,
250 repair_layers: planned_source.repair_layers,
251 })
252 } else {
253 PlannedStep::Mutate(planned_source)
254 };
255
256 assert_rollback_available(backups_enabled, &step)?;
257 steps.push(step);
258 }
259
260 if steps.is_empty() {
261 return Err(HashlineRejection::parse(
262 "transaction plan requires at least one section",
263 ));
264 }
265
266 Ok(TransactionPlan {
267 steps,
268 staged_registers: staged,
269 })
270}
271
272pub fn preview_transaction(plan: TransactionPlan) -> TransactionEnvelope {
276 let files = plan
277 .steps
278 .iter()
279 .flat_map(preview_step_files)
280 .collect::<Vec<_>>();
281 let summary_text = summary_counts(&files);
282 RegisterStore::discard(plan.staged_registers);
284 TransactionEnvelope {
285 success: true,
286 complete: true,
287 files,
288 op_id: None,
289 stop_reason: None,
290 registers_committed: false,
291 preview: true,
292 summary_text,
293 }
294}
295
296pub fn execute_transaction(
302 plan: TransactionPlan,
303 ctx: &mut ExecuteContext<'_>,
304) -> TransactionEnvelope {
305 let TransactionPlan {
306 steps,
307 staged_registers,
308 } = plan;
309
310 let op_id = new_op_id();
311 let mut journaled = false;
312 let mut files = Vec::new();
313 let mut stopped = false;
314 let mut stop_reason: Option<&'static str> = None;
315
316 for (step_index, step) in steps.into_iter().enumerate() {
317 if stopped {
318 files.extend(not_attempted_for_step(&step));
319 continue;
320 }
321
322 match execute_step(step_index, step, &op_id, &mut journaled, ctx) {
323 StepExec::Applied(mut outcomes) => files.append(&mut outcomes),
324 StepExec::Stopped {
325 mut outcomes,
326 reason,
327 } => {
328 files.append(&mut outcomes);
329 stopped = true;
330 stop_reason = Some(reason);
331 }
332 }
333 }
334
335 let classifications: Vec<FileClassification> = files
338 .iter()
339 .filter(|file| counts_toward_completion(file))
340 .map(|file| file.classification)
341 .collect();
342 let registers_committed =
343 commit_registers_if_complete(ctx.registers, staged_registers, &classifications);
344
345 let applied = classifications
346 .iter()
347 .filter(|classification| classification.is_applied_star())
348 .count();
349 let planned_primary = classifications.len();
350 let success = applied > 0;
351 let complete = applied == planned_primary && planned_primary > 0;
352 let summary_text = summary_counts(&files);
353
354 TransactionEnvelope {
355 success,
356 complete,
357 files,
358 op_id: journaled.then_some(op_id),
359 stop_reason,
360 registers_committed,
361 preview: false,
362 summary_text,
363 }
364}
365
366pub fn run_transaction(
368 sections: &[TransactionSectionInput<'_>],
369 session_registers: &RegisterStore,
370 ctx: &mut ExecuteContext<'_>,
371 preview: bool,
372) -> Result<TransactionEnvelope, HashlineRejection> {
373 let plan = plan_transaction(sections, session_registers, ctx.backups_enabled)?;
374 if preview {
375 Ok(preview_transaction(plan))
376 } else {
377 Ok(execute_transaction(plan, ctx))
378 }
379}
380
381fn split_mv<'a>(
384 operations: &'a [Operation],
385 resolved: &'a [ResolvedOperation],
386) -> Result<
387 (
388 &'a [Operation],
389 &'a [ResolvedOperation],
390 Option<&'a MvOperation>,
391 ),
392 HashlineRejection,
393> {
394 if operations.len() != resolved.len() {
395 return Err(HashlineRejection::parse(
396 "resolved operation count does not match the parsed section",
397 ));
398 }
399 if let Some(Operation::Mv(mv)) = operations.last() {
400 let line_len = operations.len() - 1;
401 if operations[..line_len]
402 .iter()
403 .any(|operation| matches!(operation, Operation::Mv(_)))
404 {
405 return Err(HashlineRejection::parse(
406 "MV must occur once and after all line operations",
407 ));
408 }
409 return Ok((&operations[..line_len], &resolved[..line_len], Some(mv)));
410 }
411 if operations
412 .iter()
413 .any(|operation| matches!(operation, Operation::Mv(_)))
414 {
415 return Err(HashlineRejection::parse(
416 "MV must occur once and after all line operations",
417 ));
418 }
419 Ok((operations, resolved, None))
420}
421
422fn plan_line_section(
423 section: &SectionPlanInput<'_>,
424 staged: &mut StagedRegisters,
425) -> Result<PlannedFile, HashlineRejection> {
426 for resolved in section.resolved {
428 match crate::hashline::syntax::verify_exact(
429 section.snapshot,
430 section.baseline,
431 resolved.address,
432 ) {
433 crate::hashline::syntax::VerificationOutcome::Exact => {}
434 crate::hashline::syntax::VerificationOutcome::RecoveryRequired(_) => {
435 return Err(HashlineRejection::new(
436 crate::hashline::syntax::HashlineRejectionCode::StaleTag,
437 crate::hashline::syntax::RejectionStage::Recovery,
438 "addressed content no longer matches the Phase-1 baseline",
439 ));
440 }
441 crate::hashline::syntax::VerificationOutcome::Rejected(rejection) => {
442 return Err(rejection)
443 }
444 crate::hashline::syntax::VerificationOutcome::BlockNeedsResolution { .. } => {
445 return Err(HashlineRejection::new(
446 crate::hashline::syntax::HashlineRejectionCode::BoundaryIneligible,
447 crate::hashline::syntax::RejectionStage::Eligibility,
448 "block address was not expanded before transaction planning",
449 ));
450 }
451 }
452 }
453 apply_section_ops(
454 section.requested_path,
455 section.canonical_path,
456 section.baseline,
457 section.operations,
458 section.resolved,
459 staged,
460 )
461}
462
463fn assert_rollback_available(
464 backups_enabled: bool,
465 step: &PlannedStep,
466) -> Result<(), HashlineRejection> {
467 if backups_enabled {
468 return Ok(());
469 }
470 match step {
471 PlannedStep::Mutate(_) => Err(HashlineRejection::backup_unavailable(
472 "backups are disabled; refusing destructive hashline mutation without a restore record",
473 )),
474 PlannedStep::Mv(mv) if mv.dest_existed => Err(HashlineRejection::backup_unavailable(
475 "backups are disabled; refusing MV onto an existing destination without a restore record",
476 )),
477 PlannedStep::Mv(_) => Ok(()),
479 }
480}
481
482fn preview_step_files(step: &PlannedStep) -> Vec<FileOutcome> {
483 match step {
484 PlannedStep::Mutate(file) => {
485 vec![FileOutcome {
486 canonical_path: file.canonical_path.clone(),
487 requested_path: file.requested_path.clone(),
488 role: FileRole::Primary,
489 classification: FileClassification::Applied,
490 mutation_state: MutationState::Unmutated,
491 final_bytes: Some(file.final_bytes.clone()),
492 final_tag: None,
493 affected: file.affected.clone(),
494 warnings: file.warnings.clone(),
495 format_skipped_reason: None,
496 backup_id: None,
497 remove_file: file.remove_file,
498 tag_notice: Some("preview: no final tag or undo identity".into()),
499 }]
500 }
501 PlannedStep::Mv(mv) => vec![
502 FileOutcome {
503 canonical_path: mv.dest_canonical.clone(),
504 requested_path: mv.dest_requested.clone(),
505 role: FileRole::MvDestination,
506 classification: FileClassification::Applied,
507 mutation_state: MutationState::Unmutated,
508 final_bytes: Some(mv.final_bytes.clone()),
509 final_tag: None,
510 affected: mv.affected.clone(),
511 warnings: mv.warnings.clone(),
512 format_skipped_reason: None,
513 backup_id: None,
514 remove_file: false,
515 tag_notice: Some("preview: no final tag or undo identity".into()),
516 },
517 FileOutcome {
518 canonical_path: mv.source_canonical.clone(),
519 requested_path: mv.source_requested.clone(),
520 role: FileRole::MvSource,
521 classification: FileClassification::Applied,
522 mutation_state: MutationState::Unmutated,
523 final_bytes: None,
524 final_tag: None,
525 affected: AffectedRegion::default(),
526 warnings: Vec::new(),
527 format_skipped_reason: None,
528 backup_id: None,
529 remove_file: true,
530 tag_notice: Some("preview: source removal not performed".into()),
531 },
532 ],
533 }
534}
535
536enum StepExec {
539 Applied(Vec<FileOutcome>),
540 Stopped {
541 outcomes: Vec<FileOutcome>,
542 reason: &'static str,
543 },
544}
545
546fn execute_step(
547 step_index: usize,
548 step: PlannedStep,
549 op_id: &str,
550 journaled: &mut bool,
551 ctx: &mut ExecuteContext<'_>,
552) -> StepExec {
553 match step {
554 PlannedStep::Mutate(file) => execute_mutate(step_index, file, op_id, journaled, ctx),
555 PlannedStep::Mv(mv) => execute_mv(step_index, mv, op_id, journaled, ctx),
556 }
557}
558
559fn execute_mutate(
560 step_index: usize,
561 file: PlannedFile,
562 op_id: &str,
563 journaled: &mut bool,
564 ctx: &mut ExecuteContext<'_>,
565) -> StepExec {
566 if fault_is(ctx, ExecuteFault::Backup { step: step_index }) {
567 return StepExec::Stopped {
568 outcomes: vec![failed_outcome(
569 &file.canonical_path,
570 &file.requested_path,
571 FileRole::Primary,
572 FileClassification::FailedBackup,
573 file.remove_file,
574 file.warnings.clone(),
575 )],
576 reason: "failed_backup",
577 };
578 }
579
580 let backup_id = match journal_existing_or_skip(
582 ctx,
583 op_id,
584 &file.canonical_path,
585 file.remove_file,
586 "hashline: pre-mutation backup",
587 ) {
588 Ok(id) => {
589 if id.is_some() {
590 *journaled = true;
591 }
592 id
593 }
594 Err(_) => {
595 return StepExec::Stopped {
596 outcomes: vec![failed_outcome(
597 &file.canonical_path,
598 &file.requested_path,
599 FileRole::Primary,
600 FileClassification::FailedBackup,
601 file.remove_file,
602 file.warnings.clone(),
603 )],
604 reason: "failed_backup",
605 };
606 }
607 };
608
609 if backup_id.is_none() && path_exists(&file.canonical_path) {
613 return StepExec::Stopped {
614 outcomes: vec![failed_outcome(
615 &file.canonical_path,
616 &file.requested_path,
617 FileRole::Primary,
618 FileClassification::FailedBackup,
619 file.remove_file,
620 file.warnings.clone(),
621 )],
622 reason: "failed_backup",
623 };
624 }
625
626 if fault_is(ctx, ExecuteFault::BaselineDrift { step: step_index })
627 || !baseline_matches(&file.canonical_path, &file.baseline_bytes)
628 {
629 return StepExec::Stopped {
630 outcomes: vec![failed_outcome(
631 &file.canonical_path,
632 &file.requested_path,
633 FileRole::Primary,
634 FileClassification::FailedBaselineDrift,
635 file.remove_file,
636 file.warnings.clone(),
637 )],
638 reason: "hashline_baseline_drift",
639 };
640 }
641
642 if fault_is(ctx, ExecuteFault::Write { step: step_index }) {
643 return StepExec::Stopped {
644 outcomes: vec![failed_outcome(
645 &file.canonical_path,
646 &file.requested_path,
647 FileRole::Primary,
648 FileClassification::FailedWrite,
649 file.remove_file,
650 file.warnings.clone(),
651 )],
652 reason: "failed_write",
653 };
654 }
655
656 if file.remove_file {
657 if let Err(error) = fs::remove_file(&file.canonical_path) {
658 if error.kind() != io::ErrorKind::NotFound {
659 return StepExec::Stopped {
660 outcomes: vec![failed_outcome(
661 &file.canonical_path,
662 &file.requested_path,
663 FileRole::Primary,
664 FileClassification::FailedWrite,
665 true,
666 file.warnings.clone(),
667 )],
668 reason: "failed_write",
669 };
670 }
671 }
672 invalidate_removed_source(ctx.snapshots, &file.canonical_path);
673 return StepExec::Applied(vec![FileOutcome {
674 canonical_path: file.canonical_path,
675 requested_path: file.requested_path,
676 role: FileRole::Primary,
677 classification: FileClassification::Applied,
678 mutation_state: MutationState::Applied,
679 final_bytes: None,
680 final_tag: None,
681 affected: AffectedRegion::default(),
682 warnings: file.warnings,
683 format_skipped_reason: None,
684 backup_id,
685 remove_file: true,
686 tag_notice: Some("source path removed; no final tag".into()),
687 }]);
688 }
689
690 if let Err(error) = durable_write(&file.canonical_path, &file.final_bytes) {
691 let classification = if error.to_string().contains("durability") {
692 FileClassification::FailedDurability
693 } else {
694 FileClassification::FailedWrite
695 };
696 return StepExec::Stopped {
697 outcomes: vec![failed_outcome(
698 &file.canonical_path,
699 &file.requested_path,
700 FileRole::Primary,
701 classification,
702 false,
703 file.warnings.clone(),
704 )],
705 reason: classification.as_str(),
706 };
707 }
708
709 if fault_is(ctx, ExecuteFault::Durability { step: step_index }) {
710 return StepExec::Stopped {
711 outcomes: vec![failed_outcome(
712 &file.canonical_path,
713 &file.requested_path,
714 FileRole::Primary,
715 FileClassification::FailedDurability,
716 false,
717 file.warnings.clone(),
718 )],
719 reason: "failed_durability",
720 };
721 }
722
723 let on_disk = match fs::read(&file.canonical_path) {
725 Ok(bytes) => bytes,
726 Err(_) => {
727 return StepExec::Applied(vec![FileOutcome {
728 canonical_path: file.canonical_path,
729 requested_path: file.requested_path,
730 role: FileRole::Primary,
731 classification: FileClassification::AppliedTagUnavailable,
732 mutation_state: MutationState::Applied,
733 final_bytes: Some(file.final_bytes),
734 final_tag: None,
735 affected: file.affected,
736 warnings: file.warnings,
737 format_skipped_reason: None,
738 backup_id,
739 remove_file: false,
740 tag_notice: Some("final bytes could not be re-read for tagging".into()),
741 }]);
742 }
743 };
744
745 let mut classification = FileClassification::Applied;
746 if fault_is(ctx, ExecuteFault::ValidationFailure { step: step_index }) {
747 classification = FileClassification::AppliedWithValidationFailure;
748 }
749
750 let (final_tag, tag_notice, classification) =
751 if fault_is(ctx, ExecuteFault::FinalTagUnavailable { step: step_index }) {
752 (
753 None,
754 Some("final tag unavailable; re-read before chaining".into()),
755 FileClassification::AppliedTagUnavailable,
756 )
757 } else {
758 let published = publish_edit_response_snapshot(
759 ctx.snapshots,
760 &file.canonical_path,
761 file.requested_path.clone(),
762 &on_disk,
763 &file.affected,
764 );
765 tag_from_publish(published, classification)
766 };
767
768 StepExec::Applied(vec![FileOutcome {
769 canonical_path: file.canonical_path,
770 requested_path: file.requested_path,
771 role: FileRole::Primary,
772 classification,
773 mutation_state: classification.mutation_state(),
774 final_bytes: Some(on_disk),
775 final_tag,
776 affected: file.affected,
777 warnings: file.warnings,
778 format_skipped_reason: None,
779 backup_id,
780 remove_file: false,
781 tag_notice,
782 }])
783}
784
785fn execute_mv(
786 step_index: usize,
787 mv: PlannedMv,
788 op_id: &str,
789 journaled: &mut bool,
790 ctx: &mut ExecuteContext<'_>,
791) -> StepExec {
792 if fault_is(ctx, ExecuteFault::Backup { step: step_index }) {
793 return StepExec::Stopped {
794 outcomes: mv_failed_pair(
795 &mv,
796 FileClassification::FailedBackup,
797 FileClassification::NotAttempted,
798 ),
799 reason: "failed_backup",
800 };
801 }
802
803 let dest_backup_id = if mv.dest_existed {
805 match ctx.backups.snapshot_with_op(
806 ctx.session,
807 &mv.dest_canonical,
808 "hashline: MV destination backup",
809 Some(op_id),
810 ) {
811 Ok(Some(id)) => {
812 *journaled = true;
813 Some(id)
814 }
815 Ok(None) => {
816 return StepExec::Stopped {
817 outcomes: mv_failed_pair(
818 &mv,
819 FileClassification::FailedBackup,
820 FileClassification::NotAttempted,
821 ),
822 reason: "failed_backup",
823 };
824 }
825 Err(_) => {
826 return StepExec::Stopped {
827 outcomes: mv_failed_pair(
828 &mv,
829 FileClassification::FailedBackup,
830 FileClassification::NotAttempted,
831 ),
832 reason: "failed_backup",
833 };
834 }
835 }
836 } else {
837 match ctx.backups.snapshot_op_tombstone(
838 ctx.session,
839 op_id,
840 &mv.dest_canonical,
841 "hashline: MV created destination",
842 ) {
843 Ok(Some(id)) => {
844 *journaled = true;
845 Some(id)
846 }
847 Ok(None) => {
848 return StepExec::Stopped {
852 outcomes: mv_failed_pair(
853 &mv,
854 FileClassification::FailedBackup,
855 FileClassification::NotAttempted,
856 ),
857 reason: "failed_backup",
858 };
859 }
860 Err(_) => {
861 return StepExec::Stopped {
862 outcomes: mv_failed_pair(
863 &mv,
864 FileClassification::FailedBackup,
865 FileClassification::NotAttempted,
866 ),
867 reason: "failed_backup",
868 };
869 }
870 }
871 };
872
873 let source_backup_id = match ctx.backups.snapshot_with_op(
875 ctx.session,
876 &mv.source_canonical,
877 "hashline: MV source backup",
878 Some(op_id),
879 ) {
880 Ok(Some(id)) => {
881 *journaled = true;
882 Some(id)
883 }
884 Ok(None) | Err(_) => {
885 return StepExec::Stopped {
886 outcomes: mv_failed_pair(
887 &mv,
888 FileClassification::FailedBackup,
889 FileClassification::NotAttempted,
890 ),
891 reason: "failed_backup",
892 };
893 }
894 };
895
896 if fault_is(ctx, ExecuteFault::BaselineDrift { step: step_index })
898 || !baseline_matches(&mv.source_canonical, &mv.source_baseline_bytes)
899 || mv
900 .dest_baseline_bytes
901 .as_ref()
902 .is_some_and(|expected| !baseline_matches(&mv.dest_canonical, expected))
903 {
904 return StepExec::Stopped {
905 outcomes: mv_failed_pair(
906 &mv,
907 FileClassification::FailedBaselineDrift,
908 FileClassification::NotAttempted,
909 ),
910 reason: "hashline_baseline_drift",
911 };
912 }
913
914 if fault_is(ctx, ExecuteFault::Write { step: step_index }) {
915 return StepExec::Stopped {
916 outcomes: mv_failed_pair(
917 &mv,
918 FileClassification::FailedWrite,
919 FileClassification::NotAttempted,
920 ),
921 reason: "failed_write",
922 };
923 }
924
925 if let Err(error) = ensure_parent_dirs(&mv.dest_canonical)
927 .and_then(|_| durable_write(&mv.dest_canonical, &mv.final_bytes))
928 {
929 let classification = if error.to_string().contains("durability") {
930 FileClassification::FailedDurability
931 } else {
932 FileClassification::FailedWrite
933 };
934 return StepExec::Stopped {
935 outcomes: mv_failed_pair(&mv, classification, FileClassification::NotAttempted),
936 reason: classification.as_str(),
937 };
938 }
939
940 if fault_is(ctx, ExecuteFault::Durability { step: step_index }) {
941 return StepExec::Stopped {
942 outcomes: mv_failed_pair(
943 &mv,
944 FileClassification::FailedDurability,
945 FileClassification::NotAttempted,
946 ),
947 reason: "failed_durability",
948 };
949 }
950
951 let dest_on_disk = fs::read(&mv.dest_canonical).unwrap_or_else(|_| mv.final_bytes.clone());
952
953 if fault_is(ctx, ExecuteFault::SourceUnlink { step: step_index })
954 || fs::remove_file(&mv.source_canonical).is_err()
955 {
956 let (final_tag, tag_notice, dest_class) =
958 observe_dest_tag(ctx, &mv, &dest_on_disk, step_index);
959 return StepExec::Stopped {
960 outcomes: vec![
961 FileOutcome {
962 canonical_path: mv.dest_canonical,
963 requested_path: mv.dest_requested,
964 role: FileRole::MvDestination,
965 classification: dest_class,
966 mutation_state: dest_class.mutation_state(),
967 final_bytes: Some(dest_on_disk),
968 final_tag,
969 affected: mv.affected,
970 warnings: mv.warnings,
971 format_skipped_reason: None,
972 backup_id: dest_backup_id,
973 remove_file: false,
974 tag_notice,
975 },
976 FileOutcome {
977 canonical_path: mv.source_canonical,
978 requested_path: mv.source_requested,
979 role: FileRole::MvSource,
980 classification: FileClassification::FailedSourceUnlink,
981 mutation_state: MutationState::PartialMv,
982 final_bytes: Some(mv.source_baseline_bytes),
983 final_tag: None,
984 affected: AffectedRegion::default(),
985 warnings: Vec::new(),
986 format_skipped_reason: None,
987 backup_id: source_backup_id,
988 remove_file: false,
989 tag_notice: Some(
990 "destination written; source unlink failed — partial MV under shared op_id"
991 .into(),
992 ),
993 },
994 ],
995 reason: "failed_source_unlink",
996 };
997 }
998
999 invalidate_removed_source(ctx.snapshots, &mv.source_canonical);
1000
1001 let (final_tag, tag_notice, dest_class) = observe_dest_tag(ctx, &mv, &dest_on_disk, step_index);
1002
1003 StepExec::Applied(vec![
1004 FileOutcome {
1005 canonical_path: mv.dest_canonical,
1006 requested_path: mv.dest_requested,
1007 role: FileRole::MvDestination,
1008 classification: dest_class,
1009 mutation_state: dest_class.mutation_state(),
1010 final_bytes: Some(dest_on_disk),
1011 final_tag,
1012 affected: mv.affected,
1013 warnings: mv.warnings,
1014 format_skipped_reason: None,
1015 backup_id: dest_backup_id,
1016 remove_file: false,
1017 tag_notice,
1018 },
1019 FileOutcome {
1020 canonical_path: mv.source_canonical,
1021 requested_path: mv.source_requested,
1022 role: FileRole::MvSource,
1023 classification: FileClassification::Applied,
1024 mutation_state: MutationState::Applied,
1025 final_bytes: None,
1026 final_tag: None,
1027 affected: AffectedRegion::default(),
1028 warnings: Vec::new(),
1029 format_skipped_reason: None,
1030 backup_id: source_backup_id,
1031 remove_file: true,
1032 tag_notice: Some("source path removed; no final tag".into()),
1033 },
1034 ])
1035}
1036
1037fn observe_dest_tag(
1038 ctx: &mut ExecuteContext<'_>,
1039 mv: &PlannedMv,
1040 dest_on_disk: &[u8],
1041 step_index: usize,
1042) -> (Option<String>, Option<String>, FileClassification) {
1043 if fault_is(ctx, ExecuteFault::FinalTagUnavailable { step: step_index }) {
1044 return (
1045 None,
1046 Some("final tag unavailable; re-read before chaining".into()),
1047 FileClassification::AppliedTagUnavailable,
1048 );
1049 }
1050 let mut classification = FileClassification::Applied;
1051 if fault_is(ctx, ExecuteFault::ValidationFailure { step: step_index }) {
1052 classification = FileClassification::AppliedWithValidationFailure;
1053 }
1054 let published = publish_edit_response_snapshot(
1055 ctx.snapshots,
1056 &mv.dest_canonical,
1057 mv.dest_requested.clone(),
1058 dest_on_disk,
1059 &mv.affected,
1060 );
1061 tag_from_publish(published, classification)
1062}
1063
1064fn tag_from_publish(
1065 published: EditResponseSnapshot,
1066 classification: FileClassification,
1067) -> (Option<String>, Option<String>, FileClassification) {
1068 if let Some(snapshot) = published.snapshot {
1069 (Some(snapshot.tag.clone()), published.notice, classification)
1070 } else {
1071 (
1072 None,
1073 published
1074 .notice
1075 .or_else(|| Some("final tag unavailable; re-read before chaining".into())),
1076 FileClassification::AppliedTagUnavailable,
1077 )
1078 }
1079}
1080
1081fn journal_existing_or_skip(
1082 ctx: &mut ExecuteContext<'_>,
1083 op_id: &str,
1084 path: &Path,
1085 _remove_file: bool,
1086 description: &str,
1087) -> Result<Option<String>, ()> {
1088 if !path_exists(path) {
1089 return Ok(None);
1091 }
1092 match ctx
1093 .backups
1094 .snapshot_with_op(ctx.session, path, description, Some(op_id))
1095 {
1096 Ok(id) => Ok(id),
1097 Err(_) => Err(()),
1098 }
1099}
1100
1101fn path_exists(path: &Path) -> bool {
1104 fs::symlink_metadata(path).is_ok()
1105}
1106
1107fn baseline_matches(path: &Path, expected: &[u8]) -> bool {
1108 match fs::read(path) {
1109 Ok(bytes) => bytes == expected,
1110 Err(error) if error.kind() == io::ErrorKind::NotFound => expected.is_empty(),
1111 Err(_) => false,
1112 }
1113}
1114
1115fn ensure_parent_dirs(path: &Path) -> io::Result<()> {
1116 if let Some(parent) = path.parent() {
1117 if !parent.as_os_str().is_empty() {
1118 fs::create_dir_all(parent)?;
1119 }
1120 }
1121 Ok(())
1122}
1123
1124fn durable_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
1126 ensure_parent_dirs(path)?;
1127 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1128 let file_name = path
1129 .file_name()
1130 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
1131 let temp_name = {
1132 let mut name = std::ffi::OsString::from(".aft-hashline-");
1133 name.push(file_name);
1134 name.push(".tmp");
1135 name
1136 };
1137 let temp_path = parent.join(temp_name);
1138
1139 let write_result = (|| {
1140 let mut file = OpenOptions::new()
1141 .write(true)
1142 .create(true)
1143 .truncate(true)
1144 .open(&temp_path)?;
1145 file.write_all(bytes)?;
1146 file.sync_all()
1147 .map_err(|error| io::Error::new(error.kind(), format!("durability: {error}")))?;
1148 fs::rename(&temp_path, path)?;
1149 if let Ok(dir) = File::open(parent) {
1151 let _ = dir.sync_all();
1152 }
1153 Ok(())
1154 })();
1155
1156 if write_result.is_err() {
1157 let _ = fs::remove_file(&temp_path);
1158 }
1159 write_result
1160}
1161
1162fn failed_outcome(
1165 path: &Path,
1166 requested: &str,
1167 role: FileRole,
1168 classification: FileClassification,
1169 remove_file: bool,
1170 warnings: Vec<String>,
1171) -> FileOutcome {
1172 FileOutcome {
1173 canonical_path: path.to_path_buf(),
1174 requested_path: requested.to_string(),
1175 role,
1176 classification,
1177 mutation_state: classification.mutation_state(),
1178 final_bytes: None,
1179 final_tag: None,
1180 affected: AffectedRegion::default(),
1181 warnings,
1182 format_skipped_reason: None,
1183 backup_id: None,
1184 remove_file,
1185 tag_notice: None,
1186 }
1187}
1188
1189fn mv_failed_pair(
1190 mv: &PlannedMv,
1191 dest_class: FileClassification,
1192 source_class: FileClassification,
1193) -> Vec<FileOutcome> {
1194 vec![
1195 failed_outcome(
1196 &mv.dest_canonical,
1197 &mv.dest_requested,
1198 FileRole::MvDestination,
1199 dest_class,
1200 false,
1201 mv.warnings.clone(),
1202 ),
1203 failed_outcome(
1204 &mv.source_canonical,
1205 &mv.source_requested,
1206 FileRole::MvSource,
1207 source_class,
1208 false,
1209 Vec::new(),
1210 ),
1211 ]
1212}
1213
1214fn not_attempted_for_step(step: &PlannedStep) -> Vec<FileOutcome> {
1215 match step {
1216 PlannedStep::Mutate(file) => vec![failed_outcome(
1217 &file.canonical_path,
1218 &file.requested_path,
1219 FileRole::Primary,
1220 FileClassification::NotAttempted,
1221 file.remove_file,
1222 Vec::new(),
1223 )],
1224 PlannedStep::Mv(mv) => mv_failed_pair(
1225 mv,
1226 FileClassification::NotAttempted,
1227 FileClassification::NotAttempted,
1228 ),
1229 }
1230}
1231
1232fn fault_is(ctx: &ExecuteContext<'_>, want: ExecuteFault) -> bool {
1233 ctx.fault.as_ref() == Some(&want)
1234}
1235
1236fn counts_toward_completion(file: &FileOutcome) -> bool {
1237 !(file.role == FileRole::MvSource && file.classification.is_applied_star())
1239}
1240
1241fn summary_counts(files: &[FileOutcome]) -> String {
1242 let primary: Vec<_> = files
1243 .iter()
1244 .filter(|file| counts_toward_completion(file))
1245 .collect();
1246 let applied = primary
1247 .iter()
1248 .filter(|file| file.classification.is_applied_star())
1249 .count();
1250 let total = primary.len();
1251 format!("{applied} of {total} files applied")
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256 use super::*;
1257 use crate::backup::BackupPolicy;
1258 use crate::hashline::scan::scan_bytes;
1259 use crate::hashline::snapshot::{capture_taggable_read, ReadPublication, ReadSelection};
1260 use crate::hashline::syntax::{
1261 parse_address, resolve_address, resolve_snapshot, PutOperation, PutSource, RegisterRef,
1262 ResolvedAddress,
1263 };
1264
1265 const SESSION: &str = "hashline-tx-test";
1266
1267 fn whole_snapshot(bytes: &[u8]) -> Snapshot {
1268 scan_bytes(bytes)
1269 }
1270
1271 fn put_text(address: &str, body: &[&str]) -> Operation {
1272 Operation::Put(PutOperation {
1273 address: parse_address(address).unwrap(),
1274 source: PutSource::Text(body.iter().map(|line| (*line).to_string()).collect()),
1275 line: 1,
1276 })
1277 }
1278
1279 fn resolve_one(snapshot: &Snapshot, operation: &Operation) -> ResolvedOperation {
1280 let address = match operation.address() {
1281 Some(address) => resolve_address(address, snapshot).unwrap(),
1282 None => ResolvedAddress::WholeFile,
1283 };
1284 ResolvedOperation {
1285 operation_index: 0,
1286 address,
1287 }
1288 }
1289
1290 fn write_file(path: &Path, bytes: &[u8]) {
1291 if let Some(parent) = path.parent() {
1292 fs::create_dir_all(parent).unwrap();
1293 }
1294 fs::write(path, bytes).unwrap();
1295 }
1296
1297 fn backup_store(dir: &Path) -> BackupStore {
1298 let mut store = BackupStore::new();
1299 store.set_storage_dir(dir.to_path_buf(), 72);
1300 store
1301 }
1302
1303 fn ctx<'a>(
1304 backups: &'a mut BackupStore,
1305 snapshots: &'a mut SnapshotStore,
1306 registers: &'a mut RegisterStore,
1307 backups_enabled: bool,
1308 fault: Option<ExecuteFault>,
1309 ) -> ExecuteContext<'a> {
1310 ExecuteContext {
1311 session: SESSION,
1312 backups,
1313 snapshots,
1314 registers,
1315 backups_enabled,
1316 fault,
1317 }
1318 }
1319
1320 fn section_put<'a>(
1321 path: &'a Path,
1322 requested: &'a str,
1323 baseline: &'a Baseline,
1324 snapshot: &'a Snapshot,
1325 ops: &'a [Operation],
1326 resolved: &'a [ResolvedOperation],
1327 ) -> TransactionSectionInput<'a> {
1328 TransactionSectionInput {
1329 canonical_path: path,
1330 requested_path: requested,
1331 baseline,
1332 snapshot,
1333 operations: ops,
1334 resolved,
1335 mv_destination: None,
1336 }
1337 }
1338
1339 fn put_after_reads(
1340 selections: impl IntoIterator<Item = ReadSelection>,
1341 ) -> Result<Vec<u8>, HashlineRejection> {
1342 let temp = tempfile::tempdir().unwrap();
1343 let path = temp.path().join("reread.txt");
1344 let original = b"one\ntwo\nthree\nfour\n";
1345 write_file(&path, original);
1346
1347 let mut snapshots = SnapshotStore::new();
1348 let mut tag = None;
1349 for selection in selections {
1350 let publication =
1351 capture_taggable_read(&mut snapshots, &path, "reread.txt", selection).unwrap();
1352 let ReadPublication::Tagged { snapshot, .. } = publication else {
1353 panic!("fixture read must publish a tagged snapshot");
1354 };
1355 tag.get_or_insert(snapshot.tag);
1356 }
1357
1358 let snapshot = resolve_snapshot(
1359 &mut snapshots,
1360 &path,
1361 tag.as_deref().expect("at least one read selection"),
1362 )?;
1363 let baseline = Baseline::from_bytes(original.to_vec());
1364 let operations = vec![put_text("2", &["TWO"])];
1365 let resolved = vec![resolve_one(&snapshot, &operations[0])];
1366 let sections = [section_put(
1367 &path,
1368 "reread.txt",
1369 &baseline,
1370 &snapshot,
1371 &operations,
1372 &resolved,
1373 )];
1374 let session_registers = RegisterStore::new();
1375 let plan = plan_transaction(§ions, &session_registers, true)?;
1376 let mut backups = backup_store(&temp.path().join("backups"));
1377 let mut execution_registers = RegisterStore::new();
1378 let mut execution = ctx(
1379 &mut backups,
1380 &mut snapshots,
1381 &mut execution_registers,
1382 true,
1383 None,
1384 );
1385 let envelope = execute_transaction(plan, &mut execution);
1386 assert!(envelope.success);
1387 assert!(envelope.complete);
1388 Ok(fs::read(path).unwrap())
1389 }
1390
1391 #[test]
1392 fn two_ranged_reads_of_one_version_then_put_applies() {
1393 let bytes = put_after_reads([ReadSelection::range(1, 2), ReadSelection::range(3, 4)])
1394 .expect("same-version ranged reads must resolve");
1395 assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1396 }
1397
1398 #[test]
1399 fn ranged_then_whole_read_of_one_version_then_put_applies() {
1400 let bytes = put_after_reads([ReadSelection::range(2, 2), ReadSelection::WholeFile])
1401 .expect("same-version ranged and whole reads must resolve");
1402 assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1403 }
1404
1405 #[test]
1406 fn second_read_without_intervening_mutation_does_not_enter_refusal_loop() {
1407 let bytes = put_after_reads([ReadSelection::range(1, 1), ReadSelection::range(2, 2)])
1408 .expect("a second read of unchanged content must leave the tag editable");
1409 assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1410 }
1411
1412 #[test]
1414 fn a8_phase1_mutation_free_and_phase2_ordered() {
1415 let temp = tempfile::tempdir().unwrap();
1416 let a = temp.path().join("a.txt");
1417 let b = temp.path().join("b.txt");
1418 write_file(&a, b"alpha\n");
1419 write_file(&b, b"beta\n");
1420 let bytes_a = fs::read(&a).unwrap();
1421 let bytes_b = fs::read(&b).unwrap();
1422 let snap_a = whole_snapshot(&bytes_a);
1423 let snap_b = whole_snapshot(&bytes_b);
1424 let base_a = Baseline::from_bytes(bytes_a.clone());
1425 let base_b = Baseline::from_bytes(bytes_b.clone());
1426 let ops_a = vec![put_text("1", &["ALPHA"])];
1427 let ops_b = vec![put_text("1", &["BETA"])];
1428 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1429 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1430 let sections = [
1431 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1432 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1433 ];
1434 let registers = RegisterStore::new();
1435 let plan = plan_transaction(§ions, ®isters, true).expect("phase1");
1436 assert_eq!(fs::read(&a).unwrap(), b"alpha\n");
1438 assert_eq!(fs::read(&b).unwrap(), b"beta\n");
1439 assert_eq!(plan.steps.len(), 2);
1440
1441 let backup_dir = temp.path().join("backups");
1442 let mut backups = backup_store(&backup_dir);
1443 let mut snapshots = SnapshotStore::new();
1444 let mut session_regs = RegisterStore::new();
1445 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1446 let envelope = execute_transaction(plan, &mut exec);
1447 assert!(envelope.success);
1448 assert!(envelope.complete);
1449 assert!(envelope.op_id.is_some());
1450 assert_eq!(envelope.files.len(), 2);
1451 assert_eq!(envelope.files[0].requested_path, "a.txt");
1452 assert_eq!(envelope.files[1].requested_path, "b.txt");
1453 assert_eq!(
1454 envelope.files[0].classification,
1455 FileClassification::Applied
1456 );
1457 assert_eq!(envelope.files[0].mutation_state, MutationState::Applied);
1458 assert_eq!(fs::read(&a).unwrap(), b"ALPHA\n");
1459 assert_eq!(fs::read(&b).unwrap(), b"BETA\n");
1460 assert!(envelope.summary_text.contains("2 of 2 files applied"));
1461
1462 let op_id = envelope.op_id.clone().unwrap();
1464 let restored = backups.restore_last_operation(SESSION).unwrap();
1465 assert_eq!(restored.op_id, op_id);
1466 assert_eq!(fs::read(&a).unwrap(), b"alpha\n");
1467 assert_eq!(fs::read(&b).unwrap(), b"beta\n");
1468 }
1469
1470 #[test]
1472 fn a8_all_failed_emits_success_false_with_envelope() {
1473 let temp = tempfile::tempdir().unwrap();
1474 let a = temp.path().join("a.txt");
1475 let b = temp.path().join("b.txt");
1476 write_file(&a, b"a\n");
1477 write_file(&b, b"b\n");
1478 let bytes_a = fs::read(&a).unwrap();
1479 let bytes_b = fs::read(&b).unwrap();
1480 let snap_a = whole_snapshot(&bytes_a);
1481 let snap_b = whole_snapshot(&bytes_b);
1482 let base_a = Baseline::from_bytes(bytes_a);
1483 let base_b = Baseline::from_bytes(bytes_b);
1484 let ops_a = vec![put_text("1", &["A"])];
1485 let ops_b = vec![put_text("1", &["B"])];
1486 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1487 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1488 let sections = [
1489 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1490 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1491 ];
1492 let registers = RegisterStore::new();
1493 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1494
1495 let mut backups = backup_store(&temp.path().join("backups"));
1496 let mut snapshots = SnapshotStore::new();
1497 let mut session_regs = RegisterStore::new();
1498 let mut exec = ctx(
1499 &mut backups,
1500 &mut snapshots,
1501 &mut session_regs,
1502 true,
1503 Some(ExecuteFault::BaselineDrift { step: 0 }),
1504 );
1505 let envelope = execute_transaction(plan, &mut exec);
1506 assert!(!envelope.success);
1507 assert!(!envelope.complete);
1508 assert_eq!(
1509 envelope.files[0].classification,
1510 FileClassification::FailedBaselineDrift
1511 );
1512 assert_eq!(envelope.files[0].mutation_state, MutationState::Unmutated);
1513 assert_eq!(
1514 envelope.files[1].classification,
1515 FileClassification::NotAttempted
1516 );
1517 assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
1518 assert_eq!(envelope.stop_reason, Some("hashline_baseline_drift"));
1519 assert!(envelope.summary_text.starts_with("0 of 2 files applied"));
1520 assert!(envelope.op_id.is_some());
1523 assert_eq!(fs::read(&a).unwrap(), b"a\n");
1524 assert_eq!(fs::read(&b).unwrap(), b"b\n");
1525 }
1526
1527 #[test]
1529 fn a8_partial_failure_keeps_prior_under_shared_op_id() {
1530 let temp = tempfile::tempdir().unwrap();
1531 let a = temp.path().join("a.txt");
1532 let b = temp.path().join("b.txt");
1533 write_file(&a, b"a\n");
1534 write_file(&b, b"b\n");
1535 let bytes_a = fs::read(&a).unwrap();
1536 let bytes_b = fs::read(&b).unwrap();
1537 let snap_a = whole_snapshot(&bytes_a);
1538 let snap_b = whole_snapshot(&bytes_b);
1539 let base_a = Baseline::from_bytes(bytes_a);
1540 let base_b = Baseline::from_bytes(bytes_b);
1541 let ops_a = vec![put_text("1", &["A"])];
1542 let ops_b = vec![put_text("1", &["B"])];
1543 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1544 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1545 let sections = [
1546 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1547 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1548 ];
1549 let registers = RegisterStore::new();
1550 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1551
1552 let mut backups = backup_store(&temp.path().join("backups"));
1553 let mut snapshots = SnapshotStore::new();
1554 let mut session_regs = RegisterStore::new();
1555 let mut exec = ctx(
1556 &mut backups,
1557 &mut snapshots,
1558 &mut session_regs,
1559 true,
1560 Some(ExecuteFault::Write { step: 1 }),
1561 );
1562 let envelope = execute_transaction(plan, &mut exec);
1563 assert!(envelope.success);
1564 assert!(!envelope.complete);
1565 assert!(envelope.op_id.is_some());
1566 assert_eq!(
1567 envelope.files[0].classification,
1568 FileClassification::Applied
1569 );
1570 assert_eq!(
1571 envelope.files[1].classification,
1572 FileClassification::FailedWrite
1573 );
1574 assert_eq!(
1575 envelope.files[1].mutation_state,
1576 MutationState::UnknownPossiblyMutated
1577 );
1578 assert_eq!(fs::read(&a).unwrap(), b"A\n");
1579 assert_eq!(fs::read(&b).unwrap(), b"b\n");
1580
1581 let op_id = envelope.op_id.unwrap();
1582 let restored = backups.restore_last_operation(SESSION).unwrap();
1583 assert_eq!(restored.op_id, op_id);
1584 assert_eq!(fs::read(&a).unwrap(), b"a\n");
1585 }
1586
1587 #[test]
1589 fn a8_mv_new_and_existing_destination_with_undo() {
1590 let temp = tempfile::tempdir().unwrap();
1591 let src = temp.path().join("src.txt");
1592 let new_dest = temp.path().join("new_dest.txt");
1593 write_file(&src, b"move-me\n");
1594 let bytes = fs::read(&src).unwrap();
1595 let snap = whole_snapshot(&bytes);
1596 let base = Baseline::from_bytes(bytes.clone());
1597 let ops = vec![Operation::Mv(MvOperation {
1598 destination: "new_dest.txt".into(),
1599 line: 1,
1600 })];
1601 let resolved = vec![ResolvedOperation {
1603 operation_index: 0,
1604 address: ResolvedAddress::WholeFile,
1605 }];
1606 let sections = [TransactionSectionInput {
1607 canonical_path: &src,
1608 requested_path: "src.txt",
1609 baseline: &base,
1610 snapshot: &snap,
1611 operations: &ops,
1612 resolved: &resolved,
1613 mv_destination: Some(MvDestinationInput {
1614 canonical_path: &new_dest,
1615 requested_path: "new_dest.txt",
1616 baseline_bytes: None,
1617 }),
1618 }];
1619 let registers = RegisterStore::new();
1620 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1621 let mut backups = backup_store(&temp.path().join("backups"));
1622 let mut snapshots = SnapshotStore::new();
1623 snapshots.publish(&src, snap.clone());
1625 let mut session_regs = RegisterStore::new();
1626 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1627 let envelope = execute_transaction(plan, &mut exec);
1628 assert!(envelope.success && envelope.complete);
1629 assert!(envelope.op_id.is_some());
1630 assert_eq!(envelope.files[0].role, FileRole::MvDestination);
1631 assert_eq!(envelope.files[1].role, FileRole::MvSource);
1632 assert!(envelope.files[0].final_tag.is_some());
1633 assert!(envelope.files[1].remove_file);
1634 assert_eq!(fs::read(&new_dest).unwrap(), b"move-me\n");
1635 assert!(!src.exists());
1636 assert!(snapshots.lookup(&src, &snap.tag).is_err());
1638
1639 let op_id = envelope.op_id.unwrap();
1640 let restored = backups.restore_last_operation(SESSION).unwrap();
1641 assert_eq!(restored.op_id, op_id);
1642 assert_eq!(fs::read(&src).unwrap(), b"move-me\n");
1643 assert!(!new_dest.exists(), "created destination removed on undo");
1644
1645 let src2 = temp.path().join("src2.txt");
1647 let dest2 = temp.path().join("dest2.txt");
1648 write_file(&src2, b"from\n");
1649 write_file(&dest2, b"old-dest\n");
1650 let bytes2 = fs::read(&src2).unwrap();
1651 let snap2 = whole_snapshot(&bytes2);
1652 let base2 = Baseline::from_bytes(bytes2);
1653 let dest_bytes = fs::read(&dest2).unwrap();
1654 let ops2 = vec![Operation::Mv(MvOperation {
1655 destination: "dest2.txt".into(),
1656 line: 1,
1657 })];
1658 let resolved2 = vec![ResolvedOperation {
1659 operation_index: 0,
1660 address: ResolvedAddress::WholeFile,
1661 }];
1662 let sections2 = [TransactionSectionInput {
1663 canonical_path: &src2,
1664 requested_path: "src2.txt",
1665 baseline: &base2,
1666 snapshot: &snap2,
1667 operations: &ops2,
1668 resolved: &resolved2,
1669 mv_destination: Some(MvDestinationInput {
1670 canonical_path: &dest2,
1671 requested_path: "dest2.txt",
1672 baseline_bytes: Some(&dest_bytes),
1673 }),
1674 }];
1675 let plan2 = plan_transaction(§ions2, ®isters, true).unwrap();
1676 let mut exec2 = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1677 let envelope2 = execute_transaction(plan2, &mut exec2);
1678 assert!(envelope2.success);
1679 assert_eq!(fs::read(&dest2).unwrap(), b"from\n");
1680 assert!(!src2.exists());
1681 let restored2 = backups.restore_last_operation(SESSION).unwrap();
1682 assert_eq!(restored2.op_id, envelope2.op_id.unwrap());
1683 assert_eq!(fs::read(&src2).unwrap(), b"from\n");
1684 assert_eq!(fs::read(&dest2).unwrap(), b"old-dest\n");
1685 }
1686
1687 #[test]
1689 fn a8_mv_source_unlink_failure_is_partial_mv() {
1690 let temp = tempfile::tempdir().unwrap();
1691 let src = temp.path().join("src.txt");
1692 let dest = temp.path().join("dest.txt");
1693 write_file(&src, b"body\n");
1694 let bytes = fs::read(&src).unwrap();
1695 let snap = whole_snapshot(&bytes);
1696 let base = Baseline::from_bytes(bytes);
1697 let ops = vec![Operation::Mv(MvOperation {
1698 destination: "dest.txt".into(),
1699 line: 1,
1700 })];
1701 let resolved = vec![ResolvedOperation {
1702 operation_index: 0,
1703 address: ResolvedAddress::WholeFile,
1704 }];
1705 let sections = [TransactionSectionInput {
1706 canonical_path: &src,
1707 requested_path: "src.txt",
1708 baseline: &base,
1709 snapshot: &snap,
1710 operations: &ops,
1711 resolved: &resolved,
1712 mv_destination: Some(MvDestinationInput {
1713 canonical_path: &dest,
1714 requested_path: "dest.txt",
1715 baseline_bytes: None,
1716 }),
1717 }];
1718 let registers = RegisterStore::new();
1719 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1720 let mut backups = backup_store(&temp.path().join("backups"));
1721 let mut snapshots = SnapshotStore::new();
1722 let mut session_regs = RegisterStore::new();
1723 let mut exec = ctx(
1724 &mut backups,
1725 &mut snapshots,
1726 &mut session_regs,
1727 true,
1728 Some(ExecuteFault::SourceUnlink { step: 0 }),
1729 );
1730 let envelope = execute_transaction(plan, &mut exec);
1731 assert!(envelope.success);
1732 assert!(!envelope.complete);
1733 assert!(envelope.op_id.is_some());
1734 assert_eq!(
1735 envelope.files[0].classification,
1736 FileClassification::Applied
1737 );
1738 assert_eq!(
1739 envelope.files[1].classification,
1740 FileClassification::FailedSourceUnlink
1741 );
1742 assert_eq!(envelope.files[1].mutation_state, MutationState::PartialMv);
1743 assert_eq!(fs::read(&dest).unwrap(), b"body\n");
1744 assert!(src.exists(), "source remains after unlink failure");
1745 }
1746
1747 #[test]
1749 fn a8_register_commit_only_when_all_applied() {
1750 let temp = tempfile::tempdir().unwrap();
1751 let a = temp.path().join("a.txt");
1752 write_file(&a, b"one\ntwo\n");
1753 let bytes = fs::read(&a).unwrap();
1754 let snap = whole_snapshot(&bytes);
1755 let base = Baseline::from_bytes(bytes);
1756 let ops = vec![Operation::Cut(crate::hashline::syntax::CutOperation {
1757 address: parse_address("1").unwrap(),
1758 register: Some(RegisterRef::Named("clip".into())),
1759 line: 1,
1760 })];
1761 let resolved = vec![resolve_one(&snap, &ops[0])];
1762 let sections = [section_put(&a, "a.txt", &base, &snap, &ops, &resolved)];
1763 let registers = RegisterStore::new();
1764 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1765
1766 let mut backups = backup_store(&temp.path().join("backups"));
1767 let mut snapshots = SnapshotStore::new();
1768 let mut session_regs = RegisterStore::new();
1769 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1770 let envelope = execute_transaction(plan, &mut exec);
1771 assert!(envelope.registers_committed);
1772 assert_eq!(
1773 session_regs.get(&RegisterRef::Named("clip".into())),
1774 Some(["one".to_string()].as_slice())
1775 );
1776
1777 write_file(&a, b"one\ntwo\n");
1779 let plan2 = plan_transaction(§ions, &RegisterStore::new(), true).unwrap();
1780 let mut session_regs2 = RegisterStore::new();
1781 let mut exec2 = ctx(
1782 &mut backups,
1783 &mut snapshots,
1784 &mut session_regs2,
1785 true,
1786 Some(ExecuteFault::Write { step: 0 }),
1787 );
1788 let envelope2 = execute_transaction(plan2, &mut exec2);
1789 assert!(!envelope2.registers_committed);
1790 assert!(session_regs2
1791 .get(&RegisterRef::Named("clip".into()))
1792 .is_none());
1793 }
1794
1795 #[test]
1797 fn a8_applied_star_variants() {
1798 let temp = tempfile::tempdir().unwrap();
1799 let path = temp.path().join("v.txt");
1800 write_file(&path, b"x\n");
1801 let bytes = fs::read(&path).unwrap();
1802 let snap = whole_snapshot(&bytes);
1803 let base = Baseline::from_bytes(bytes);
1804 let ops = vec![put_text("1", &["Y"])];
1805 let resolved = vec![resolve_one(&snap, &ops[0])];
1806 let sections = [section_put(&path, "v.txt", &base, &snap, &ops, &resolved)];
1807 let registers = RegisterStore::new();
1808
1809 let mut backups = backup_store(&temp.path().join("backups"));
1810 let mut snapshots = SnapshotStore::new();
1811 let mut session_regs = RegisterStore::new();
1812 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1813 let mut exec = ctx(
1814 &mut backups,
1815 &mut snapshots,
1816 &mut session_regs,
1817 true,
1818 Some(ExecuteFault::ValidationFailure { step: 0 }),
1819 );
1820 let envelope = execute_transaction(plan, &mut exec);
1821 assert_eq!(
1822 envelope.files[0].classification,
1823 FileClassification::AppliedWithValidationFailure
1824 );
1825 assert_eq!(fs::read(&path).unwrap(), b"Y\n");
1826
1827 write_file(&path, b"x\n");
1828 let bytes = fs::read(&path).unwrap();
1829 let snap = whole_snapshot(&bytes);
1830 let base = Baseline::from_bytes(bytes);
1831 let sections = [section_put(&path, "v.txt", &base, &snap, &ops, &resolved)];
1832 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1833 let mut exec = ctx(
1834 &mut backups,
1835 &mut snapshots,
1836 &mut session_regs,
1837 true,
1838 Some(ExecuteFault::FinalTagUnavailable { step: 0 }),
1839 );
1840 let envelope = execute_transaction(plan, &mut exec);
1841 assert_eq!(
1842 envelope.files[0].classification,
1843 FileClassification::AppliedTagUnavailable
1844 );
1845 assert!(envelope.files[0].final_tag.is_none());
1846 assert!(envelope.files[0].tag_notice.is_some());
1847 }
1848
1849 #[test]
1851 fn a10_preview_mutates_nothing() {
1852 let temp = tempfile::tempdir().unwrap();
1853 let path = temp.path().join("p.txt");
1854 let dest = temp.path().join("p-dest.txt");
1855 write_file(&path, b"preview\n");
1856 let bytes = fs::read(&path).unwrap();
1857 let snap = whole_snapshot(&bytes);
1858 let base = Baseline::from_bytes(bytes.clone());
1859 let ops = vec![
1860 put_text("1", &["PREVIEWED"]),
1861 Operation::Mv(MvOperation {
1862 destination: "p-dest.txt".into(),
1863 line: 2,
1864 }),
1865 ];
1866 let resolved = vec![
1867 resolve_one(&snap, &ops[0]),
1868 ResolvedOperation {
1869 operation_index: 1,
1870 address: ResolvedAddress::WholeFile,
1871 },
1872 ];
1873 let sections = [TransactionSectionInput {
1874 canonical_path: &path,
1875 requested_path: "p.txt",
1876 baseline: &base,
1877 snapshot: &snap,
1878 operations: &ops,
1879 resolved: &resolved,
1880 mv_destination: Some(MvDestinationInput {
1881 canonical_path: &dest,
1882 requested_path: "p-dest.txt",
1883 baseline_bytes: None,
1884 }),
1885 }];
1886 let mut registers = RegisterStore::new();
1887 {
1889 let mut staged = registers.stage();
1890 staged
1891 .capture(RegisterRef::Named("keep".into()), vec!["seed".into()])
1892 .unwrap();
1893 registers.commit(staged);
1894 }
1895 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1896 let before_reg = registers
1897 .get(&RegisterRef::Named("keep".into()))
1898 .map(|lines| lines.to_vec());
1899
1900 let backups = backup_store(&temp.path().join("backups"));
1901 let tracked_before = backups.tracked_files(SESSION);
1902 let mut snapshots = SnapshotStore::new();
1903 snapshots.publish(&path, snap.clone());
1904 let envelope = preview_transaction(plan);
1905
1906 assert!(envelope.preview);
1907 assert!(envelope.op_id.is_none());
1908 assert!(!envelope.registers_committed);
1909 assert_eq!(fs::read(&path).unwrap(), b"preview\n");
1910 assert!(!dest.exists());
1911 assert_eq!(backups.tracked_files(SESSION), tracked_before);
1912 assert!(snapshots.lookup(&path, &snap.tag).is_ok());
1913 assert_eq!(
1914 registers
1915 .get(&RegisterRef::Named("keep".into()))
1916 .map(|lines| lines.to_vec()),
1917 before_reg
1918 );
1919 assert!(envelope.files.iter().all(|f| f.final_tag.is_none()));
1920 assert!(envelope
1921 .files
1922 .iter()
1923 .all(|f| f.mutation_state == MutationState::Unmutated));
1924 }
1925
1926 #[test]
1928 fn a12_baseline_drift_stops_later_files_and_keeps_prior_op_id() {
1929 let temp = tempfile::tempdir().unwrap();
1930 let a = temp.path().join("a.txt");
1931 let b = temp.path().join("b.txt");
1932 write_file(&a, b"a0\n");
1933 write_file(&b, b"b0\n");
1934 let bytes_a = fs::read(&a).unwrap();
1935 let bytes_b = fs::read(&b).unwrap();
1936 let snap_a = whole_snapshot(&bytes_a);
1937 let snap_b = whole_snapshot(&bytes_b);
1938 let base_a = Baseline::from_bytes(bytes_a);
1939 let base_b = Baseline::from_bytes(bytes_b);
1940 let ops_a = vec![put_text("1", &["A1"])];
1941 let ops_b = vec![put_text("1", &["B1"])];
1942 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1943 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1944 let sections = [
1945 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1946 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1947 ];
1948 let registers = RegisterStore::new();
1949 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1950
1951 write_file(&b, b"b-EXTERNAL\n");
1953
1954 let mut backups = backup_store(&temp.path().join("backups"));
1955 let mut snapshots = SnapshotStore::new();
1956 let mut session_regs = RegisterStore::new();
1957 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1958 let envelope = execute_transaction(plan, &mut exec);
1959
1960 assert!(envelope.success);
1961 assert!(!envelope.complete);
1962 assert_eq!(
1963 envelope.files[0].classification,
1964 FileClassification::Applied
1965 );
1966 assert_eq!(
1967 envelope.files[1].classification,
1968 FileClassification::FailedBaselineDrift
1969 );
1970 assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
1971 assert_eq!(envelope.stop_reason, Some("hashline_baseline_drift"));
1972 assert!(envelope.op_id.is_some());
1973 assert_eq!(fs::read(&a).unwrap(), b"A1\n");
1974 assert_eq!(fs::read(&b).unwrap(), b"b-EXTERNAL\n");
1975
1976 let op_id = envelope.op_id.unwrap();
1977 let restored = backups.restore_last_operation(SESSION).unwrap();
1978 assert_eq!(restored.op_id, op_id);
1979 assert_eq!(fs::read(&a).unwrap(), b"a0\n");
1980 }
1981
1982 #[test]
1984 fn a17_backup_unavailable_refusals_and_new_dest_mv() {
1985 let temp = tempfile::tempdir().unwrap();
1986 let path = temp.path().join("t.txt");
1987 write_file(&path, b"t\n");
1988 let bytes = fs::read(&path).unwrap();
1989 let snap = whole_snapshot(&bytes);
1990 let base = Baseline::from_bytes(bytes);
1991 let ops = vec![put_text("1", &["T"])];
1992 let resolved = vec![resolve_one(&snap, &ops[0])];
1993 let sections = [section_put(&path, "t.txt", &base, &snap, &ops, &resolved)];
1994 let registers = RegisterStore::new();
1995 let err = plan_transaction(§ions, ®isters, false).unwrap_err();
1996 assert_eq!(
1997 err.code,
1998 crate::hashline::syntax::HashlineRejectionCode::BackupUnavailable
1999 );
2000 assert_eq!(err.stage, crate::hashline::syntax::RejectionStage::Baseline);
2001 assert_eq!(fs::read(&path).unwrap(), b"t\n");
2002
2003 let src = temp.path().join("s.txt");
2005 let dest = temp.path().join("d.txt");
2006 write_file(&src, b"s\n");
2007 write_file(&dest, b"d\n");
2008 let s_bytes = fs::read(&src).unwrap();
2009 let d_bytes = fs::read(&dest).unwrap();
2010 let s_snap = whole_snapshot(&s_bytes);
2011 let s_base = Baseline::from_bytes(s_bytes);
2012 let mv_ops = vec![Operation::Mv(MvOperation {
2013 destination: "d.txt".into(),
2014 line: 1,
2015 })];
2016 let mv_resolved = vec![ResolvedOperation {
2017 operation_index: 0,
2018 address: ResolvedAddress::WholeFile,
2019 }];
2020 let mv_sections = [TransactionSectionInput {
2021 canonical_path: &src,
2022 requested_path: "s.txt",
2023 baseline: &s_base,
2024 snapshot: &s_snap,
2025 operations: &mv_ops,
2026 resolved: &mv_resolved,
2027 mv_destination: Some(MvDestinationInput {
2028 canonical_path: &dest,
2029 requested_path: "d.txt",
2030 baseline_bytes: Some(&d_bytes),
2031 }),
2032 }];
2033 let err = plan_transaction(&mv_sections, ®isters, false).unwrap_err();
2034 assert_eq!(
2035 err.code,
2036 crate::hashline::syntax::HashlineRejectionCode::BackupUnavailable
2037 );
2038 assert_eq!(fs::read(&src).unwrap(), b"s\n");
2039 assert_eq!(fs::read(&dest).unwrap(), b"d\n");
2040
2041 let src2 = temp.path().join("s2.txt");
2044 let dest2 = temp.path().join("d2.txt");
2045 write_file(&src2, b"s2\n");
2046 let s2_bytes = fs::read(&src2).unwrap();
2047 let s2_snap = whole_snapshot(&s2_bytes);
2048 let s2_base = Baseline::from_bytes(s2_bytes);
2049 let mv2_ops = vec![Operation::Mv(MvOperation {
2050 destination: "d2.txt".into(),
2051 line: 1,
2052 })];
2053 let mv2_resolved = vec![ResolvedOperation {
2054 operation_index: 0,
2055 address: ResolvedAddress::WholeFile,
2056 }];
2057 let mv2_sections = [TransactionSectionInput {
2058 canonical_path: &src2,
2059 requested_path: "s2.txt",
2060 baseline: &s2_base,
2061 snapshot: &s2_snap,
2062 operations: &mv2_ops,
2063 resolved: &mv2_resolved,
2064 mv_destination: Some(MvDestinationInput {
2065 canonical_path: &dest2,
2066 requested_path: "d2.txt",
2067 baseline_bytes: None,
2068 }),
2069 }];
2070 let plan = plan_transaction(&mv2_sections, ®isters, false).expect("new dest MV plans");
2071 let mut backups = backup_store(&temp.path().join("backups"));
2072 let mut snapshots = SnapshotStore::new();
2073 let mut session_regs = RegisterStore::new();
2074 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, false, None);
2077 let envelope = execute_transaction(plan, &mut exec);
2078 assert!(envelope.success);
2079 assert!(envelope.op_id.is_some(), "real journaled op_id required");
2080 assert_eq!(fs::read(&dest2).unwrap(), b"s2\n");
2081 assert!(!src2.exists());
2082 let op_id = envelope.op_id.unwrap();
2083 let restored = backups.restore_last_operation(SESSION).unwrap();
2084 assert_eq!(restored.op_id, op_id);
2085 assert_eq!(fs::read(&src2).unwrap(), b"s2\n");
2086 assert!(!dest2.exists());
2087
2088 let mut disabled = BackupStore::new();
2090 disabled.set_policy(BackupPolicy {
2091 enabled: false,
2092 ..BackupPolicy::default()
2093 });
2094 write_file(&src2, b"s2\n");
2095 let plan = plan_transaction(&mv2_sections, ®isters, false).unwrap();
2098 let mut snapshots = SnapshotStore::new();
2099 let mut session_regs = RegisterStore::new();
2100 let mut exec = ctx(
2101 &mut disabled,
2102 &mut snapshots,
2103 &mut session_regs,
2104 false,
2105 None,
2106 );
2107 let envelope = execute_transaction(plan, &mut exec);
2108 assert!(!envelope.success);
2109 assert!(envelope.op_id.is_none());
2110 assert_eq!(
2111 envelope.files[0].classification,
2112 FileClassification::FailedBackup
2113 );
2114 }
2115
2116 #[test]
2118 fn op_id_present_when_journal_entry_exists_before_failure() {
2119 let temp = tempfile::tempdir().unwrap();
2120 let a = temp.path().join("a.txt");
2121 write_file(&a, b"a\n");
2122 let bytes = fs::read(&a).unwrap();
2123 let snap = whole_snapshot(&bytes);
2124 let base = Baseline::from_bytes(bytes);
2125 let ops = vec![put_text("1", &["A"])];
2126 let resolved = vec![resolve_one(&snap, &ops[0])];
2127 let sections = [section_put(&a, "a.txt", &base, &snap, &ops, &resolved)];
2128 let registers = RegisterStore::new();
2129 let plan = plan_transaction(§ions, ®isters, true).unwrap();
2130 let mut backups = backup_store(&temp.path().join("backups"));
2131 let mut snapshots = SnapshotStore::new();
2132 let mut session_regs = RegisterStore::new();
2133 write_file(&a, b"changed\n");
2136 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
2137 let envelope = execute_transaction(plan, &mut exec);
2138 assert!(!envelope.success);
2139 assert_eq!(
2140 envelope.files[0].classification,
2141 FileClassification::FailedBaselineDrift
2142 );
2143 assert!(envelope.op_id.is_some());
2145 assert_eq!(fs::read(&a).unwrap(), b"changed\n");
2146 }
2147}