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