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 failed_transaction_preserves_baseline_for_reread_then_edit() {
1397 let temp = tempfile::tempdir().unwrap();
1398 let path = temp.path().join("after-failure.py");
1399 let original = (1..=130)
1400 .map(|line| format!("line_{line} = {line}\n"))
1401 .collect::<String>();
1402 write_file(&path, original.as_bytes());
1403
1404 let mut snapshots = SnapshotStore::new();
1405 let publication = capture_taggable_read(
1406 &mut snapshots,
1407 &path,
1408 "after-failure.py",
1409 ReadSelection::WholeFile,
1410 )
1411 .unwrap();
1412 let ReadPublication::Tagged { snapshot, .. } = publication else {
1413 panic!("fixture read must publish a tagged snapshot");
1414 };
1415 let tag = snapshot.tag.clone();
1416 let baseline = Baseline::from_bytes(original.as_bytes().to_vec());
1417 let operations = vec![put_text("16", &["line_16 = 160"])];
1418 let resolved = vec![resolve_one(&snapshot, &operations[0])];
1419 let sections = [section_put(
1420 &path,
1421 "after-failure.py",
1422 &baseline,
1423 &snapshot,
1424 &operations,
1425 &resolved,
1426 )];
1427 let mut backups = backup_store(&temp.path().join("backups"));
1428 let mut registers = RegisterStore::new();
1429 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1430 let failed = {
1431 let mut execution = ctx(
1432 &mut backups,
1433 &mut snapshots,
1434 &mut registers,
1435 true,
1436 Some(ExecuteFault::Write { step: 0 }),
1437 );
1438 execute_transaction(plan, &mut execution)
1439 };
1440 assert!(!failed.success);
1441 assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
1442 assert!(snapshots
1443 .lookup(&path, &tag)
1444 .expect("failed apply must preserve the baseline snapshot")
1445 .is_seen(16));
1446
1447 let reread = capture_taggable_read(
1448 &mut snapshots,
1449 &path,
1450 "after-failure.py",
1451 ReadSelection::WholeFile,
1452 )
1453 .unwrap();
1454 let ReadPublication::Tagged {
1455 snapshot: reread_snapshot,
1456 ..
1457 } = reread
1458 else {
1459 panic!("reread must publish a tagged snapshot");
1460 };
1461 assert_eq!(reread_snapshot.tag, tag);
1462 let next_baseline = Baseline::from_bytes(original.as_bytes().to_vec());
1463 let next_operations = vec![put_text("16", &["line_16 = 160"])];
1464 let next_resolved = vec![resolve_one(&reread_snapshot, &next_operations[0])];
1465 let next_sections = [section_put(
1466 &path,
1467 "after-failure.py",
1468 &next_baseline,
1469 &reread_snapshot,
1470 &next_operations,
1471 &next_resolved,
1472 )];
1473 let next_plan = plan_transaction(&next_sections, ®isters, true).unwrap();
1474 let applied = {
1475 let mut execution = ctx(&mut backups, &mut snapshots, &mut registers, true, None);
1476 execute_transaction(next_plan, &mut execution)
1477 };
1478 assert!(applied.success);
1479 assert!(applied.complete);
1480 assert_eq!(
1481 fs::read_to_string(path).unwrap().lines().nth(15),
1482 Some("line_16 = 160")
1483 );
1484 }
1485
1486 #[test]
1487 fn two_ranged_reads_of_one_version_then_put_applies() {
1488 let bytes = put_after_reads([ReadSelection::range(1, 2), ReadSelection::range(3, 4)])
1489 .expect("same-version ranged reads must resolve");
1490 assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1491 }
1492
1493 #[test]
1494 fn ranged_then_whole_read_of_one_version_then_put_applies() {
1495 let bytes = put_after_reads([ReadSelection::range(2, 2), ReadSelection::WholeFile])
1496 .expect("same-version ranged and whole reads must resolve");
1497 assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1498 }
1499
1500 #[test]
1501 fn second_read_without_intervening_mutation_does_not_enter_refusal_loop() {
1502 let bytes = put_after_reads([ReadSelection::range(1, 1), ReadSelection::range(2, 2)])
1503 .expect("a second read of unchanged content must leave the tag editable");
1504 assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1505 }
1506
1507 #[test]
1509 fn a8_phase1_mutation_free_and_phase2_ordered() {
1510 let temp = tempfile::tempdir().unwrap();
1511 let a = temp.path().join("a.txt");
1512 let b = temp.path().join("b.txt");
1513 write_file(&a, b"alpha\n");
1514 write_file(&b, b"beta\n");
1515 let bytes_a = fs::read(&a).unwrap();
1516 let bytes_b = fs::read(&b).unwrap();
1517 let snap_a = whole_snapshot(&bytes_a);
1518 let snap_b = whole_snapshot(&bytes_b);
1519 let base_a = Baseline::from_bytes(bytes_a.clone());
1520 let base_b = Baseline::from_bytes(bytes_b.clone());
1521 let ops_a = vec![put_text("1", &["ALPHA"])];
1522 let ops_b = vec![put_text("1", &["BETA"])];
1523 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1524 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1525 let sections = [
1526 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1527 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1528 ];
1529 let registers = RegisterStore::new();
1530 let plan = plan_transaction(§ions, ®isters, true).expect("phase1");
1531 assert_eq!(fs::read(&a).unwrap(), b"alpha\n");
1533 assert_eq!(fs::read(&b).unwrap(), b"beta\n");
1534 assert_eq!(plan.steps.len(), 2);
1535
1536 let backup_dir = temp.path().join("backups");
1537 let mut backups = backup_store(&backup_dir);
1538 let mut snapshots = SnapshotStore::new();
1539 let mut session_regs = RegisterStore::new();
1540 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1541 let envelope = execute_transaction(plan, &mut exec);
1542 assert!(envelope.success);
1543 assert!(envelope.complete);
1544 assert!(envelope.op_id.is_some());
1545 assert_eq!(envelope.files.len(), 2);
1546 assert_eq!(envelope.files[0].requested_path, "a.txt");
1547 assert_eq!(envelope.files[1].requested_path, "b.txt");
1548 assert_eq!(
1549 envelope.files[0].classification,
1550 FileClassification::Applied
1551 );
1552 assert_eq!(envelope.files[0].mutation_state, MutationState::Applied);
1553 assert_eq!(fs::read(&a).unwrap(), b"ALPHA\n");
1554 assert_eq!(fs::read(&b).unwrap(), b"BETA\n");
1555 assert!(envelope.summary_text.contains("2 of 2 files applied"));
1556
1557 let op_id = envelope.op_id.clone().unwrap();
1559 let restored = backups.restore_last_operation(SESSION).unwrap();
1560 assert_eq!(restored.op_id, op_id);
1561 assert_eq!(fs::read(&a).unwrap(), b"alpha\n");
1562 assert_eq!(fs::read(&b).unwrap(), b"beta\n");
1563 }
1564
1565 #[test]
1567 fn a8_all_failed_emits_success_false_with_envelope() {
1568 let temp = tempfile::tempdir().unwrap();
1569 let a = temp.path().join("a.txt");
1570 let b = temp.path().join("b.txt");
1571 write_file(&a, b"a\n");
1572 write_file(&b, b"b\n");
1573 let bytes_a = fs::read(&a).unwrap();
1574 let bytes_b = fs::read(&b).unwrap();
1575 let snap_a = whole_snapshot(&bytes_a);
1576 let snap_b = whole_snapshot(&bytes_b);
1577 let base_a = Baseline::from_bytes(bytes_a);
1578 let base_b = Baseline::from_bytes(bytes_b);
1579 let ops_a = vec![put_text("1", &["A"])];
1580 let ops_b = vec![put_text("1", &["B"])];
1581 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1582 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1583 let sections = [
1584 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1585 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1586 ];
1587 let registers = RegisterStore::new();
1588 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1589
1590 let mut backups = backup_store(&temp.path().join("backups"));
1591 let mut snapshots = SnapshotStore::new();
1592 let mut session_regs = RegisterStore::new();
1593 let mut exec = ctx(
1594 &mut backups,
1595 &mut snapshots,
1596 &mut session_regs,
1597 true,
1598 Some(ExecuteFault::BaselineDrift { step: 0 }),
1599 );
1600 let envelope = execute_transaction(plan, &mut exec);
1601 assert!(!envelope.success);
1602 assert!(!envelope.complete);
1603 assert_eq!(
1604 envelope.files[0].classification,
1605 FileClassification::FailedBaselineDrift
1606 );
1607 assert_eq!(envelope.files[0].mutation_state, MutationState::Unmutated);
1608 assert_eq!(
1609 envelope.files[1].classification,
1610 FileClassification::NotAttempted
1611 );
1612 assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
1613 assert_eq!(envelope.stop_reason, Some("hashline_baseline_drift"));
1614 assert!(envelope.summary_text.starts_with("0 of 2 files applied"));
1615 assert!(envelope.op_id.is_some());
1618 assert_eq!(fs::read(&a).unwrap(), b"a\n");
1619 assert_eq!(fs::read(&b).unwrap(), b"b\n");
1620 }
1621
1622 #[test]
1624 fn a8_partial_failure_keeps_prior_under_shared_op_id() {
1625 let temp = tempfile::tempdir().unwrap();
1626 let a = temp.path().join("a.txt");
1627 let b = temp.path().join("b.txt");
1628 write_file(&a, b"a\n");
1629 write_file(&b, b"b\n");
1630 let bytes_a = fs::read(&a).unwrap();
1631 let bytes_b = fs::read(&b).unwrap();
1632 let snap_a = whole_snapshot(&bytes_a);
1633 let snap_b = whole_snapshot(&bytes_b);
1634 let base_a = Baseline::from_bytes(bytes_a);
1635 let base_b = Baseline::from_bytes(bytes_b);
1636 let ops_a = vec![put_text("1", &["A"])];
1637 let ops_b = vec![put_text("1", &["B"])];
1638 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1639 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1640 let sections = [
1641 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1642 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1643 ];
1644 let registers = RegisterStore::new();
1645 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1646
1647 let mut backups = backup_store(&temp.path().join("backups"));
1648 let mut snapshots = SnapshotStore::new();
1649 let mut session_regs = RegisterStore::new();
1650 let mut exec = ctx(
1651 &mut backups,
1652 &mut snapshots,
1653 &mut session_regs,
1654 true,
1655 Some(ExecuteFault::Write { step: 1 }),
1656 );
1657 let envelope = execute_transaction(plan, &mut exec);
1658 assert!(envelope.success);
1659 assert!(!envelope.complete);
1660 assert!(envelope.op_id.is_some());
1661 assert_eq!(
1662 envelope.files[0].classification,
1663 FileClassification::Applied
1664 );
1665 assert_eq!(
1666 envelope.files[1].classification,
1667 FileClassification::FailedWrite
1668 );
1669 assert_eq!(
1670 envelope.files[1].mutation_state,
1671 MutationState::UnknownPossiblyMutated
1672 );
1673 assert_eq!(fs::read(&a).unwrap(), b"A\n");
1674 assert_eq!(fs::read(&b).unwrap(), b"b\n");
1675
1676 let op_id = envelope.op_id.unwrap();
1677 let restored = backups.restore_last_operation(SESSION).unwrap();
1678 assert_eq!(restored.op_id, op_id);
1679 assert_eq!(fs::read(&a).unwrap(), b"a\n");
1680 }
1681
1682 #[test]
1684 fn a8_mv_new_and_existing_destination_with_undo() {
1685 let temp = tempfile::tempdir().unwrap();
1686 let src = temp.path().join("src.txt");
1687 let new_dest = temp.path().join("new_dest.txt");
1688 write_file(&src, b"move-me\n");
1689 let bytes = fs::read(&src).unwrap();
1690 let snap = whole_snapshot(&bytes);
1691 let base = Baseline::from_bytes(bytes.clone());
1692 let ops = vec![Operation::Mv(MvOperation {
1693 destination: "new_dest.txt".into(),
1694 line: 1,
1695 })];
1696 let resolved = vec![ResolvedOperation {
1698 operation_index: 0,
1699 address: ResolvedAddress::WholeFile,
1700 }];
1701 let sections = [TransactionSectionInput {
1702 canonical_path: &src,
1703 requested_path: "src.txt",
1704 baseline: &base,
1705 snapshot: &snap,
1706 operations: &ops,
1707 resolved: &resolved,
1708 mv_destination: Some(MvDestinationInput {
1709 canonical_path: &new_dest,
1710 requested_path: "new_dest.txt",
1711 baseline_bytes: None,
1712 }),
1713 }];
1714 let registers = RegisterStore::new();
1715 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1716 let mut backups = backup_store(&temp.path().join("backups"));
1717 let mut snapshots = SnapshotStore::new();
1718 snapshots.publish(&src, snap.clone());
1720 let mut session_regs = RegisterStore::new();
1721 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1722 let envelope = execute_transaction(plan, &mut exec);
1723 assert!(envelope.success && envelope.complete);
1724 assert!(envelope.op_id.is_some());
1725 assert_eq!(envelope.files[0].role, FileRole::MvDestination);
1726 assert_eq!(envelope.files[1].role, FileRole::MvSource);
1727 assert!(envelope.files[0].final_tag.is_some());
1728 assert!(envelope.files[1].remove_file);
1729 assert_eq!(fs::read(&new_dest).unwrap(), b"move-me\n");
1730 assert!(!src.exists());
1731 assert!(snapshots.lookup(&src, &snap.tag).is_err());
1733
1734 let op_id = envelope.op_id.unwrap();
1735 let restored = backups.restore_last_operation(SESSION).unwrap();
1736 assert_eq!(restored.op_id, op_id);
1737 assert_eq!(fs::read(&src).unwrap(), b"move-me\n");
1738 assert!(!new_dest.exists(), "created destination removed on undo");
1739
1740 let src2 = temp.path().join("src2.txt");
1742 let dest2 = temp.path().join("dest2.txt");
1743 write_file(&src2, b"from\n");
1744 write_file(&dest2, b"old-dest\n");
1745 let bytes2 = fs::read(&src2).unwrap();
1746 let snap2 = whole_snapshot(&bytes2);
1747 let base2 = Baseline::from_bytes(bytes2);
1748 let dest_bytes = fs::read(&dest2).unwrap();
1749 let ops2 = vec![Operation::Mv(MvOperation {
1750 destination: "dest2.txt".into(),
1751 line: 1,
1752 })];
1753 let resolved2 = vec![ResolvedOperation {
1754 operation_index: 0,
1755 address: ResolvedAddress::WholeFile,
1756 }];
1757 let sections2 = [TransactionSectionInput {
1758 canonical_path: &src2,
1759 requested_path: "src2.txt",
1760 baseline: &base2,
1761 snapshot: &snap2,
1762 operations: &ops2,
1763 resolved: &resolved2,
1764 mv_destination: Some(MvDestinationInput {
1765 canonical_path: &dest2,
1766 requested_path: "dest2.txt",
1767 baseline_bytes: Some(&dest_bytes),
1768 }),
1769 }];
1770 let plan2 = plan_transaction(§ions2, ®isters, true).unwrap();
1771 let mut exec2 = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1772 let envelope2 = execute_transaction(plan2, &mut exec2);
1773 assert!(envelope2.success);
1774 assert_eq!(fs::read(&dest2).unwrap(), b"from\n");
1775 assert!(!src2.exists());
1776 let restored2 = backups.restore_last_operation(SESSION).unwrap();
1777 assert_eq!(restored2.op_id, envelope2.op_id.unwrap());
1778 assert_eq!(fs::read(&src2).unwrap(), b"from\n");
1779 assert_eq!(fs::read(&dest2).unwrap(), b"old-dest\n");
1780 }
1781
1782 #[test]
1784 fn a8_mv_source_unlink_failure_is_partial_mv() {
1785 let temp = tempfile::tempdir().unwrap();
1786 let src = temp.path().join("src.txt");
1787 let dest = temp.path().join("dest.txt");
1788 write_file(&src, b"body\n");
1789 let bytes = fs::read(&src).unwrap();
1790 let snap = whole_snapshot(&bytes);
1791 let base = Baseline::from_bytes(bytes);
1792 let ops = vec![Operation::Mv(MvOperation {
1793 destination: "dest.txt".into(),
1794 line: 1,
1795 })];
1796 let resolved = vec![ResolvedOperation {
1797 operation_index: 0,
1798 address: ResolvedAddress::WholeFile,
1799 }];
1800 let sections = [TransactionSectionInput {
1801 canonical_path: &src,
1802 requested_path: "src.txt",
1803 baseline: &base,
1804 snapshot: &snap,
1805 operations: &ops,
1806 resolved: &resolved,
1807 mv_destination: Some(MvDestinationInput {
1808 canonical_path: &dest,
1809 requested_path: "dest.txt",
1810 baseline_bytes: None,
1811 }),
1812 }];
1813 let registers = RegisterStore::new();
1814 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1815 let mut backups = backup_store(&temp.path().join("backups"));
1816 let mut snapshots = SnapshotStore::new();
1817 let mut session_regs = RegisterStore::new();
1818 let mut exec = ctx(
1819 &mut backups,
1820 &mut snapshots,
1821 &mut session_regs,
1822 true,
1823 Some(ExecuteFault::SourceUnlink { step: 0 }),
1824 );
1825 let envelope = execute_transaction(plan, &mut exec);
1826 assert!(envelope.success);
1827 assert!(!envelope.complete);
1828 assert!(envelope.op_id.is_some());
1829 assert_eq!(
1830 envelope.files[0].classification,
1831 FileClassification::Applied
1832 );
1833 assert_eq!(
1834 envelope.files[1].classification,
1835 FileClassification::FailedSourceUnlink
1836 );
1837 assert_eq!(envelope.files[1].mutation_state, MutationState::PartialMv);
1838 assert_eq!(fs::read(&dest).unwrap(), b"body\n");
1839 assert!(src.exists(), "source remains after unlink failure");
1840 }
1841
1842 #[test]
1844 fn a8_register_commit_only_when_all_applied() {
1845 let temp = tempfile::tempdir().unwrap();
1846 let a = temp.path().join("a.txt");
1847 write_file(&a, b"one\ntwo\n");
1848 let bytes = fs::read(&a).unwrap();
1849 let snap = whole_snapshot(&bytes);
1850 let base = Baseline::from_bytes(bytes);
1851 let ops = vec![Operation::Cut(crate::hashline::syntax::CutOperation {
1852 address: parse_address("1").unwrap(),
1853 register: Some(RegisterRef::Named("clip".into())),
1854 line: 1,
1855 })];
1856 let resolved = vec![resolve_one(&snap, &ops[0])];
1857 let sections = [section_put(&a, "a.txt", &base, &snap, &ops, &resolved)];
1858 let registers = RegisterStore::new();
1859 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1860
1861 let mut backups = backup_store(&temp.path().join("backups"));
1862 let mut snapshots = SnapshotStore::new();
1863 let mut session_regs = RegisterStore::new();
1864 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1865 let envelope = execute_transaction(plan, &mut exec);
1866 assert!(envelope.registers_committed);
1867 assert_eq!(
1868 session_regs.get(&RegisterRef::Named("clip".into())),
1869 Some(["one".to_string()].as_slice())
1870 );
1871
1872 write_file(&a, b"one\ntwo\n");
1874 let plan2 = plan_transaction(§ions, &RegisterStore::new(), true).unwrap();
1875 let mut session_regs2 = RegisterStore::new();
1876 let mut exec2 = ctx(
1877 &mut backups,
1878 &mut snapshots,
1879 &mut session_regs2,
1880 true,
1881 Some(ExecuteFault::Write { step: 0 }),
1882 );
1883 let envelope2 = execute_transaction(plan2, &mut exec2);
1884 assert!(!envelope2.registers_committed);
1885 assert!(session_regs2
1886 .get(&RegisterRef::Named("clip".into()))
1887 .is_none());
1888 }
1889
1890 #[test]
1892 fn a8_applied_star_variants() {
1893 let temp = tempfile::tempdir().unwrap();
1894 let path = temp.path().join("v.txt");
1895 write_file(&path, b"x\n");
1896 let bytes = fs::read(&path).unwrap();
1897 let snap = whole_snapshot(&bytes);
1898 let base = Baseline::from_bytes(bytes);
1899 let ops = vec![put_text("1", &["Y"])];
1900 let resolved = vec![resolve_one(&snap, &ops[0])];
1901 let sections = [section_put(&path, "v.txt", &base, &snap, &ops, &resolved)];
1902 let registers = RegisterStore::new();
1903
1904 let mut backups = backup_store(&temp.path().join("backups"));
1905 let mut snapshots = SnapshotStore::new();
1906 let mut session_regs = RegisterStore::new();
1907 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1908 let mut exec = ctx(
1909 &mut backups,
1910 &mut snapshots,
1911 &mut session_regs,
1912 true,
1913 Some(ExecuteFault::ValidationFailure { step: 0 }),
1914 );
1915 let envelope = execute_transaction(plan, &mut exec);
1916 assert_eq!(
1917 envelope.files[0].classification,
1918 FileClassification::AppliedWithValidationFailure
1919 );
1920 assert_eq!(fs::read(&path).unwrap(), b"Y\n");
1921
1922 write_file(&path, b"x\n");
1923 let bytes = fs::read(&path).unwrap();
1924 let snap = whole_snapshot(&bytes);
1925 let base = Baseline::from_bytes(bytes);
1926 let sections = [section_put(&path, "v.txt", &base, &snap, &ops, &resolved)];
1927 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1928 let mut exec = ctx(
1929 &mut backups,
1930 &mut snapshots,
1931 &mut session_regs,
1932 true,
1933 Some(ExecuteFault::FinalTagUnavailable { step: 0 }),
1934 );
1935 let envelope = execute_transaction(plan, &mut exec);
1936 assert_eq!(
1937 envelope.files[0].classification,
1938 FileClassification::AppliedTagUnavailable
1939 );
1940 assert!(envelope.files[0].final_tag.is_none());
1941 assert!(envelope.files[0].tag_notice.is_some());
1942 }
1943
1944 #[test]
1946 fn a10_preview_mutates_nothing() {
1947 let temp = tempfile::tempdir().unwrap();
1948 let path = temp.path().join("p.txt");
1949 let dest = temp.path().join("p-dest.txt");
1950 write_file(&path, b"preview\n");
1951 let bytes = fs::read(&path).unwrap();
1952 let snap = whole_snapshot(&bytes);
1953 let base = Baseline::from_bytes(bytes.clone());
1954 let ops = vec![
1955 put_text("1", &["PREVIEWED"]),
1956 Operation::Mv(MvOperation {
1957 destination: "p-dest.txt".into(),
1958 line: 2,
1959 }),
1960 ];
1961 let resolved = vec![
1962 resolve_one(&snap, &ops[0]),
1963 ResolvedOperation {
1964 operation_index: 1,
1965 address: ResolvedAddress::WholeFile,
1966 },
1967 ];
1968 let sections = [TransactionSectionInput {
1969 canonical_path: &path,
1970 requested_path: "p.txt",
1971 baseline: &base,
1972 snapshot: &snap,
1973 operations: &ops,
1974 resolved: &resolved,
1975 mv_destination: Some(MvDestinationInput {
1976 canonical_path: &dest,
1977 requested_path: "p-dest.txt",
1978 baseline_bytes: None,
1979 }),
1980 }];
1981 let mut registers = RegisterStore::new();
1982 {
1984 let mut staged = registers.stage();
1985 staged
1986 .capture(RegisterRef::Named("keep".into()), vec!["seed".into()])
1987 .unwrap();
1988 registers.commit(staged);
1989 }
1990 let plan = plan_transaction(§ions, ®isters, true).unwrap();
1991 let before_reg = registers
1992 .get(&RegisterRef::Named("keep".into()))
1993 .map(|lines| lines.to_vec());
1994
1995 let backups = backup_store(&temp.path().join("backups"));
1996 let tracked_before = backups.tracked_files(SESSION);
1997 let mut snapshots = SnapshotStore::new();
1998 snapshots.publish(&path, snap.clone());
1999 let envelope = preview_transaction(plan);
2000
2001 assert!(envelope.preview);
2002 assert!(envelope.op_id.is_none());
2003 assert!(!envelope.registers_committed);
2004 assert_eq!(fs::read(&path).unwrap(), b"preview\n");
2005 assert!(!dest.exists());
2006 assert_eq!(backups.tracked_files(SESSION), tracked_before);
2007 assert!(snapshots.lookup(&path, &snap.tag).is_ok());
2008 assert_eq!(
2009 registers
2010 .get(&RegisterRef::Named("keep".into()))
2011 .map(|lines| lines.to_vec()),
2012 before_reg
2013 );
2014 assert!(envelope.files.iter().all(|f| f.final_tag.is_none()));
2015 assert!(envelope
2016 .files
2017 .iter()
2018 .all(|f| f.mutation_state == MutationState::Unmutated));
2019 }
2020
2021 #[test]
2022 fn mixed_patch_keeps_distinct_file_independent_and_same_path_pair_atomic() {
2023 let temp = tempfile::tempdir().unwrap();
2024 let distinct = temp.path().join("distinct.txt");
2025 let composed = temp.path().join("composed.txt");
2026 write_file(&distinct, b"distinct\n");
2027 write_file(&composed, b"one\ntwo\nthree\n");
2028 let distinct_bytes = fs::read(&distinct).unwrap();
2029 let composed_bytes = fs::read(&composed).unwrap();
2030 let distinct_snapshot = whole_snapshot(&distinct_bytes);
2031 let composed_snapshot = whole_snapshot(&composed_bytes);
2032 let distinct_baseline = Baseline::from_bytes(distinct_bytes);
2033 let composed_baseline = Baseline::from_bytes(composed_bytes);
2034 let distinct_ops = vec![put_text("1", &["changed"])];
2035 let first_cut = vec![Operation::Cut(CutOperation {
2036 address: parse_address("1").unwrap(),
2037 register: None,
2038 line: 2,
2039 })];
2040 let last_cut = vec![Operation::Cut(CutOperation {
2041 address: parse_address("3").unwrap(),
2042 register: None,
2043 line: 4,
2044 })];
2045 let distinct_resolved = vec![resolve_one(&distinct_snapshot, &distinct_ops[0])];
2046 let first_resolved = vec![resolve_one(&composed_snapshot, &first_cut[0])];
2047 let last_resolved = vec![resolve_one(&composed_snapshot, &last_cut[0])];
2048 let sections = [
2049 section_put(
2050 &distinct,
2051 "distinct.txt",
2052 &distinct_baseline,
2053 &distinct_snapshot,
2054 &distinct_ops,
2055 &distinct_resolved,
2056 ),
2057 section_put(
2058 &composed,
2059 "composed.txt",
2060 &composed_baseline,
2061 &composed_snapshot,
2062 &first_cut,
2063 &first_resolved,
2064 ),
2065 section_put(
2066 &composed,
2067 "composed.txt",
2068 &composed_baseline,
2069 &composed_snapshot,
2070 &last_cut,
2071 &last_resolved,
2072 ),
2073 ];
2074 let registers = RegisterStore::new();
2075 let plan = plan_transaction(§ions, ®isters, true).unwrap();
2076 assert_eq!(plan.steps.len(), 2);
2077
2078 let mut backups = backup_store(&temp.path().join("backups"));
2079 let mut snapshots = SnapshotStore::new();
2080 let mut session_regs = RegisterStore::new();
2081 let mut exec = ctx(
2082 &mut backups,
2083 &mut snapshots,
2084 &mut session_regs,
2085 true,
2086 Some(ExecuteFault::BaselineDrift { step: 1 }),
2087 );
2088 let envelope = execute_transaction(plan, &mut exec);
2089
2090 assert!(envelope.success);
2091 assert!(!envelope.complete);
2092 assert_eq!(envelope.summary_text, "1 of 2 files applied");
2093 assert_eq!(envelope.files.len(), 2);
2094 assert_eq!(
2095 envelope.files[0].classification,
2096 FileClassification::Applied
2097 );
2098 assert_eq!(
2099 envelope.files[1].classification,
2100 FileClassification::FailedBaselineDrift
2101 );
2102 assert_eq!(fs::read(&distinct).unwrap(), b"changed\n");
2103 assert_eq!(fs::read(&composed).unwrap(), b"one\ntwo\nthree\n");
2104 }
2105
2106 #[test]
2108 fn a12_baseline_drift_stops_later_files_and_keeps_prior_op_id() {
2109 let temp = tempfile::tempdir().unwrap();
2110 let a = temp.path().join("a.txt");
2111 let b = temp.path().join("b.txt");
2112 write_file(&a, b"a0\n");
2113 write_file(&b, b"b0\n");
2114 let bytes_a = fs::read(&a).unwrap();
2115 let bytes_b = fs::read(&b).unwrap();
2116 let snap_a = whole_snapshot(&bytes_a);
2117 let snap_b = whole_snapshot(&bytes_b);
2118 let base_a = Baseline::from_bytes(bytes_a);
2119 let base_b = Baseline::from_bytes(bytes_b);
2120 let ops_a = vec![put_text("1", &["A1"])];
2121 let ops_b = vec![put_text("1", &["B1"])];
2122 let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
2123 let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
2124 let sections = [
2125 section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
2126 section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
2127 ];
2128 let registers = RegisterStore::new();
2129 let plan = plan_transaction(§ions, ®isters, true).unwrap();
2130
2131 write_file(&b, b"b-EXTERNAL\n");
2133
2134 let mut backups = backup_store(&temp.path().join("backups"));
2135 let mut snapshots = SnapshotStore::new();
2136 let mut session_regs = RegisterStore::new();
2137 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
2138 let envelope = execute_transaction(plan, &mut exec);
2139
2140 assert!(envelope.success);
2141 assert!(!envelope.complete);
2142 assert_eq!(
2143 envelope.files[0].classification,
2144 FileClassification::Applied
2145 );
2146 assert_eq!(
2147 envelope.files[1].classification,
2148 FileClassification::FailedBaselineDrift
2149 );
2150 assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
2151 assert_eq!(envelope.stop_reason, Some("hashline_baseline_drift"));
2152 assert!(envelope.op_id.is_some());
2153 assert_eq!(fs::read(&a).unwrap(), b"A1\n");
2154 assert_eq!(fs::read(&b).unwrap(), b"b-EXTERNAL\n");
2155
2156 let op_id = envelope.op_id.unwrap();
2157 let restored = backups.restore_last_operation(SESSION).unwrap();
2158 assert_eq!(restored.op_id, op_id);
2159 assert_eq!(fs::read(&a).unwrap(), b"a0\n");
2160 }
2161
2162 #[test]
2164 fn a17_backup_unavailable_refusals_and_new_dest_mv() {
2165 let temp = tempfile::tempdir().unwrap();
2166 let path = temp.path().join("t.txt");
2167 write_file(&path, b"t\n");
2168 let bytes = fs::read(&path).unwrap();
2169 let snap = whole_snapshot(&bytes);
2170 let base = Baseline::from_bytes(bytes);
2171 let ops = vec![put_text("1", &["T"])];
2172 let resolved = vec![resolve_one(&snap, &ops[0])];
2173 let sections = [section_put(&path, "t.txt", &base, &snap, &ops, &resolved)];
2174 let registers = RegisterStore::new();
2175 let err = plan_transaction(§ions, ®isters, false).unwrap_err();
2176 assert_eq!(
2177 err.code,
2178 crate::hashline::syntax::HashlineRejectionCode::BackupUnavailable
2179 );
2180 assert_eq!(err.stage, crate::hashline::syntax::RejectionStage::Baseline);
2181 assert_eq!(fs::read(&path).unwrap(), b"t\n");
2182
2183 let src = temp.path().join("s.txt");
2185 let dest = temp.path().join("d.txt");
2186 write_file(&src, b"s\n");
2187 write_file(&dest, b"d\n");
2188 let s_bytes = fs::read(&src).unwrap();
2189 let d_bytes = fs::read(&dest).unwrap();
2190 let s_snap = whole_snapshot(&s_bytes);
2191 let s_base = Baseline::from_bytes(s_bytes);
2192 let mv_ops = vec![Operation::Mv(MvOperation {
2193 destination: "d.txt".into(),
2194 line: 1,
2195 })];
2196 let mv_resolved = vec![ResolvedOperation {
2197 operation_index: 0,
2198 address: ResolvedAddress::WholeFile,
2199 }];
2200 let mv_sections = [TransactionSectionInput {
2201 canonical_path: &src,
2202 requested_path: "s.txt",
2203 baseline: &s_base,
2204 snapshot: &s_snap,
2205 operations: &mv_ops,
2206 resolved: &mv_resolved,
2207 mv_destination: Some(MvDestinationInput {
2208 canonical_path: &dest,
2209 requested_path: "d.txt",
2210 baseline_bytes: Some(&d_bytes),
2211 }),
2212 }];
2213 let err = plan_transaction(&mv_sections, ®isters, false).unwrap_err();
2214 assert_eq!(
2215 err.code,
2216 crate::hashline::syntax::HashlineRejectionCode::BackupUnavailable
2217 );
2218 assert_eq!(fs::read(&src).unwrap(), b"s\n");
2219 assert_eq!(fs::read(&dest).unwrap(), b"d\n");
2220
2221 let src2 = temp.path().join("s2.txt");
2224 let dest2 = temp.path().join("d2.txt");
2225 write_file(&src2, b"s2\n");
2226 let s2_bytes = fs::read(&src2).unwrap();
2227 let s2_snap = whole_snapshot(&s2_bytes);
2228 let s2_base = Baseline::from_bytes(s2_bytes);
2229 let mv2_ops = vec![Operation::Mv(MvOperation {
2230 destination: "d2.txt".into(),
2231 line: 1,
2232 })];
2233 let mv2_resolved = vec![ResolvedOperation {
2234 operation_index: 0,
2235 address: ResolvedAddress::WholeFile,
2236 }];
2237 let mv2_sections = [TransactionSectionInput {
2238 canonical_path: &src2,
2239 requested_path: "s2.txt",
2240 baseline: &s2_base,
2241 snapshot: &s2_snap,
2242 operations: &mv2_ops,
2243 resolved: &mv2_resolved,
2244 mv_destination: Some(MvDestinationInput {
2245 canonical_path: &dest2,
2246 requested_path: "d2.txt",
2247 baseline_bytes: None,
2248 }),
2249 }];
2250 let plan = plan_transaction(&mv2_sections, ®isters, false).expect("new dest MV plans");
2251 let mut backups = backup_store(&temp.path().join("backups"));
2252 let mut snapshots = SnapshotStore::new();
2253 let mut session_regs = RegisterStore::new();
2254 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, false, None);
2257 let envelope = execute_transaction(plan, &mut exec);
2258 assert!(envelope.success);
2259 assert!(envelope.op_id.is_some(), "real journaled op_id required");
2260 assert_eq!(fs::read(&dest2).unwrap(), b"s2\n");
2261 assert!(!src2.exists());
2262 let op_id = envelope.op_id.unwrap();
2263 let restored = backups.restore_last_operation(SESSION).unwrap();
2264 assert_eq!(restored.op_id, op_id);
2265 assert_eq!(fs::read(&src2).unwrap(), b"s2\n");
2266 assert!(!dest2.exists());
2267
2268 let mut disabled = BackupStore::new();
2270 disabled.set_policy(BackupPolicy {
2271 enabled: false,
2272 ..BackupPolicy::default()
2273 });
2274 write_file(&src2, b"s2\n");
2275 let plan = plan_transaction(&mv2_sections, ®isters, false).unwrap();
2278 let mut snapshots = SnapshotStore::new();
2279 let mut session_regs = RegisterStore::new();
2280 let mut exec = ctx(
2281 &mut disabled,
2282 &mut snapshots,
2283 &mut session_regs,
2284 false,
2285 None,
2286 );
2287 let envelope = execute_transaction(plan, &mut exec);
2288 assert!(!envelope.success);
2289 assert!(envelope.op_id.is_none());
2290 assert_eq!(
2291 envelope.files[0].classification,
2292 FileClassification::FailedBackup
2293 );
2294 }
2295
2296 #[test]
2298 fn op_id_present_when_journal_entry_exists_before_failure() {
2299 let temp = tempfile::tempdir().unwrap();
2300 let a = temp.path().join("a.txt");
2301 write_file(&a, b"a\n");
2302 let bytes = fs::read(&a).unwrap();
2303 let snap = whole_snapshot(&bytes);
2304 let base = Baseline::from_bytes(bytes);
2305 let ops = vec![put_text("1", &["A"])];
2306 let resolved = vec![resolve_one(&snap, &ops[0])];
2307 let sections = [section_put(&a, "a.txt", &base, &snap, &ops, &resolved)];
2308 let registers = RegisterStore::new();
2309 let plan = plan_transaction(§ions, ®isters, true).unwrap();
2310 let mut backups = backup_store(&temp.path().join("backups"));
2311 let mut snapshots = SnapshotStore::new();
2312 let mut session_regs = RegisterStore::new();
2313 write_file(&a, b"changed\n");
2316 let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
2317 let envelope = execute_transaction(plan, &mut exec);
2318 assert!(!envelope.success);
2319 assert_eq!(
2320 envelope.files[0].classification,
2321 FileClassification::FailedBaselineDrift
2322 );
2323 assert!(envelope.op_id.is_some());
2325 assert_eq!(fs::read(&a).unwrap(), b"changed\n");
2326 }
2327}