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 backup_id.is_none() && path_exists(&file.canonical_path) {
617 return StepExec::Stopped {
618 outcomes: vec![failed_outcome(
619 &file.canonical_path,
620 &file.requested_path,
621 FileRole::Primary,
622 FileClassification::FailedBackup,
623 file.remove_file,
624 file.warnings.clone(),
625 )],
626 reason: "failed_backup",
627 };
628 }
629
630 if fault_is(ctx, ExecuteFault::BaselineDrift { step: step_index })
631 || !baseline_matches(&file.canonical_path, &file.baseline_bytes)
632 {
633 return StepExec::Stopped {
634 outcomes: vec![failed_outcome(
635 &file.canonical_path,
636 &file.requested_path,
637 FileRole::Primary,
638 FileClassification::FailedBaselineDrift,
639 file.remove_file,
640 file.warnings.clone(),
641 )],
642 reason: "hashline_baseline_drift",
643 };
644 }
645
646 if fault_is(ctx, ExecuteFault::Write { step: step_index }) {
647 return StepExec::Stopped {
648 outcomes: vec![failed_outcome(
649 &file.canonical_path,
650 &file.requested_path,
651 FileRole::Primary,
652 FileClassification::FailedWrite,
653 file.remove_file,
654 file.warnings.clone(),
655 )],
656 reason: "failed_write",
657 };
658 }
659
660 if file.remove_file {
661 if let Err(error) = fs::remove_file(&file.canonical_path) {
662 if error.kind() != io::ErrorKind::NotFound {
663 return StepExec::Stopped {
664 outcomes: vec![failed_outcome(
665 &file.canonical_path,
666 &file.requested_path,
667 FileRole::Primary,
668 FileClassification::FailedWrite,
669 true,
670 file.warnings.clone(),
671 )],
672 reason: "failed_write",
673 };
674 }
675 }
676 invalidate_removed_source(ctx.snapshots, &file.canonical_path);
677 return StepExec::Applied(vec![FileOutcome {
678 canonical_path: file.canonical_path,
679 requested_path: file.requested_path,
680 role: FileRole::Primary,
681 classification: FileClassification::Applied,
682 mutation_state: MutationState::Applied,
683 final_bytes: None,
684 final_tag: None,
685 affected: AffectedRegion::default(),
686 warnings: file.warnings,
687 format_skipped_reason: None,
688 backup_id,
689 remove_file: true,
690 tag_notice: Some("source path removed; no final tag".into()),
691 }]);
692 }
693
694 if let Err(error) = durable_write(&file.canonical_path, &file.final_bytes) {
695 let classification = if error.to_string().contains("durability") {
696 FileClassification::FailedDurability
697 } else {
698 FileClassification::FailedWrite
699 };
700 return StepExec::Stopped {
701 outcomes: vec![failed_outcome(
702 &file.canonical_path,
703 &file.requested_path,
704 FileRole::Primary,
705 classification,
706 false,
707 file.warnings.clone(),
708 )],
709 reason: classification.as_str(),
710 };
711 }
712
713 if fault_is(ctx, ExecuteFault::Durability { step: step_index }) {
714 return StepExec::Stopped {
715 outcomes: vec![failed_outcome(
716 &file.canonical_path,
717 &file.requested_path,
718 FileRole::Primary,
719 FileClassification::FailedDurability,
720 false,
721 file.warnings.clone(),
722 )],
723 reason: "failed_durability",
724 };
725 }
726
727 let on_disk = match fs::read(&file.canonical_path) {
729 Ok(bytes) => bytes,
730 Err(_) => {
731 return StepExec::Applied(vec![FileOutcome {
732 canonical_path: file.canonical_path,
733 requested_path: file.requested_path,
734 role: FileRole::Primary,
735 classification: FileClassification::AppliedTagUnavailable,
736 mutation_state: MutationState::Applied,
737 final_bytes: Some(file.final_bytes),
738 final_tag: None,
739 affected: file.affected,
740 warnings: file.warnings,
741 format_skipped_reason: None,
742 backup_id,
743 remove_file: false,
744 tag_notice: Some("final bytes could not be re-read for tagging".into()),
745 }]);
746 }
747 };
748
749 let mut classification = FileClassification::Applied;
750 if fault_is(ctx, ExecuteFault::ValidationFailure { step: step_index }) {
751 classification = FileClassification::AppliedWithValidationFailure;
752 }
753
754 let (final_tag, tag_notice, classification) =
755 if fault_is(ctx, ExecuteFault::FinalTagUnavailable { step: step_index }) {
756 (
757 None,
758 Some("final tag unavailable; re-read before chaining".into()),
759 FileClassification::AppliedTagUnavailable,
760 )
761 } else {
762 let published = publish_edit_response_snapshot(
763 ctx.snapshots,
764 &file.canonical_path,
765 file.requested_path.clone(),
766 &on_disk,
767 &file.affected,
768 );
769 tag_from_publish(published, classification)
770 };
771
772 StepExec::Applied(vec![FileOutcome {
773 canonical_path: file.canonical_path,
774 requested_path: file.requested_path,
775 role: FileRole::Primary,
776 classification,
777 mutation_state: classification.mutation_state(),
778 final_bytes: Some(on_disk),
779 final_tag,
780 affected: file.affected,
781 warnings: file.warnings,
782 format_skipped_reason: None,
783 backup_id,
784 remove_file: false,
785 tag_notice,
786 }])
787}
788
789fn execute_mv(
790 step_index: usize,
791 mv: PlannedMv,
792 op_id: &str,
793 journaled: &mut bool,
794 ctx: &mut ExecuteContext<'_>,
795) -> StepExec {
796 if fault_is(ctx, ExecuteFault::Backup { step: step_index }) {
797 return StepExec::Stopped {
798 outcomes: mv_failed_pair(
799 &mv,
800 FileClassification::FailedBackup,
801 FileClassification::NotAttempted,
802 ),
803 reason: "failed_backup",
804 };
805 }
806
807 let dest_backup_id = if mv.dest_existed {
809 match ctx.backups.snapshot_with_op(
810 ctx.session,
811 &mv.dest_canonical,
812 "hashline: MV destination backup",
813 Some(op_id),
814 ) {
815 Ok(Some(id)) => {
816 *journaled = true;
817 Some(id)
818 }
819 Ok(None) => {
820 return StepExec::Stopped {
821 outcomes: mv_failed_pair(
822 &mv,
823 FileClassification::FailedBackup,
824 FileClassification::NotAttempted,
825 ),
826 reason: "failed_backup",
827 };
828 }
829 Err(_) => {
830 return StepExec::Stopped {
831 outcomes: mv_failed_pair(
832 &mv,
833 FileClassification::FailedBackup,
834 FileClassification::NotAttempted,
835 ),
836 reason: "failed_backup",
837 };
838 }
839 }
840 } else {
841 match ctx.backups.snapshot_op_tombstone(
842 ctx.session,
843 op_id,
844 &mv.dest_canonical,
845 "hashline: MV created destination",
846 ) {
847 Ok(Some(id)) => {
848 *journaled = true;
849 Some(id)
850 }
851 Ok(None) => {
852 return StepExec::Stopped {
856 outcomes: mv_failed_pair(
857 &mv,
858 FileClassification::FailedBackup,
859 FileClassification::NotAttempted,
860 ),
861 reason: "failed_backup",
862 };
863 }
864 Err(_) => {
865 return StepExec::Stopped {
866 outcomes: mv_failed_pair(
867 &mv,
868 FileClassification::FailedBackup,
869 FileClassification::NotAttempted,
870 ),
871 reason: "failed_backup",
872 };
873 }
874 }
875 };
876
877 let source_backup_id = match ctx.backups.snapshot_with_op(
879 ctx.session,
880 &mv.source_canonical,
881 "hashline: MV source backup",
882 Some(op_id),
883 ) {
884 Ok(Some(id)) => {
885 *journaled = true;
886 Some(id)
887 }
888 Ok(None) | Err(_) => {
889 return StepExec::Stopped {
890 outcomes: mv_failed_pair(
891 &mv,
892 FileClassification::FailedBackup,
893 FileClassification::NotAttempted,
894 ),
895 reason: "failed_backup",
896 };
897 }
898 };
899
900 if fault_is(ctx, ExecuteFault::BaselineDrift { step: step_index })
902 || !baseline_matches(&mv.source_canonical, &mv.source_baseline_bytes)
903 || mv
904 .dest_baseline_bytes
905 .as_ref()
906 .is_some_and(|expected| !baseline_matches(&mv.dest_canonical, expected))
907 {
908 return StepExec::Stopped {
909 outcomes: mv_failed_pair(
910 &mv,
911 FileClassification::FailedBaselineDrift,
912 FileClassification::NotAttempted,
913 ),
914 reason: "hashline_baseline_drift",
915 };
916 }
917
918 if fault_is(ctx, ExecuteFault::Write { step: step_index }) {
919 return StepExec::Stopped {
920 outcomes: mv_failed_pair(
921 &mv,
922 FileClassification::FailedWrite,
923 FileClassification::NotAttempted,
924 ),
925 reason: "failed_write",
926 };
927 }
928
929 if let Err(error) = ensure_parent_dirs(&mv.dest_canonical)
931 .and_then(|_| durable_write(&mv.dest_canonical, &mv.final_bytes))
932 {
933 let classification = if error.to_string().contains("durability") {
934 FileClassification::FailedDurability
935 } else {
936 FileClassification::FailedWrite
937 };
938 return StepExec::Stopped {
939 outcomes: mv_failed_pair(&mv, classification, FileClassification::NotAttempted),
940 reason: classification.as_str(),
941 };
942 }
943
944 if fault_is(ctx, ExecuteFault::Durability { step: step_index }) {
945 return StepExec::Stopped {
946 outcomes: mv_failed_pair(
947 &mv,
948 FileClassification::FailedDurability,
949 FileClassification::NotAttempted,
950 ),
951 reason: "failed_durability",
952 };
953 }
954
955 let dest_on_disk = fs::read(&mv.dest_canonical).unwrap_or_else(|_| mv.final_bytes.clone());
956
957 if fault_is(ctx, ExecuteFault::SourceUnlink { step: step_index })
958 || fs::remove_file(&mv.source_canonical).is_err()
959 {
960 let (final_tag, tag_notice, dest_class) =
962 observe_dest_tag(ctx, &mv, &dest_on_disk, step_index);
963 return StepExec::Stopped {
964 outcomes: vec![
965 FileOutcome {
966 canonical_path: mv.dest_canonical,
967 requested_path: mv.dest_requested,
968 role: FileRole::MvDestination,
969 classification: dest_class,
970 mutation_state: dest_class.mutation_state(),
971 final_bytes: Some(dest_on_disk),
972 final_tag,
973 affected: mv.affected,
974 warnings: mv.warnings,
975 format_skipped_reason: None,
976 backup_id: dest_backup_id,
977 remove_file: false,
978 tag_notice,
979 },
980 FileOutcome {
981 canonical_path: mv.source_canonical,
982 requested_path: mv.source_requested,
983 role: FileRole::MvSource,
984 classification: FileClassification::FailedSourceUnlink,
985 mutation_state: MutationState::PartialMv,
986 final_bytes: Some(mv.source_baseline_bytes),
987 final_tag: None,
988 affected: AffectedRegion::default(),
989 warnings: Vec::new(),
990 format_skipped_reason: None,
991 backup_id: source_backup_id,
992 remove_file: false,
993 tag_notice: Some(
994 "destination written; source unlink failed — partial MV under shared op_id"
995 .into(),
996 ),
997 },
998 ],
999 reason: "failed_source_unlink",
1000 };
1001 }
1002
1003 invalidate_removed_source(ctx.snapshots, &mv.source_canonical);
1004
1005 let (final_tag, tag_notice, dest_class) = observe_dest_tag(ctx, &mv, &dest_on_disk, step_index);
1006
1007 StepExec::Applied(vec![
1008 FileOutcome {
1009 canonical_path: mv.dest_canonical,
1010 requested_path: mv.dest_requested,
1011 role: FileRole::MvDestination,
1012 classification: dest_class,
1013 mutation_state: dest_class.mutation_state(),
1014 final_bytes: Some(dest_on_disk),
1015 final_tag,
1016 affected: mv.affected,
1017 warnings: mv.warnings,
1018 format_skipped_reason: None,
1019 backup_id: dest_backup_id,
1020 remove_file: false,
1021 tag_notice,
1022 },
1023 FileOutcome {
1024 canonical_path: mv.source_canonical,
1025 requested_path: mv.source_requested,
1026 role: FileRole::MvSource,
1027 classification: FileClassification::Applied,
1028 mutation_state: MutationState::Applied,
1029 final_bytes: None,
1030 final_tag: None,
1031 affected: AffectedRegion::default(),
1032 warnings: Vec::new(),
1033 format_skipped_reason: None,
1034 backup_id: source_backup_id,
1035 remove_file: true,
1036 tag_notice: Some("source path removed; no final tag".into()),
1037 },
1038 ])
1039}
1040
1041fn observe_dest_tag(
1042 ctx: &mut ExecuteContext<'_>,
1043 mv: &PlannedMv,
1044 dest_on_disk: &[u8],
1045 step_index: usize,
1046) -> (Option<String>, Option<String>, FileClassification) {
1047 if fault_is(ctx, ExecuteFault::FinalTagUnavailable { step: step_index }) {
1048 return (
1049 None,
1050 Some("final tag unavailable; re-read before chaining".into()),
1051 FileClassification::AppliedTagUnavailable,
1052 );
1053 }
1054 let mut classification = FileClassification::Applied;
1055 if fault_is(ctx, ExecuteFault::ValidationFailure { step: step_index }) {
1056 classification = FileClassification::AppliedWithValidationFailure;
1057 }
1058 let published = publish_edit_response_snapshot(
1059 ctx.snapshots,
1060 &mv.dest_canonical,
1061 mv.dest_requested.clone(),
1062 dest_on_disk,
1063 &mv.affected,
1064 );
1065 tag_from_publish(published, classification)
1066}
1067
1068fn tag_from_publish(
1069 published: EditResponseSnapshot,
1070 classification: FileClassification,
1071) -> (Option<String>, Option<String>, FileClassification) {
1072 if let Some(snapshot) = published.snapshot {
1073 (Some(snapshot.tag.clone()), published.notice, classification)
1074 } else {
1075 (
1076 None,
1077 published
1078 .notice
1079 .or_else(|| Some("final tag unavailable; re-read before chaining".into())),
1080 FileClassification::AppliedTagUnavailable,
1081 )
1082 }
1083}
1084
1085fn journal_existing_or_skip(
1086 ctx: &mut ExecuteContext<'_>,
1087 op_id: &str,
1088 path: &Path,
1089 _remove_file: bool,
1090 description: &str,
1091) -> Result<Option<String>, ()> {
1092 if !path_exists(path) {
1093 return Ok(None);
1095 }
1096 match ctx
1097 .backups
1098 .snapshot_with_op(ctx.session, path, description, Some(op_id))
1099 {
1100 Ok(id) => Ok(id),
1101 Err(_) => Err(()),
1102 }
1103}
1104
1105fn path_exists(path: &Path) -> bool {
1108 fs::symlink_metadata(path).is_ok()
1109}
1110
1111fn baseline_matches(path: &Path, expected: &[u8]) -> bool {
1112 match fs::read(path) {
1113 Ok(bytes) => bytes == expected,
1114 Err(error) if error.kind() == io::ErrorKind::NotFound => expected.is_empty(),
1115 Err(_) => false,
1116 }
1117}
1118
1119fn ensure_parent_dirs(path: &Path) -> io::Result<()> {
1120 if let Some(parent) = path.parent() {
1121 if !parent.as_os_str().is_empty() {
1122 fs::create_dir_all(parent)?;
1123 }
1124 }
1125 Ok(())
1126}
1127
1128fn durable_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
1130 ensure_parent_dirs(path)?;
1131 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1132 let file_name = path
1133 .file_name()
1134 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
1135 let temp_name = {
1136 let mut name = std::ffi::OsString::from(".aft-hashline-");
1137 name.push(file_name);
1138 name.push(".tmp");
1139 name
1140 };
1141 let temp_path = parent.join(temp_name);
1142
1143 let write_result = (|| {
1144 let mut file = OpenOptions::new()
1145 .write(true)
1146 .create(true)
1147 .truncate(true)
1148 .open(&temp_path)?;
1149 file.write_all(bytes)?;
1150 file.sync_all()
1151 .map_err(|error| io::Error::new(error.kind(), format!("durability: {error}")))?;
1152 fs::rename(&temp_path, path)?;
1153 if let Ok(dir) = File::open(parent) {
1155 let _ = dir.sync_all();
1156 }
1157 Ok(())
1158 })();
1159
1160 if write_result.is_err() {
1161 let _ = fs::remove_file(&temp_path);
1162 }
1163 write_result
1164}
1165
1166fn failed_outcome(
1169 path: &Path,
1170 requested: &str,
1171 role: FileRole,
1172 classification: FileClassification,
1173 remove_file: bool,
1174 warnings: Vec<String>,
1175) -> FileOutcome {
1176 FileOutcome {
1177 canonical_path: path.to_path_buf(),
1178 requested_path: requested.to_string(),
1179 role,
1180 classification,
1181 mutation_state: classification.mutation_state(),
1182 final_bytes: None,
1183 final_tag: None,
1184 affected: AffectedRegion::default(),
1185 warnings,
1186 format_skipped_reason: None,
1187 backup_id: None,
1188 remove_file,
1189 tag_notice: None,
1190 }
1191}
1192
1193fn mv_failed_pair(
1194 mv: &PlannedMv,
1195 dest_class: FileClassification,
1196 source_class: FileClassification,
1197) -> Vec<FileOutcome> {
1198 vec![
1199 failed_outcome(
1200 &mv.dest_canonical,
1201 &mv.dest_requested,
1202 FileRole::MvDestination,
1203 dest_class,
1204 false,
1205 mv.warnings.clone(),
1206 ),
1207 failed_outcome(
1208 &mv.source_canonical,
1209 &mv.source_requested,
1210 FileRole::MvSource,
1211 source_class,
1212 false,
1213 Vec::new(),
1214 ),
1215 ]
1216}
1217
1218fn not_attempted_for_step(step: &PlannedStep) -> Vec<FileOutcome> {
1219 match step {
1220 PlannedStep::Mutate(file) => vec![failed_outcome(
1221 &file.canonical_path,
1222 &file.requested_path,
1223 FileRole::Primary,
1224 FileClassification::NotAttempted,
1225 file.remove_file,
1226 Vec::new(),
1227 )],
1228 PlannedStep::Mv(mv) => mv_failed_pair(
1229 mv,
1230 FileClassification::NotAttempted,
1231 FileClassification::NotAttempted,
1232 ),
1233 }
1234}
1235
1236fn fault_is(ctx: &ExecuteContext<'_>, want: ExecuteFault) -> bool {
1237 ctx.fault.as_ref() == Some(&want)
1238}
1239
1240fn counts_toward_completion(file: &FileOutcome) -> bool {
1241 !(file.role == FileRole::MvSource && file.classification.is_applied_star())
1243}
1244
1245fn summary_counts(files: &[FileOutcome]) -> String {
1246 let primary: Vec<_> = files
1247 .iter()
1248 .filter(|file| counts_toward_completion(file))
1249 .collect();
1250 let applied = primary
1251 .iter()
1252 .filter(|file| file.classification.is_applied_star())
1253 .count();
1254 let total = primary.len();
1255 format!("{applied} of {total} files applied")
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260 use super::*;
1261 use crate::backup::BackupPolicy;
1262 use crate::hashline::scan::scan_bytes;
1263 use crate::hashline::snapshot::{capture_taggable_read, ReadPublication, ReadSelection};
1264 use crate::hashline::syntax::{
1265 parse_address, resolve_address, resolve_snapshot, CutOperation, PutOperation, PutSource,
1266 RegisterRef, ResolvedAddress,
1267 };
1268
1269 const SESSION: &str = "hashline-tx-test";
1270
1271 fn whole_snapshot(bytes: &[u8]) -> Snapshot {
1272 scan_bytes(bytes)
1273 }
1274
1275 fn put_text(address: &str, body: &[&str]) -> Operation {
1276 Operation::Put(PutOperation {
1277 address: parse_address(address).unwrap(),
1278 source: PutSource::Text(body.iter().map(|line| (*line).to_string()).collect()),
1279 line: 1,
1280 })
1281 }
1282
1283 fn resolve_one(snapshot: &Snapshot, operation: &Operation) -> ResolvedOperation {
1284 let address = match operation.address() {
1285 Some(address) => resolve_address(address, snapshot).unwrap(),
1286 None => ResolvedAddress::WholeFile,
1287 };
1288 ResolvedOperation {
1289 operation_index: 0,
1290 address,
1291 }
1292 }
1293
1294 fn write_file(path: &Path, bytes: &[u8]) {
1295 if let Some(parent) = path.parent() {
1296 fs::create_dir_all(parent).unwrap();
1297 }
1298 fs::write(path, bytes).unwrap();
1299 }
1300
1301 fn backup_store(dir: &Path) -> BackupStore {
1302 let mut store = BackupStore::new();
1303 store.set_storage_dir(dir.to_path_buf(), 72);
1304 store
1305 }
1306
1307 fn ctx<'a>(
1308 backups: &'a mut BackupStore,
1309 snapshots: &'a mut SnapshotStore,
1310 registers: &'a mut RegisterStore,
1311 backups_enabled: bool,
1312 fault: Option<ExecuteFault>,
1313 ) -> ExecuteContext<'a> {
1314 ExecuteContext {
1315 session: SESSION,
1316 backups,
1317 snapshots,
1318 registers,
1319 backups_enabled,
1320 fault,
1321 }
1322 }
1323
1324 fn section_put<'a>(
1325 path: &'a Path,
1326 requested: &'a str,
1327 baseline: &'a Baseline,
1328 snapshot: &'a Snapshot,
1329 ops: &'a [Operation],
1330 resolved: &'a [ResolvedOperation],
1331 ) -> TransactionSectionInput<'a> {
1332 TransactionSectionInput {
1333 canonical_path: path,
1334 requested_path: requested,
1335 baseline,
1336 snapshot,
1337 operations: ops,
1338 resolved,
1339 mv_destination: None,
1340 }
1341 }
1342
1343 fn put_after_reads(
1344 selections: impl IntoIterator<Item = ReadSelection>,
1345 ) -> Result<Vec<u8>, HashlineRejection> {
1346 let temp = tempfile::tempdir().unwrap();
1347 let path = temp.path().join("reread.txt");
1348 let original = b"one\ntwo\nthree\nfour\n";
1349 write_file(&path, original);
1350
1351 let mut snapshots = SnapshotStore::new();
1352 let mut tag = None;
1353 for selection in selections {
1354 let publication =
1355 capture_taggable_read(&mut snapshots, &path, "reread.txt", selection).unwrap();
1356 let ReadPublication::Tagged { snapshot, .. } = publication else {
1357 panic!("fixture read must publish a tagged snapshot");
1358 };
1359 tag.get_or_insert(snapshot.tag);
1360 }
1361
1362 let snapshot = resolve_snapshot(
1363 &mut snapshots,
1364 &path,
1365 tag.as_deref().expect("at least one read selection"),
1366 )?;
1367 let baseline = Baseline::from_bytes(original.to_vec());
1368 let operations = vec![put_text("2", &["TWO"])];
1369 let resolved = vec![resolve_one(&snapshot, &operations[0])];
1370 let sections = [section_put(
1371 &path,
1372 "reread.txt",
1373 &baseline,
1374 &snapshot,
1375 &operations,
1376 &resolved,
1377 )];
1378 let session_registers = RegisterStore::new();
1379 let plan = plan_transaction(§ions, &session_registers, true)?;
1380 let mut backups = backup_store(&temp.path().join("backups"));
1381 let mut execution_registers = RegisterStore::new();
1382 let mut execution = ctx(
1383 &mut backups,
1384 &mut snapshots,
1385 &mut execution_registers,
1386 true,
1387 None,
1388 );
1389 let envelope = execute_transaction(plan, &mut execution);
1390 assert!(envelope.success);
1391 assert!(envelope.complete);
1392 Ok(fs::read(path).unwrap())
1393 }
1394
1395 #[test]
1396 fn two_ranged_reads_of_one_version_then_put_applies() {
1397 let bytes = put_after_reads([ReadSelection::range(1, 2), ReadSelection::range(3, 4)])
1398 .expect("same-version ranged reads must resolve");
1399 assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1400 }
1401
1402 #[test]
1403 fn ranged_then_whole_read_of_one_version_then_put_applies() {
1404 let bytes = put_after_reads([ReadSelection::range(2, 2), ReadSelection::WholeFile])
1405 .expect("same-version ranged and whole reads must resolve");
1406 assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1407 }
1408
1409 #[test]
1410 fn second_read_without_intervening_mutation_does_not_enter_refusal_loop() {
1411 let bytes = put_after_reads([ReadSelection::range(1, 1), ReadSelection::range(2, 2)])
1412 .expect("a second read of unchanged content must leave the tag editable");
1413 assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1414 }
1415
1416 #[test]
1418 fn a8_phase1_mutation_free_and_phase2_ordered() {
1419 let temp = tempfile::tempdir().unwrap();
1420 let a = temp.path().join("a.txt");
1421 let b = temp.path().join("b.txt");
1422 write_file(&a, b"alpha\n");
1423 write_file(&b, b"beta\n");
1424 let bytes_a = fs::read(&a).unwrap();
1425 let bytes_b = fs::read(&b).unwrap();
1426 let snap_a = whole_snapshot(&bytes_a);
1427 let snap_b = whole_snapshot(&bytes_b);
1428 let base_a = Baseline::from_bytes(bytes_a.clone());
1429 let base_b = Baseline::from_bytes(bytes_b.clone());
1430 let ops_a = vec![put_text("1", &["ALPHA"])];
1431 let ops_b = vec![put_text("1", &["BETA"])];
1432 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1433 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1434 let sections = [
1435 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1436 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1437 ];
1438 let registers = RegisterStore::new();
1439 let plan = plan_transaction(§ions, ®isters, true).expect("phase1");
1440 assert_eq!(fs::read(&a).unwrap(), b"alpha\n");
1442 assert_eq!(fs::read(&b).unwrap(), b"beta\n");
1443 assert_eq!(plan.steps.len(), 2);
1444
1445 let backup_dir = temp.path().join("backups");
1446 let mut backups = backup_store(&backup_dir);
1447 let mut snapshots = SnapshotStore::new();
1448 let mut session_regs = RegisterStore::new();
1449 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1450 let envelope = execute_transaction(plan, &mut exec);
1451 assert!(envelope.success);
1452 assert!(envelope.complete);
1453 assert!(envelope.op_id.is_some());
1454 assert_eq!(envelope.files.len(), 2);
1455 assert_eq!(envelope.files[0].requested_path, "a.txt");
1456 assert_eq!(envelope.files[1].requested_path, "b.txt");
1457 assert_eq!(
1458 envelope.files[0].classification,
1459 FileClassification::Applied
1460 );
1461 assert_eq!(envelope.files[0].mutation_state, MutationState::Applied);
1462 assert_eq!(fs::read(&a).unwrap(), b"ALPHA\n");
1463 assert_eq!(fs::read(&b).unwrap(), b"BETA\n");
1464 assert!(envelope.summary_text.contains("2 of 2 files applied"));
1465
1466 let op_id = envelope.op_id.clone().unwrap();
1468 let restored = backups.restore_last_operation(SESSION).unwrap();
1469 assert_eq!(restored.op_id, op_id);
1470 assert_eq!(fs::read(&a).unwrap(), b"alpha\n");
1471 assert_eq!(fs::read(&b).unwrap(), b"beta\n");
1472 }
1473
1474 #[test]
1476 fn a8_all_failed_emits_success_false_with_envelope() {
1477 let temp = tempfile::tempdir().unwrap();
1478 let a = temp.path().join("a.txt");
1479 let b = temp.path().join("b.txt");
1480 write_file(&a, b"a\n");
1481 write_file(&b, b"b\n");
1482 let bytes_a = fs::read(&a).unwrap();
1483 let bytes_b = fs::read(&b).unwrap();
1484 let snap_a = whole_snapshot(&bytes_a);
1485 let snap_b = whole_snapshot(&bytes_b);
1486 let base_a = Baseline::from_bytes(bytes_a);
1487 let base_b = Baseline::from_bytes(bytes_b);
1488 let ops_a = vec![put_text("1", &["A"])];
1489 let ops_b = vec![put_text("1", &["B"])];
1490 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1491 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1492 let sections = [
1493 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1494 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1495 ];
1496 let registers = RegisterStore::new();
1497 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1498
1499 let mut backups = backup_store(&temp.path().join("backups"));
1500 let mut snapshots = SnapshotStore::new();
1501 let mut session_regs = RegisterStore::new();
1502 let mut exec = ctx(
1503 &mut backups,
1504 &mut snapshots,
1505 &mut session_regs,
1506 true,
1507 Some(ExecuteFault::BaselineDrift { step: 0 }),
1508 );
1509 let envelope = execute_transaction(plan, &mut exec);
1510 assert!(!envelope.success);
1511 assert!(!envelope.complete);
1512 assert_eq!(
1513 envelope.files[0].classification,
1514 FileClassification::FailedBaselineDrift
1515 );
1516 assert_eq!(envelope.files[0].mutation_state, MutationState::Unmutated);
1517 assert_eq!(
1518 envelope.files[1].classification,
1519 FileClassification::NotAttempted
1520 );
1521 assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
1522 assert_eq!(envelope.stop_reason, Some("hashline_baseline_drift"));
1523 assert!(envelope.summary_text.starts_with("0 of 2 files applied"));
1524 assert!(envelope.op_id.is_some());
1527 assert_eq!(fs::read(&a).unwrap(), b"a\n");
1528 assert_eq!(fs::read(&b).unwrap(), b"b\n");
1529 }
1530
1531 #[test]
1533 fn a8_partial_failure_keeps_prior_under_shared_op_id() {
1534 let temp = tempfile::tempdir().unwrap();
1535 let a = temp.path().join("a.txt");
1536 let b = temp.path().join("b.txt");
1537 write_file(&a, b"a\n");
1538 write_file(&b, b"b\n");
1539 let bytes_a = fs::read(&a).unwrap();
1540 let bytes_b = fs::read(&b).unwrap();
1541 let snap_a = whole_snapshot(&bytes_a);
1542 let snap_b = whole_snapshot(&bytes_b);
1543 let base_a = Baseline::from_bytes(bytes_a);
1544 let base_b = Baseline::from_bytes(bytes_b);
1545 let ops_a = vec![put_text("1", &["A"])];
1546 let ops_b = vec![put_text("1", &["B"])];
1547 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1548 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1549 let sections = [
1550 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1551 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1552 ];
1553 let registers = RegisterStore::new();
1554 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1555
1556 let mut backups = backup_store(&temp.path().join("backups"));
1557 let mut snapshots = SnapshotStore::new();
1558 let mut session_regs = RegisterStore::new();
1559 let mut exec = ctx(
1560 &mut backups,
1561 &mut snapshots,
1562 &mut session_regs,
1563 true,
1564 Some(ExecuteFault::Write { step: 1 }),
1565 );
1566 let envelope = execute_transaction(plan, &mut exec);
1567 assert!(envelope.success);
1568 assert!(!envelope.complete);
1569 assert!(envelope.op_id.is_some());
1570 assert_eq!(
1571 envelope.files[0].classification,
1572 FileClassification::Applied
1573 );
1574 assert_eq!(
1575 envelope.files[1].classification,
1576 FileClassification::FailedWrite
1577 );
1578 assert_eq!(
1579 envelope.files[1].mutation_state,
1580 MutationState::UnknownPossiblyMutated
1581 );
1582 assert_eq!(fs::read(&a).unwrap(), b"A\n");
1583 assert_eq!(fs::read(&b).unwrap(), b"b\n");
1584
1585 let op_id = envelope.op_id.unwrap();
1586 let restored = backups.restore_last_operation(SESSION).unwrap();
1587 assert_eq!(restored.op_id, op_id);
1588 assert_eq!(fs::read(&a).unwrap(), b"a\n");
1589 }
1590
1591 #[test]
1593 fn a8_mv_new_and_existing_destination_with_undo() {
1594 let temp = tempfile::tempdir().unwrap();
1595 let src = temp.path().join("src.txt");
1596 let new_dest = temp.path().join("new_dest.txt");
1597 write_file(&src, b"move-me\n");
1598 let bytes = fs::read(&src).unwrap();
1599 let snap = whole_snapshot(&bytes);
1600 let base = Baseline::from_bytes(bytes.clone());
1601 let ops = vec![Operation::Mv(MvOperation {
1602 destination: "new_dest.txt".into(),
1603 line: 1,
1604 })];
1605 let resolved = vec![ResolvedOperation {
1607 operation_index: 0,
1608 address: ResolvedAddress::WholeFile,
1609 }];
1610 let sections = [TransactionSectionInput {
1611 canonical_path: &src,
1612 requested_path: "src.txt",
1613 baseline: &base,
1614 snapshot: &snap,
1615 operations: &ops,
1616 resolved: &resolved,
1617 mv_destination: Some(MvDestinationInput {
1618 canonical_path: &new_dest,
1619 requested_path: "new_dest.txt",
1620 baseline_bytes: None,
1621 }),
1622 }];
1623 let registers = RegisterStore::new();
1624 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1625 let mut backups = backup_store(&temp.path().join("backups"));
1626 let mut snapshots = SnapshotStore::new();
1627 snapshots.publish(&src, snap.clone());
1629 let mut session_regs = RegisterStore::new();
1630 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1631 let envelope = execute_transaction(plan, &mut exec);
1632 assert!(envelope.success && envelope.complete);
1633 assert!(envelope.op_id.is_some());
1634 assert_eq!(envelope.files[0].role, FileRole::MvDestination);
1635 assert_eq!(envelope.files[1].role, FileRole::MvSource);
1636 assert!(envelope.files[0].final_tag.is_some());
1637 assert!(envelope.files[1].remove_file);
1638 assert_eq!(fs::read(&new_dest).unwrap(), b"move-me\n");
1639 assert!(!src.exists());
1640 assert!(snapshots.lookup(&src, &snap.tag).is_err());
1642
1643 let op_id = envelope.op_id.unwrap();
1644 let restored = backups.restore_last_operation(SESSION).unwrap();
1645 assert_eq!(restored.op_id, op_id);
1646 assert_eq!(fs::read(&src).unwrap(), b"move-me\n");
1647 assert!(!new_dest.exists(), "created destination removed on undo");
1648
1649 let src2 = temp.path().join("src2.txt");
1651 let dest2 = temp.path().join("dest2.txt");
1652 write_file(&src2, b"from\n");
1653 write_file(&dest2, b"old-dest\n");
1654 let bytes2 = fs::read(&src2).unwrap();
1655 let snap2 = whole_snapshot(&bytes2);
1656 let base2 = Baseline::from_bytes(bytes2);
1657 let dest_bytes = fs::read(&dest2).unwrap();
1658 let ops2 = vec![Operation::Mv(MvOperation {
1659 destination: "dest2.txt".into(),
1660 line: 1,
1661 })];
1662 let resolved2 = vec![ResolvedOperation {
1663 operation_index: 0,
1664 address: ResolvedAddress::WholeFile,
1665 }];
1666 let sections2 = [TransactionSectionInput {
1667 canonical_path: &src2,
1668 requested_path: "src2.txt",
1669 baseline: &base2,
1670 snapshot: &snap2,
1671 operations: &ops2,
1672 resolved: &resolved2,
1673 mv_destination: Some(MvDestinationInput {
1674 canonical_path: &dest2,
1675 requested_path: "dest2.txt",
1676 baseline_bytes: Some(&dest_bytes),
1677 }),
1678 }];
1679 let plan2 = plan_transaction(§ions2, ®isters, true).unwrap();
1680 let mut exec2 = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1681 let envelope2 = execute_transaction(plan2, &mut exec2);
1682 assert!(envelope2.success);
1683 assert_eq!(fs::read(&dest2).unwrap(), b"from\n");
1684 assert!(!src2.exists());
1685 let restored2 = backups.restore_last_operation(SESSION).unwrap();
1686 assert_eq!(restored2.op_id, envelope2.op_id.unwrap());
1687 assert_eq!(fs::read(&src2).unwrap(), b"from\n");
1688 assert_eq!(fs::read(&dest2).unwrap(), b"old-dest\n");
1689 }
1690
1691 #[test]
1693 fn a8_mv_source_unlink_failure_is_partial_mv() {
1694 let temp = tempfile::tempdir().unwrap();
1695 let src = temp.path().join("src.txt");
1696 let dest = temp.path().join("dest.txt");
1697 write_file(&src, b"body\n");
1698 let bytes = fs::read(&src).unwrap();
1699 let snap = whole_snapshot(&bytes);
1700 let base = Baseline::from_bytes(bytes);
1701 let ops = vec![Operation::Mv(MvOperation {
1702 destination: "dest.txt".into(),
1703 line: 1,
1704 })];
1705 let resolved = vec![ResolvedOperation {
1706 operation_index: 0,
1707 address: ResolvedAddress::WholeFile,
1708 }];
1709 let sections = [TransactionSectionInput {
1710 canonical_path: &src,
1711 requested_path: "src.txt",
1712 baseline: &base,
1713 snapshot: &snap,
1714 operations: &ops,
1715 resolved: &resolved,
1716 mv_destination: Some(MvDestinationInput {
1717 canonical_path: &dest,
1718 requested_path: "dest.txt",
1719 baseline_bytes: None,
1720 }),
1721 }];
1722 let registers = RegisterStore::new();
1723 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1724 let mut backups = backup_store(&temp.path().join("backups"));
1725 let mut snapshots = SnapshotStore::new();
1726 let mut session_regs = RegisterStore::new();
1727 let mut exec = ctx(
1728 &mut backups,
1729 &mut snapshots,
1730 &mut session_regs,
1731 true,
1732 Some(ExecuteFault::SourceUnlink { step: 0 }),
1733 );
1734 let envelope = execute_transaction(plan, &mut exec);
1735 assert!(envelope.success);
1736 assert!(!envelope.complete);
1737 assert!(envelope.op_id.is_some());
1738 assert_eq!(
1739 envelope.files[0].classification,
1740 FileClassification::Applied
1741 );
1742 assert_eq!(
1743 envelope.files[1].classification,
1744 FileClassification::FailedSourceUnlink
1745 );
1746 assert_eq!(envelope.files[1].mutation_state, MutationState::PartialMv);
1747 assert_eq!(fs::read(&dest).unwrap(), b"body\n");
1748 assert!(src.exists(), "source remains after unlink failure");
1749 }
1750
1751 #[test]
1753 fn a8_register_commit_only_when_all_applied() {
1754 let temp = tempfile::tempdir().unwrap();
1755 let a = temp.path().join("a.txt");
1756 write_file(&a, b"one\ntwo\n");
1757 let bytes = fs::read(&a).unwrap();
1758 let snap = whole_snapshot(&bytes);
1759 let base = Baseline::from_bytes(bytes);
1760 let ops = vec![Operation::Cut(crate::hashline::syntax::CutOperation {
1761 address: parse_address("1").unwrap(),
1762 register: Some(RegisterRef::Named("clip".into())),
1763 line: 1,
1764 })];
1765 let resolved = vec![resolve_one(&snap, &ops[0])];
1766 let sections = [section_put(&a, "a.txt", &base, &snap, &ops, &resolved)];
1767 let registers = RegisterStore::new();
1768 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1769
1770 let mut backups = backup_store(&temp.path().join("backups"));
1771 let mut snapshots = SnapshotStore::new();
1772 let mut session_regs = RegisterStore::new();
1773 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1774 let envelope = execute_transaction(plan, &mut exec);
1775 assert!(envelope.registers_committed);
1776 assert_eq!(
1777 session_regs.get(&RegisterRef::Named("clip".into())),
1778 Some(["one".to_string()].as_slice())
1779 );
1780
1781 write_file(&a, b"one\ntwo\n");
1783 let plan2 = plan_transaction(§ions, &RegisterStore::new(), true).unwrap();
1784 let mut session_regs2 = RegisterStore::new();
1785 let mut exec2 = ctx(
1786 &mut backups,
1787 &mut snapshots,
1788 &mut session_regs2,
1789 true,
1790 Some(ExecuteFault::Write { step: 0 }),
1791 );
1792 let envelope2 = execute_transaction(plan2, &mut exec2);
1793 assert!(!envelope2.registers_committed);
1794 assert!(session_regs2
1795 .get(&RegisterRef::Named("clip".into()))
1796 .is_none());
1797 }
1798
1799 #[test]
1801 fn a8_applied_star_variants() {
1802 let temp = tempfile::tempdir().unwrap();
1803 let path = temp.path().join("v.txt");
1804 write_file(&path, b"x\n");
1805 let bytes = fs::read(&path).unwrap();
1806 let snap = whole_snapshot(&bytes);
1807 let base = Baseline::from_bytes(bytes);
1808 let ops = vec![put_text("1", &["Y"])];
1809 let resolved = vec![resolve_one(&snap, &ops[0])];
1810 let sections = [section_put(&path, "v.txt", &base, &snap, &ops, &resolved)];
1811 let registers = RegisterStore::new();
1812
1813 let mut backups = backup_store(&temp.path().join("backups"));
1814 let mut snapshots = SnapshotStore::new();
1815 let mut session_regs = RegisterStore::new();
1816 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1817 let mut exec = ctx(
1818 &mut backups,
1819 &mut snapshots,
1820 &mut session_regs,
1821 true,
1822 Some(ExecuteFault::ValidationFailure { step: 0 }),
1823 );
1824 let envelope = execute_transaction(plan, &mut exec);
1825 assert_eq!(
1826 envelope.files[0].classification,
1827 FileClassification::AppliedWithValidationFailure
1828 );
1829 assert_eq!(fs::read(&path).unwrap(), b"Y\n");
1830
1831 write_file(&path, b"x\n");
1832 let bytes = fs::read(&path).unwrap();
1833 let snap = whole_snapshot(&bytes);
1834 let base = Baseline::from_bytes(bytes);
1835 let sections = [section_put(&path, "v.txt", &base, &snap, &ops, &resolved)];
1836 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1837 let mut exec = ctx(
1838 &mut backups,
1839 &mut snapshots,
1840 &mut session_regs,
1841 true,
1842 Some(ExecuteFault::FinalTagUnavailable { step: 0 }),
1843 );
1844 let envelope = execute_transaction(plan, &mut exec);
1845 assert_eq!(
1846 envelope.files[0].classification,
1847 FileClassification::AppliedTagUnavailable
1848 );
1849 assert!(envelope.files[0].final_tag.is_none());
1850 assert!(envelope.files[0].tag_notice.is_some());
1851 }
1852
1853 #[test]
1855 fn a10_preview_mutates_nothing() {
1856 let temp = tempfile::tempdir().unwrap();
1857 let path = temp.path().join("p.txt");
1858 let dest = temp.path().join("p-dest.txt");
1859 write_file(&path, b"preview\n");
1860 let bytes = fs::read(&path).unwrap();
1861 let snap = whole_snapshot(&bytes);
1862 let base = Baseline::from_bytes(bytes.clone());
1863 let ops = vec![
1864 put_text("1", &["PREVIEWED"]),
1865 Operation::Mv(MvOperation {
1866 destination: "p-dest.txt".into(),
1867 line: 2,
1868 }),
1869 ];
1870 let resolved = vec![
1871 resolve_one(&snap, &ops[0]),
1872 ResolvedOperation {
1873 operation_index: 1,
1874 address: ResolvedAddress::WholeFile,
1875 },
1876 ];
1877 let sections = [TransactionSectionInput {
1878 canonical_path: &path,
1879 requested_path: "p.txt",
1880 baseline: &base,
1881 snapshot: &snap,
1882 operations: &ops,
1883 resolved: &resolved,
1884 mv_destination: Some(MvDestinationInput {
1885 canonical_path: &dest,
1886 requested_path: "p-dest.txt",
1887 baseline_bytes: None,
1888 }),
1889 }];
1890 let mut registers = RegisterStore::new();
1891 {
1893 let mut staged = registers.stage();
1894 staged
1895 .capture(RegisterRef::Named("keep".into()), vec!["seed".into()])
1896 .unwrap();
1897 registers.commit(staged);
1898 }
1899 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1900 let before_reg = registers
1901 .get(&RegisterRef::Named("keep".into()))
1902 .map(|lines| lines.to_vec());
1903
1904 let backups = backup_store(&temp.path().join("backups"));
1905 let tracked_before = backups.tracked_files(SESSION);
1906 let mut snapshots = SnapshotStore::new();
1907 snapshots.publish(&path, snap.clone());
1908 let envelope = preview_transaction(plan);
1909
1910 assert!(envelope.preview);
1911 assert!(envelope.op_id.is_none());
1912 assert!(!envelope.registers_committed);
1913 assert_eq!(fs::read(&path).unwrap(), b"preview\n");
1914 assert!(!dest.exists());
1915 assert_eq!(backups.tracked_files(SESSION), tracked_before);
1916 assert!(snapshots.lookup(&path, &snap.tag).is_ok());
1917 assert_eq!(
1918 registers
1919 .get(&RegisterRef::Named("keep".into()))
1920 .map(|lines| lines.to_vec()),
1921 before_reg
1922 );
1923 assert!(envelope.files.iter().all(|f| f.final_tag.is_none()));
1924 assert!(envelope
1925 .files
1926 .iter()
1927 .all(|f| f.mutation_state == MutationState::Unmutated));
1928 }
1929
1930 #[test]
1931 fn mixed_patch_keeps_distinct_file_independent_and_same_path_pair_atomic() {
1932 let temp = tempfile::tempdir().unwrap();
1933 let distinct = temp.path().join("distinct.txt");
1934 let composed = temp.path().join("composed.txt");
1935 write_file(&distinct, b"distinct\n");
1936 write_file(&composed, b"one\ntwo\nthree\n");
1937 let distinct_bytes = fs::read(&distinct).unwrap();
1938 let composed_bytes = fs::read(&composed).unwrap();
1939 let distinct_snapshot = whole_snapshot(&distinct_bytes);
1940 let composed_snapshot = whole_snapshot(&composed_bytes);
1941 let distinct_baseline = Baseline::from_bytes(distinct_bytes);
1942 let composed_baseline = Baseline::from_bytes(composed_bytes);
1943 let distinct_ops = vec![put_text("1", &["changed"])];
1944 let first_cut = vec![Operation::Cut(CutOperation {
1945 address: parse_address("1").unwrap(),
1946 register: None,
1947 line: 2,
1948 })];
1949 let last_cut = vec![Operation::Cut(CutOperation {
1950 address: parse_address("3").unwrap(),
1951 register: None,
1952 line: 4,
1953 })];
1954 let distinct_resolved = vec![resolve_one(&distinct_snapshot, &distinct_ops[0])];
1955 let first_resolved = vec![resolve_one(&composed_snapshot, &first_cut[0])];
1956 let last_resolved = vec![resolve_one(&composed_snapshot, &last_cut[0])];
1957 let sections = [
1958 section_put(
1959 &distinct,
1960 "distinct.txt",
1961 &distinct_baseline,
1962 &distinct_snapshot,
1963 &distinct_ops,
1964 &distinct_resolved,
1965 ),
1966 section_put(
1967 &composed,
1968 "composed.txt",
1969 &composed_baseline,
1970 &composed_snapshot,
1971 &first_cut,
1972 &first_resolved,
1973 ),
1974 section_put(
1975 &composed,
1976 "composed.txt",
1977 &composed_baseline,
1978 &composed_snapshot,
1979 &last_cut,
1980 &last_resolved,
1981 ),
1982 ];
1983 let registers = RegisterStore::new();
1984 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1985 assert_eq!(plan.steps.len(), 2);
1986
1987 let mut backups = backup_store(&temp.path().join("backups"));
1988 let mut snapshots = SnapshotStore::new();
1989 let mut session_regs = RegisterStore::new();
1990 let mut exec = ctx(
1991 &mut backups,
1992 &mut snapshots,
1993 &mut session_regs,
1994 true,
1995 Some(ExecuteFault::BaselineDrift { step: 1 }),
1996 );
1997 let envelope = execute_transaction(plan, &mut exec);
1998
1999 assert!(envelope.success);
2000 assert!(!envelope.complete);
2001 assert_eq!(envelope.summary_text, "1 of 2 files applied");
2002 assert_eq!(envelope.files.len(), 2);
2003 assert_eq!(
2004 envelope.files[0].classification,
2005 FileClassification::Applied
2006 );
2007 assert_eq!(
2008 envelope.files[1].classification,
2009 FileClassification::FailedBaselineDrift
2010 );
2011 assert_eq!(fs::read(&distinct).unwrap(), b"changed\n");
2012 assert_eq!(fs::read(&composed).unwrap(), b"one\ntwo\nthree\n");
2013 }
2014
2015 #[test]
2017 fn a12_baseline_drift_stops_later_files_and_keeps_prior_op_id() {
2018 let temp = tempfile::tempdir().unwrap();
2019 let a = temp.path().join("a.txt");
2020 let b = temp.path().join("b.txt");
2021 write_file(&a, b"a0\n");
2022 write_file(&b, b"b0\n");
2023 let bytes_a = fs::read(&a).unwrap();
2024 let bytes_b = fs::read(&b).unwrap();
2025 let snap_a = whole_snapshot(&bytes_a);
2026 let snap_b = whole_snapshot(&bytes_b);
2027 let base_a = Baseline::from_bytes(bytes_a);
2028 let base_b = Baseline::from_bytes(bytes_b);
2029 let ops_a = vec![put_text("1", &["A1"])];
2030 let ops_b = vec![put_text("1", &["B1"])];
2031 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
2032 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
2033 let sections = [
2034 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
2035 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
2036 ];
2037 let registers = RegisterStore::new();
2038 let plan = plan_transaction(§ions, ®isters, true).unwrap();
2039
2040 write_file(&b, b"b-EXTERNAL\n");
2042
2043 let mut backups = backup_store(&temp.path().join("backups"));
2044 let mut snapshots = SnapshotStore::new();
2045 let mut session_regs = RegisterStore::new();
2046 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
2047 let envelope = execute_transaction(plan, &mut exec);
2048
2049 assert!(envelope.success);
2050 assert!(!envelope.complete);
2051 assert_eq!(
2052 envelope.files[0].classification,
2053 FileClassification::Applied
2054 );
2055 assert_eq!(
2056 envelope.files[1].classification,
2057 FileClassification::FailedBaselineDrift
2058 );
2059 assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
2060 assert_eq!(envelope.stop_reason, Some("hashline_baseline_drift"));
2061 assert!(envelope.op_id.is_some());
2062 assert_eq!(fs::read(&a).unwrap(), b"A1\n");
2063 assert_eq!(fs::read(&b).unwrap(), b"b-EXTERNAL\n");
2064
2065 let op_id = envelope.op_id.unwrap();
2066 let restored = backups.restore_last_operation(SESSION).unwrap();
2067 assert_eq!(restored.op_id, op_id);
2068 assert_eq!(fs::read(&a).unwrap(), b"a0\n");
2069 }
2070
2071 #[test]
2073 fn a17_backup_unavailable_refusals_and_new_dest_mv() {
2074 let temp = tempfile::tempdir().unwrap();
2075 let path = temp.path().join("t.txt");
2076 write_file(&path, b"t\n");
2077 let bytes = fs::read(&path).unwrap();
2078 let snap = whole_snapshot(&bytes);
2079 let base = Baseline::from_bytes(bytes);
2080 let ops = vec![put_text("1", &["T"])];
2081 let resolved = vec![resolve_one(&snap, &ops[0])];
2082 let sections = [section_put(&path, "t.txt", &base, &snap, &ops, &resolved)];
2083 let registers = RegisterStore::new();
2084 let err = plan_transaction(§ions, ®isters, false).unwrap_err();
2085 assert_eq!(
2086 err.code,
2087 crate::hashline::syntax::HashlineRejectionCode::BackupUnavailable
2088 );
2089 assert_eq!(err.stage, crate::hashline::syntax::RejectionStage::Baseline);
2090 assert_eq!(fs::read(&path).unwrap(), b"t\n");
2091
2092 let src = temp.path().join("s.txt");
2094 let dest = temp.path().join("d.txt");
2095 write_file(&src, b"s\n");
2096 write_file(&dest, b"d\n");
2097 let s_bytes = fs::read(&src).unwrap();
2098 let d_bytes = fs::read(&dest).unwrap();
2099 let s_snap = whole_snapshot(&s_bytes);
2100 let s_base = Baseline::from_bytes(s_bytes);
2101 let mv_ops = vec![Operation::Mv(MvOperation {
2102 destination: "d.txt".into(),
2103 line: 1,
2104 })];
2105 let mv_resolved = vec![ResolvedOperation {
2106 operation_index: 0,
2107 address: ResolvedAddress::WholeFile,
2108 }];
2109 let mv_sections = [TransactionSectionInput {
2110 canonical_path: &src,
2111 requested_path: "s.txt",
2112 baseline: &s_base,
2113 snapshot: &s_snap,
2114 operations: &mv_ops,
2115 resolved: &mv_resolved,
2116 mv_destination: Some(MvDestinationInput {
2117 canonical_path: &dest,
2118 requested_path: "d.txt",
2119 baseline_bytes: Some(&d_bytes),
2120 }),
2121 }];
2122 let err = plan_transaction(&mv_sections, ®isters, false).unwrap_err();
2123 assert_eq!(
2124 err.code,
2125 crate::hashline::syntax::HashlineRejectionCode::BackupUnavailable
2126 );
2127 assert_eq!(fs::read(&src).unwrap(), b"s\n");
2128 assert_eq!(fs::read(&dest).unwrap(), b"d\n");
2129
2130 let src2 = temp.path().join("s2.txt");
2133 let dest2 = temp.path().join("d2.txt");
2134 write_file(&src2, b"s2\n");
2135 let s2_bytes = fs::read(&src2).unwrap();
2136 let s2_snap = whole_snapshot(&s2_bytes);
2137 let s2_base = Baseline::from_bytes(s2_bytes);
2138 let mv2_ops = vec![Operation::Mv(MvOperation {
2139 destination: "d2.txt".into(),
2140 line: 1,
2141 })];
2142 let mv2_resolved = vec![ResolvedOperation {
2143 operation_index: 0,
2144 address: ResolvedAddress::WholeFile,
2145 }];
2146 let mv2_sections = [TransactionSectionInput {
2147 canonical_path: &src2,
2148 requested_path: "s2.txt",
2149 baseline: &s2_base,
2150 snapshot: &s2_snap,
2151 operations: &mv2_ops,
2152 resolved: &mv2_resolved,
2153 mv_destination: Some(MvDestinationInput {
2154 canonical_path: &dest2,
2155 requested_path: "d2.txt",
2156 baseline_bytes: None,
2157 }),
2158 }];
2159 let plan = plan_transaction(&mv2_sections, ®isters, false).expect("new dest MV plans");
2160 let mut backups = backup_store(&temp.path().join("backups"));
2161 let mut snapshots = SnapshotStore::new();
2162 let mut session_regs = RegisterStore::new();
2163 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, false, None);
2166 let envelope = execute_transaction(plan, &mut exec);
2167 assert!(envelope.success);
2168 assert!(envelope.op_id.is_some(), "real journaled op_id required");
2169 assert_eq!(fs::read(&dest2).unwrap(), b"s2\n");
2170 assert!(!src2.exists());
2171 let op_id = envelope.op_id.unwrap();
2172 let restored = backups.restore_last_operation(SESSION).unwrap();
2173 assert_eq!(restored.op_id, op_id);
2174 assert_eq!(fs::read(&src2).unwrap(), b"s2\n");
2175 assert!(!dest2.exists());
2176
2177 let mut disabled = BackupStore::new();
2179 disabled.set_policy(BackupPolicy {
2180 enabled: false,
2181 ..BackupPolicy::default()
2182 });
2183 write_file(&src2, b"s2\n");
2184 let plan = plan_transaction(&mv2_sections, ®isters, false).unwrap();
2187 let mut snapshots = SnapshotStore::new();
2188 let mut session_regs = RegisterStore::new();
2189 let mut exec = ctx(
2190 &mut disabled,
2191 &mut snapshots,
2192 &mut session_regs,
2193 false,
2194 None,
2195 );
2196 let envelope = execute_transaction(plan, &mut exec);
2197 assert!(!envelope.success);
2198 assert!(envelope.op_id.is_none());
2199 assert_eq!(
2200 envelope.files[0].classification,
2201 FileClassification::FailedBackup
2202 );
2203 }
2204
2205 #[test]
2207 fn op_id_present_when_journal_entry_exists_before_failure() {
2208 let temp = tempfile::tempdir().unwrap();
2209 let a = temp.path().join("a.txt");
2210 write_file(&a, b"a\n");
2211 let bytes = fs::read(&a).unwrap();
2212 let snap = whole_snapshot(&bytes);
2213 let base = Baseline::from_bytes(bytes);
2214 let ops = vec![put_text("1", &["A"])];
2215 let resolved = vec![resolve_one(&snap, &ops[0])];
2216 let sections = [section_put(&a, "a.txt", &base, &snap, &ops, &resolved)];
2217 let registers = RegisterStore::new();
2218 let plan = plan_transaction(§ions, ®isters, true).unwrap();
2219 let mut backups = backup_store(&temp.path().join("backups"));
2220 let mut snapshots = SnapshotStore::new();
2221 let mut session_regs = RegisterStore::new();
2222 write_file(&a, b"changed\n");
2225 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
2226 let envelope = execute_transaction(plan, &mut exec);
2227 assert!(!envelope.success);
2228 assert_eq!(
2229 envelope.files[0].classification,
2230 FileClassification::FailedBaselineDrift
2231 );
2232 assert!(envelope.op_id.is_some());
2234 assert_eq!(fs::read(&a).unwrap(), b"changed\n");
2235 }
2236}