Skip to main content

aft/hashline/transaction/
mod.rs

1//! Hashline cross-file transactions: mutation-free Phase 1, preview, and
2//! patch-ordered Phase 2 with backups, baseline recheck, durability, and MV.
3//!
4//! The line-apply engine plans PUT/CUT/REM bytes. This module owns everything
5//! that touches the filesystem or the undo journal: rollback-availability
6//! checks, ordered execution, destination-before-source MV reporting, final-byte
7//! observation, register commit gating, and `op_id` emission.
8
9use 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/// Destination coordinates for a section that ends with MV.
29#[derive(Clone, Debug)]
30pub struct MvDestinationInput<'a> {
31    pub canonical_path: &'a Path,
32    pub requested_path: &'a str,
33    /// `None` when the destination path does not exist yet (created-file rollback).
34    pub baseline_bytes: Option<&'a [u8]>,
35}
36
37/// One patch section ready for transaction planning.
38#[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/// Ordered Phase-1 plan. No disk, backup, snapshot, or session-register mutation.
50#[derive(Clone, Debug)]
51pub struct TransactionPlan {
52    pub steps: Vec<PlannedStep>,
53    pub staged_registers: StagedRegisters,
54}
55
56/// One patch-ordered execution unit.
57#[derive(Clone, Debug)]
58pub enum PlannedStep {
59    /// In-place PUT/CUT/REM against one path.
60    Mutate(PlannedFile),
61    /// Write planned bytes at the destination, then unlink the source.
62    Mv(PlannedMv),
63}
64
65/// MV plan: destination content is the post-line-op source bytes (or the
66/// untouched baseline when the section is a pure move).
67#[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/// Role of one ordered per-file outcome row.
83#[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/// One file row in the mutation-result envelope.
101#[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/// Complete Phase-2 (or preview) envelope.
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct TransactionEnvelope {
121    pub success: bool,
122    pub complete: bool,
123    pub files: Vec<FileOutcome>,
124    /// Present exactly when the undo journal retained at least one record.
125    pub op_id: Option<String>,
126    pub stop_reason: Option<&'static str>,
127    pub registers_committed: bool,
128    pub preview: bool,
129    /// Agent-visible lead-in so hosts that strip structured fields still see counts.
130    pub summary_text: String,
131}
132
133impl TransactionEnvelope {
134    /// Convert to the apply-layer envelope shape (without `op_id` / roles).
135    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/// Test and integration fault injection points for Phase 2.
160#[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
171/// Runtime handles required to execute (or preview) a plan.
172pub 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    /// When false, Phase 1 must already have refused content-destructive ops.
178    /// Phase 2 still consults the store policy for real journal records.
179    pub backups_enabled: bool,
180    pub fault: Option<ExecuteFault>,
181}
182
183/// Plan every section without mutating disk, backups, snapshots, or registers.
184///
185/// Rollback availability is proven here. When `backups_enabled` is false, every
186/// operation that would destroy existing bytes it cannot restore is rejected as
187/// `hashline_backup_unavailable`. New-destination MV is allowed because its
188/// created-file tombstone is a real undo identity once Phase 2 journals it.
189pub 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    // Phase 1 lowers every section through the apply planner first. That planner
201    // collapses repeated canonical paths into one final byte image, so Phase 2
202    // can journal, baseline-check, write, tag, and count the path exactly once.
203    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
317/// Preview reuses Phase 1 and renders the planned envelope without any mutation.
318///
319/// No `op_id`, backup, snapshot mint, undo record, or register commit.
320pub 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    // Drop staged registers — preview never commits.
328    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
341/// Execute a Phase-1 plan in patch order under one optional `op_id`.
342///
343/// Order per file: journal → baseline recheck → write → (optional format /
344/// validate markers) → durability barrier → final tag from post-barrier bytes.
345/// MV writes and durably commits the destination before unlinking the source.
346pub 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    // Successful MV source-removal companion rows are not independent planned
381    // files. Failed source-unlink rows do count so a partial MV is not complete.
382    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
411/// Convenience: plan then either preview or execute.
412pub 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
426// ── Phase 1 helpers ──────────────────────────────────────────────────────────
427
428fn 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        // New-destination MV keeps a created-file tombstone as its undo identity.
482        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
540// ── Phase 2 execution ────────────────────────────────────────────────────────
541
542enum 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    // Journal before any destructive write.
585    let backup_id = match journal_existing_or_skip(
586        ctx,
587        op_id,
588        &file.canonical_path,
589        file.remove_file,
590        "hashline: pre-mutation backup",
591    ) {
592        Ok(id) => {
593            if id.is_some() {
594                *journaled = true;
595            }
596            id
597        }
598        Err(_) => {
599            return StepExec::Stopped {
600                outcomes: vec![failed_outcome(
601                    &file.canonical_path,
602                    &file.requested_path,
603                    FileRole::Primary,
604                    FileClassification::FailedBackup,
605                    file.remove_file,
606                    file.warnings.clone(),
607                )],
608                reason: "failed_backup",
609            };
610        }
611    };
612
613    if fault_is(ctx, ExecuteFault::BaselineDrift { step: step_index })
614        || !baseline_matches(&file.canonical_path, &file.baseline_bytes)
615    {
616        return StepExec::Stopped {
617            outcomes: vec![failed_outcome(
618                &file.canonical_path,
619                &file.requested_path,
620                FileRole::Primary,
621                FileClassification::FailedBaselineDrift,
622                file.remove_file,
623                file.warnings.clone(),
624            )],
625            reason: "hashline_baseline_drift",
626        };
627    }
628
629    if fault_is(ctx, ExecuteFault::Write { step: step_index }) {
630        return StepExec::Stopped {
631            outcomes: vec![failed_outcome(
632                &file.canonical_path,
633                &file.requested_path,
634                FileRole::Primary,
635                FileClassification::FailedWrite,
636                file.remove_file,
637                file.warnings.clone(),
638            )],
639            reason: "failed_write",
640        };
641    }
642
643    if file.remove_file {
644        if let Err(error) = fs::remove_file(&file.canonical_path) {
645            if error.kind() != io::ErrorKind::NotFound {
646                return StepExec::Stopped {
647                    outcomes: vec![failed_outcome(
648                        &file.canonical_path,
649                        &file.requested_path,
650                        FileRole::Primary,
651                        FileClassification::FailedWrite,
652                        true,
653                        file.warnings.clone(),
654                    )],
655                    reason: "failed_write",
656                };
657            }
658        }
659        invalidate_removed_source(ctx.snapshots, &file.canonical_path);
660        return StepExec::Applied(vec![FileOutcome {
661            canonical_path: file.canonical_path,
662            requested_path: file.requested_path,
663            role: FileRole::Primary,
664            classification: FileClassification::Applied,
665            mutation_state: MutationState::Applied,
666            final_bytes: None,
667            final_tag: None,
668            affected: AffectedRegion::default(),
669            warnings: file.warnings,
670            format_skipped_reason: None,
671            backup_id,
672            remove_file: true,
673            tag_notice: Some("source path removed; no final tag".into()),
674        }]);
675    }
676
677    if let Err(error) = durable_write(&file.canonical_path, &file.final_bytes) {
678        let classification = if error.to_string().contains("durability") {
679            FileClassification::FailedDurability
680        } else {
681            FileClassification::FailedWrite
682        };
683        return StepExec::Stopped {
684            outcomes: vec![failed_outcome(
685                &file.canonical_path,
686                &file.requested_path,
687                FileRole::Primary,
688                classification,
689                false,
690                file.warnings.clone(),
691            )],
692            reason: classification.as_str(),
693        };
694    }
695
696    if fault_is(ctx, ExecuteFault::Durability { step: step_index }) {
697        return StepExec::Stopped {
698            outcomes: vec![failed_outcome(
699                &file.canonical_path,
700                &file.requested_path,
701                FileRole::Primary,
702                FileClassification::FailedDurability,
703                false,
704                file.warnings.clone(),
705            )],
706            reason: "failed_durability",
707        };
708    }
709
710    // Authoritative post-barrier bytes.
711    let on_disk = match fs::read(&file.canonical_path) {
712        Ok(bytes) => bytes,
713        Err(_) => {
714            return StepExec::Applied(vec![FileOutcome {
715                canonical_path: file.canonical_path,
716                requested_path: file.requested_path,
717                role: FileRole::Primary,
718                classification: FileClassification::AppliedTagUnavailable,
719                mutation_state: MutationState::Applied,
720                final_bytes: Some(file.final_bytes),
721                final_tag: None,
722                affected: file.affected,
723                warnings: file.warnings,
724                format_skipped_reason: None,
725                backup_id,
726                remove_file: false,
727                tag_notice: Some("final bytes could not be re-read for tagging".into()),
728            }]);
729        }
730    };
731
732    let mut classification = FileClassification::Applied;
733    if fault_is(ctx, ExecuteFault::ValidationFailure { step: step_index }) {
734        classification = FileClassification::AppliedWithValidationFailure;
735    }
736
737    let (final_tag, tag_notice, classification) =
738        if fault_is(ctx, ExecuteFault::FinalTagUnavailable { step: step_index }) {
739            (
740                None,
741                Some("final tag unavailable; re-read before chaining".into()),
742                FileClassification::AppliedTagUnavailable,
743            )
744        } else {
745            let published = publish_edit_response_snapshot(
746                ctx.snapshots,
747                &file.canonical_path,
748                file.requested_path.clone(),
749                &on_disk,
750                &file.affected,
751            );
752            tag_from_publish(published, classification)
753        };
754
755    StepExec::Applied(vec![FileOutcome {
756        canonical_path: file.canonical_path,
757        requested_path: file.requested_path,
758        role: FileRole::Primary,
759        classification,
760        mutation_state: classification.mutation_state(),
761        final_bytes: Some(on_disk),
762        final_tag,
763        affected: file.affected,
764        warnings: file.warnings,
765        format_skipped_reason: None,
766        backup_id,
767        remove_file: false,
768        tag_notice,
769    }])
770}
771
772fn execute_mv(
773    step_index: usize,
774    mv: PlannedMv,
775    op_id: &str,
776    journaled: &mut bool,
777    ctx: &mut ExecuteContext<'_>,
778) -> StepExec {
779    if fault_is(ctx, ExecuteFault::Backup { step: step_index }) {
780        return StepExec::Stopped {
781            outcomes: mv_failed_pair(
782                &mv,
783                FileClassification::FailedBackup,
784                FileClassification::NotAttempted,
785            ),
786            reason: "failed_backup",
787        };
788    }
789
790    // Destination journal first: existing content backup or created-file tombstone.
791    let dest_backup_id = if mv.dest_existed {
792        match ctx.backups.snapshot_with_op(
793            ctx.session,
794            &mv.dest_canonical,
795            "hashline: MV destination backup",
796            Some(op_id),
797        ) {
798            Ok(Some(id)) => {
799                *journaled = true;
800                Some(id)
801            }
802            Ok(None) => None,
803            Err(_) => {
804                return StepExec::Stopped {
805                    outcomes: mv_failed_pair(
806                        &mv,
807                        FileClassification::FailedBackup,
808                        FileClassification::NotAttempted,
809                    ),
810                    reason: "failed_backup",
811                };
812            }
813        }
814    } else {
815        match ctx.backups.snapshot_op_tombstone(
816            ctx.session,
817            op_id,
818            &mv.dest_canonical,
819            "hashline: MV created destination",
820        ) {
821            Ok(Some(id)) => {
822                *journaled = true;
823                Some(id)
824            }
825            Ok(None) => None,
826            Err(_) => {
827                return StepExec::Stopped {
828                    outcomes: mv_failed_pair(
829                        &mv,
830                        FileClassification::FailedBackup,
831                        FileClassification::NotAttempted,
832                    ),
833                    reason: "failed_backup",
834                };
835            }
836        }
837    };
838
839    // Source content backup so undo can restore it after unlink.
840    let source_backup_id = match ctx.backups.snapshot_with_op(
841        ctx.session,
842        &mv.source_canonical,
843        "hashline: MV source backup",
844        Some(op_id),
845    ) {
846        Ok(Some(id)) => {
847            *journaled = true;
848            Some(id)
849        }
850        Ok(None) => None,
851        Err(_) => {
852            return StepExec::Stopped {
853                outcomes: mv_failed_pair(
854                    &mv,
855                    FileClassification::FailedBackup,
856                    FileClassification::NotAttempted,
857                ),
858                reason: "failed_backup",
859            };
860        }
861    };
862
863    // Baseline recheck on source (and existing destination).
864    if fault_is(ctx, ExecuteFault::BaselineDrift { step: step_index })
865        || !baseline_matches(&mv.source_canonical, &mv.source_baseline_bytes)
866        || mv
867            .dest_baseline_bytes
868            .as_ref()
869            .is_some_and(|expected| !baseline_matches(&mv.dest_canonical, expected))
870    {
871        return StepExec::Stopped {
872            outcomes: mv_failed_pair(
873                &mv,
874                FileClassification::FailedBaselineDrift,
875                FileClassification::NotAttempted,
876            ),
877            reason: "hashline_baseline_drift",
878        };
879    }
880
881    if fault_is(ctx, ExecuteFault::Write { step: step_index }) {
882        return StepExec::Stopped {
883            outcomes: mv_failed_pair(
884                &mv,
885                FileClassification::FailedWrite,
886                FileClassification::NotAttempted,
887            ),
888            reason: "failed_write",
889        };
890    }
891
892    // Destination durability precedes source unlink.
893    if let Err(error) = ensure_parent_dirs(&mv.dest_canonical)
894        .and_then(|_| durable_write(&mv.dest_canonical, &mv.final_bytes))
895    {
896        let classification = if error.to_string().contains("durability") {
897            FileClassification::FailedDurability
898        } else {
899            FileClassification::FailedWrite
900        };
901        return StepExec::Stopped {
902            outcomes: mv_failed_pair(&mv, classification, FileClassification::NotAttempted),
903            reason: classification.as_str(),
904        };
905    }
906
907    if fault_is(ctx, ExecuteFault::Durability { step: step_index }) {
908        return StepExec::Stopped {
909            outcomes: mv_failed_pair(
910                &mv,
911                FileClassification::FailedDurability,
912                FileClassification::NotAttempted,
913            ),
914            reason: "failed_durability",
915        };
916    }
917
918    let dest_on_disk = fs::read(&mv.dest_canonical).unwrap_or_else(|_| mv.final_bytes.clone());
919
920    if fault_is(ctx, ExecuteFault::SourceUnlink { step: step_index })
921        || fs::remove_file(&mv.source_canonical).is_err()
922    {
923        // Destination stands; source intact. Shared op_id remains for recovery.
924        let (final_tag, tag_notice, dest_class) =
925            observe_dest_tag(ctx, &mv, &dest_on_disk, step_index);
926        return StepExec::Stopped {
927            outcomes: vec![
928                FileOutcome {
929                    canonical_path: mv.dest_canonical,
930                    requested_path: mv.dest_requested,
931                    role: FileRole::MvDestination,
932                    classification: dest_class,
933                    mutation_state: dest_class.mutation_state(),
934                    final_bytes: Some(dest_on_disk),
935                    final_tag,
936                    affected: mv.affected,
937                    warnings: mv.warnings,
938                    format_skipped_reason: None,
939                    backup_id: dest_backup_id,
940                    remove_file: false,
941                    tag_notice,
942                },
943                FileOutcome {
944                    canonical_path: mv.source_canonical,
945                    requested_path: mv.source_requested,
946                    role: FileRole::MvSource,
947                    classification: FileClassification::FailedSourceUnlink,
948                    mutation_state: MutationState::PartialMv,
949                    final_bytes: Some(mv.source_baseline_bytes),
950                    final_tag: None,
951                    affected: AffectedRegion::default(),
952                    warnings: Vec::new(),
953                    format_skipped_reason: None,
954                    backup_id: source_backup_id,
955                    remove_file: false,
956                    tag_notice: Some(
957                        "destination written; source unlink failed — partial MV under shared op_id"
958                            .into(),
959                    ),
960                },
961            ],
962            reason: "failed_source_unlink",
963        };
964    }
965
966    invalidate_removed_source(ctx.snapshots, &mv.source_canonical);
967
968    let (final_tag, tag_notice, dest_class) = observe_dest_tag(ctx, &mv, &dest_on_disk, step_index);
969
970    StepExec::Applied(vec![
971        FileOutcome {
972            canonical_path: mv.dest_canonical,
973            requested_path: mv.dest_requested,
974            role: FileRole::MvDestination,
975            classification: dest_class,
976            mutation_state: dest_class.mutation_state(),
977            final_bytes: Some(dest_on_disk),
978            final_tag,
979            affected: mv.affected,
980            warnings: mv.warnings,
981            format_skipped_reason: None,
982            backup_id: dest_backup_id,
983            remove_file: false,
984            tag_notice,
985        },
986        FileOutcome {
987            canonical_path: mv.source_canonical,
988            requested_path: mv.source_requested,
989            role: FileRole::MvSource,
990            classification: FileClassification::Applied,
991            mutation_state: MutationState::Applied,
992            final_bytes: None,
993            final_tag: None,
994            affected: AffectedRegion::default(),
995            warnings: Vec::new(),
996            format_skipped_reason: None,
997            backup_id: source_backup_id,
998            remove_file: true,
999            tag_notice: Some("source path removed; no final tag".into()),
1000        },
1001    ])
1002}
1003
1004fn observe_dest_tag(
1005    ctx: &mut ExecuteContext<'_>,
1006    mv: &PlannedMv,
1007    dest_on_disk: &[u8],
1008    step_index: usize,
1009) -> (Option<String>, Option<String>, FileClassification) {
1010    if fault_is(ctx, ExecuteFault::FinalTagUnavailable { step: step_index }) {
1011        return (
1012            None,
1013            Some("final tag unavailable; re-read before chaining".into()),
1014            FileClassification::AppliedTagUnavailable,
1015        );
1016    }
1017    let mut classification = FileClassification::Applied;
1018    if fault_is(ctx, ExecuteFault::ValidationFailure { step: step_index }) {
1019        classification = FileClassification::AppliedWithValidationFailure;
1020    }
1021    let published = publish_edit_response_snapshot(
1022        ctx.snapshots,
1023        &mv.dest_canonical,
1024        mv.dest_requested.clone(),
1025        dest_on_disk,
1026        &mv.affected,
1027    );
1028    tag_from_publish(published, classification)
1029}
1030
1031fn tag_from_publish(
1032    published: EditResponseSnapshot,
1033    classification: FileClassification,
1034) -> (Option<String>, Option<String>, FileClassification) {
1035    if let Some(snapshot) = published.snapshot {
1036        (Some(snapshot.tag.clone()), published.notice, classification)
1037    } else {
1038        (
1039            None,
1040            published
1041                .notice
1042                .or_else(|| Some("final tag unavailable; re-read before chaining".into())),
1043            FileClassification::AppliedTagUnavailable,
1044        )
1045    }
1046}
1047
1048fn journal_existing_or_skip(
1049    ctx: &mut ExecuteContext<'_>,
1050    op_id: &str,
1051    path: &Path,
1052    _remove_file: bool,
1053    description: &str,
1054) -> Result<Option<String>, ()> {
1055    if !path_exists(path) {
1056        // Creating a brand-new path via PUT is out of v1; treat as no-op journal.
1057        return Ok(None);
1058    }
1059    match ctx
1060        .backups
1061        .snapshot_with_op(ctx.session, path, description, Some(op_id))
1062    {
1063        Ok(id) => Ok(id),
1064        Err(_) => Err(()),
1065    }
1066}
1067
1068// ── Disk helpers ─────────────────────────────────────────────────────────────
1069
1070fn path_exists(path: &Path) -> bool {
1071    fs::symlink_metadata(path).is_ok()
1072}
1073
1074fn baseline_matches(path: &Path, expected: &[u8]) -> bool {
1075    match fs::read(path) {
1076        Ok(bytes) => bytes == expected,
1077        Err(error) if error.kind() == io::ErrorKind::NotFound => expected.is_empty(),
1078        Err(_) => false,
1079    }
1080}
1081
1082fn ensure_parent_dirs(path: &Path) -> io::Result<()> {
1083    if let Some(parent) = path.parent() {
1084        if !parent.as_os_str().is_empty() {
1085            fs::create_dir_all(parent)?;
1086        }
1087    }
1088    Ok(())
1089}
1090
1091/// Write bytes via temp + fsync + rename so a crash cannot leave a torn target.
1092fn durable_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
1093    ensure_parent_dirs(path)?;
1094    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1095    let file_name = path
1096        .file_name()
1097        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
1098    let temp_name = {
1099        let mut name = std::ffi::OsString::from(".aft-hashline-");
1100        name.push(file_name);
1101        name.push(".tmp");
1102        name
1103    };
1104    let temp_path = parent.join(temp_name);
1105
1106    let write_result = (|| {
1107        let mut file = OpenOptions::new()
1108            .write(true)
1109            .create(true)
1110            .truncate(true)
1111            .open(&temp_path)?;
1112        file.write_all(bytes)?;
1113        file.sync_all()
1114            .map_err(|error| io::Error::new(error.kind(), format!("durability: {error}")))?;
1115        fs::rename(&temp_path, path)?;
1116        // Best-effort directory durability after the rename.
1117        if let Ok(dir) = File::open(parent) {
1118            let _ = dir.sync_all();
1119        }
1120        Ok(())
1121    })();
1122
1123    if write_result.is_err() {
1124        let _ = fs::remove_file(&temp_path);
1125    }
1126    write_result
1127}
1128
1129// ── Outcome helpers ──────────────────────────────────────────────────────────
1130
1131fn failed_outcome(
1132    path: &Path,
1133    requested: &str,
1134    role: FileRole,
1135    classification: FileClassification,
1136    remove_file: bool,
1137    warnings: Vec<String>,
1138) -> FileOutcome {
1139    FileOutcome {
1140        canonical_path: path.to_path_buf(),
1141        requested_path: requested.to_string(),
1142        role,
1143        classification,
1144        mutation_state: classification.mutation_state(),
1145        final_bytes: None,
1146        final_tag: None,
1147        affected: AffectedRegion::default(),
1148        warnings,
1149        format_skipped_reason: None,
1150        backup_id: None,
1151        remove_file,
1152        tag_notice: None,
1153    }
1154}
1155
1156fn mv_failed_pair(
1157    mv: &PlannedMv,
1158    dest_class: FileClassification,
1159    source_class: FileClassification,
1160) -> Vec<FileOutcome> {
1161    vec![
1162        failed_outcome(
1163            &mv.dest_canonical,
1164            &mv.dest_requested,
1165            FileRole::MvDestination,
1166            dest_class,
1167            false,
1168            mv.warnings.clone(),
1169        ),
1170        failed_outcome(
1171            &mv.source_canonical,
1172            &mv.source_requested,
1173            FileRole::MvSource,
1174            source_class,
1175            false,
1176            Vec::new(),
1177        ),
1178    ]
1179}
1180
1181fn not_attempted_for_step(step: &PlannedStep) -> Vec<FileOutcome> {
1182    match step {
1183        PlannedStep::Mutate(file) => vec![failed_outcome(
1184            &file.canonical_path,
1185            &file.requested_path,
1186            FileRole::Primary,
1187            FileClassification::NotAttempted,
1188            file.remove_file,
1189            Vec::new(),
1190        )],
1191        PlannedStep::Mv(mv) => mv_failed_pair(
1192            mv,
1193            FileClassification::NotAttempted,
1194            FileClassification::NotAttempted,
1195        ),
1196    }
1197}
1198
1199fn fault_is(ctx: &ExecuteContext<'_>, want: ExecuteFault) -> bool {
1200    ctx.fault.as_ref() == Some(&want)
1201}
1202
1203fn counts_toward_completion(file: &FileOutcome) -> bool {
1204    // Successful source removal is a companion row on an already-counted MV dest.
1205    !(file.role == FileRole::MvSource && file.classification.is_applied_star())
1206}
1207
1208fn summary_counts(files: &[FileOutcome]) -> String {
1209    let primary: Vec<_> = files
1210        .iter()
1211        .filter(|file| counts_toward_completion(file))
1212        .collect();
1213    let applied = primary
1214        .iter()
1215        .filter(|file| file.classification.is_applied_star())
1216        .count();
1217    let total = primary.len();
1218    format!("{applied} of {total} files applied")
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use super::*;
1224    use crate::backup::BackupPolicy;
1225    use crate::hashline::scan::scan_bytes;
1226    use crate::hashline::snapshot::{capture_taggable_read, ReadPublication, ReadSelection};
1227    use crate::hashline::syntax::{
1228        parse_address, resolve_address, resolve_snapshot, CutOperation, PutOperation, PutSource,
1229        RegisterRef, ResolvedAddress,
1230    };
1231
1232    const SESSION: &str = "hashline-tx-test";
1233
1234    fn whole_snapshot(bytes: &[u8]) -> Snapshot {
1235        scan_bytes(bytes)
1236    }
1237
1238    fn put_text(address: &str, body: &[&str]) -> Operation {
1239        Operation::Put(PutOperation {
1240            address: parse_address(address).unwrap(),
1241            source: PutSource::Text(body.iter().map(|line| (*line).to_string()).collect()),
1242            line: 1,
1243        })
1244    }
1245
1246    fn resolve_one(snapshot: &Snapshot, operation: &Operation) -> ResolvedOperation {
1247        let address = match operation.address() {
1248            Some(address) => resolve_address(address, snapshot).unwrap(),
1249            None => ResolvedAddress::WholeFile,
1250        };
1251        ResolvedOperation {
1252            operation_index: 0,
1253            address,
1254        }
1255    }
1256
1257    fn write_file(path: &Path, bytes: &[u8]) {
1258        if let Some(parent) = path.parent() {
1259            fs::create_dir_all(parent).unwrap();
1260        }
1261        fs::write(path, bytes).unwrap();
1262    }
1263
1264    fn backup_store(dir: &Path) -> BackupStore {
1265        let mut store = BackupStore::new();
1266        store.set_storage_dir(dir.to_path_buf(), 72);
1267        store
1268    }
1269
1270    fn ctx<'a>(
1271        backups: &'a mut BackupStore,
1272        snapshots: &'a mut SnapshotStore,
1273        registers: &'a mut RegisterStore,
1274        backups_enabled: bool,
1275        fault: Option<ExecuteFault>,
1276    ) -> ExecuteContext<'a> {
1277        ExecuteContext {
1278            session: SESSION,
1279            backups,
1280            snapshots,
1281            registers,
1282            backups_enabled,
1283            fault,
1284        }
1285    }
1286
1287    fn section_put<'a>(
1288        path: &'a Path,
1289        requested: &'a str,
1290        baseline: &'a Baseline,
1291        snapshot: &'a Snapshot,
1292        ops: &'a [Operation],
1293        resolved: &'a [ResolvedOperation],
1294    ) -> TransactionSectionInput<'a> {
1295        TransactionSectionInput {
1296            canonical_path: path,
1297            requested_path: requested,
1298            baseline,
1299            snapshot,
1300            operations: ops,
1301            resolved,
1302            mv_destination: None,
1303        }
1304    }
1305
1306    fn put_after_reads(
1307        selections: impl IntoIterator<Item = ReadSelection>,
1308    ) -> Result<Vec<u8>, HashlineRejection> {
1309        let temp = tempfile::tempdir().unwrap();
1310        let path = temp.path().join("reread.txt");
1311        let original = b"one\ntwo\nthree\nfour\n";
1312        write_file(&path, original);
1313
1314        let mut snapshots = SnapshotStore::new();
1315        let mut tag = None;
1316        for selection in selections {
1317            let publication =
1318                capture_taggable_read(&mut snapshots, &path, "reread.txt", selection).unwrap();
1319            let ReadPublication::Tagged { snapshot, .. } = publication else {
1320                panic!("fixture read must publish a tagged snapshot");
1321            };
1322            tag.get_or_insert(snapshot.tag);
1323        }
1324
1325        let snapshot = resolve_snapshot(
1326            &mut snapshots,
1327            &path,
1328            tag.as_deref().expect("at least one read selection"),
1329        )?;
1330        let baseline = Baseline::from_bytes(original.to_vec());
1331        let operations = vec![put_text("2", &["TWO"])];
1332        let resolved = vec![resolve_one(&snapshot, &operations[0])];
1333        let sections = [section_put(
1334            &path,
1335            "reread.txt",
1336            &baseline,
1337            &snapshot,
1338            &operations,
1339            &resolved,
1340        )];
1341        let session_registers = RegisterStore::new();
1342        let plan = plan_transaction(&sections, &session_registers, true)?;
1343        let mut backups = backup_store(&temp.path().join("backups"));
1344        let mut execution_registers = RegisterStore::new();
1345        let mut execution = ctx(
1346            &mut backups,
1347            &mut snapshots,
1348            &mut execution_registers,
1349            true,
1350            None,
1351        );
1352        let envelope = execute_transaction(plan, &mut execution);
1353        assert!(envelope.success);
1354        assert!(envelope.complete);
1355        Ok(fs::read(path).unwrap())
1356    }
1357
1358    #[test]
1359    fn failed_transaction_preserves_baseline_for_reread_then_edit() {
1360        let temp = tempfile::tempdir().unwrap();
1361        let path = temp.path().join("after-failure.py");
1362        let original = (1..=130)
1363            .map(|line| format!("line_{line} = {line}\n"))
1364            .collect::<String>();
1365        write_file(&path, original.as_bytes());
1366
1367        let mut snapshots = SnapshotStore::new();
1368        let publication = capture_taggable_read(
1369            &mut snapshots,
1370            &path,
1371            "after-failure.py",
1372            ReadSelection::WholeFile,
1373        )
1374        .unwrap();
1375        let ReadPublication::Tagged { snapshot, .. } = publication else {
1376            panic!("fixture read must publish a tagged snapshot");
1377        };
1378        let tag = snapshot.tag.clone();
1379        let baseline = Baseline::from_bytes(original.as_bytes().to_vec());
1380        let operations = vec![put_text("16", &["line_16 = 160"])];
1381        let resolved = vec![resolve_one(&snapshot, &operations[0])];
1382        let sections = [section_put(
1383            &path,
1384            "after-failure.py",
1385            &baseline,
1386            &snapshot,
1387            &operations,
1388            &resolved,
1389        )];
1390        let mut backups = backup_store(&temp.path().join("backups"));
1391        let mut registers = RegisterStore::new();
1392        let plan = plan_transaction(&sections, &registers, true).unwrap();
1393        let failed = {
1394            let mut execution = ctx(
1395                &mut backups,
1396                &mut snapshots,
1397                &mut registers,
1398                true,
1399                Some(ExecuteFault::Write { step: 0 }),
1400            );
1401            execute_transaction(plan, &mut execution)
1402        };
1403        assert!(!failed.success);
1404        assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
1405        assert!(snapshots
1406            .lookup(&path, &tag)
1407            .expect("failed apply must preserve the baseline snapshot")
1408            .is_seen(16));
1409
1410        let reread = capture_taggable_read(
1411            &mut snapshots,
1412            &path,
1413            "after-failure.py",
1414            ReadSelection::WholeFile,
1415        )
1416        .unwrap();
1417        let ReadPublication::Tagged {
1418            snapshot: reread_snapshot,
1419            ..
1420        } = reread
1421        else {
1422            panic!("reread must publish a tagged snapshot");
1423        };
1424        assert_eq!(reread_snapshot.tag, tag);
1425        let next_baseline = Baseline::from_bytes(original.as_bytes().to_vec());
1426        let next_operations = vec![put_text("16", &["line_16 = 160"])];
1427        let next_resolved = vec![resolve_one(&reread_snapshot, &next_operations[0])];
1428        let next_sections = [section_put(
1429            &path,
1430            "after-failure.py",
1431            &next_baseline,
1432            &reread_snapshot,
1433            &next_operations,
1434            &next_resolved,
1435        )];
1436        let next_plan = plan_transaction(&next_sections, &registers, true).unwrap();
1437        let applied = {
1438            let mut execution = ctx(&mut backups, &mut snapshots, &mut registers, true, None);
1439            execute_transaction(next_plan, &mut execution)
1440        };
1441        assert!(applied.success);
1442        assert!(applied.complete);
1443        assert_eq!(
1444            fs::read_to_string(path).unwrap().lines().nth(15),
1445            Some("line_16 = 160")
1446        );
1447    }
1448
1449    #[test]
1450    fn two_ranged_reads_of_one_version_then_put_applies() {
1451        let bytes = put_after_reads([ReadSelection::range(1, 2), ReadSelection::range(3, 4)])
1452            .expect("same-version ranged reads must resolve");
1453        assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1454    }
1455
1456    #[test]
1457    fn ranged_then_whole_read_of_one_version_then_put_applies() {
1458        let bytes = put_after_reads([ReadSelection::range(2, 2), ReadSelection::WholeFile])
1459            .expect("same-version ranged and whole reads must resolve");
1460        assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1461    }
1462
1463    #[test]
1464    fn second_read_without_intervening_mutation_does_not_enter_refusal_loop() {
1465        let bytes = put_after_reads([ReadSelection::range(1, 1), ReadSelection::range(2, 2)])
1466            .expect("a second read of unchanged content must leave the tag editable");
1467        assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1468    }
1469
1470    /// A8: Phase 1 is mutation-free; Phase 2 is patch-ordered with honest envelopes.
1471    #[test]
1472    fn a8_phase1_mutation_free_and_phase2_ordered() {
1473        let temp = tempfile::tempdir().unwrap();
1474        let a = temp.path().join("a.txt");
1475        let b = temp.path().join("b.txt");
1476        write_file(&a, b"alpha\n");
1477        write_file(&b, b"beta\n");
1478        let bytes_a = fs::read(&a).unwrap();
1479        let bytes_b = fs::read(&b).unwrap();
1480        let snap_a = whole_snapshot(&bytes_a);
1481        let snap_b = whole_snapshot(&bytes_b);
1482        let base_a = Baseline::from_bytes(bytes_a.clone());
1483        let base_b = Baseline::from_bytes(bytes_b.clone());
1484        let ops_a = vec![put_text("1", &["ALPHA"])];
1485        let ops_b = vec![put_text("1", &["BETA"])];
1486        let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1487        let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1488        let sections = [
1489            section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1490            section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1491        ];
1492        let registers = RegisterStore::new();
1493        let plan = plan_transaction(&sections, &registers, true).expect("phase1");
1494        // Phase 1 left disk untouched.
1495        assert_eq!(fs::read(&a).unwrap(), b"alpha\n");
1496        assert_eq!(fs::read(&b).unwrap(), b"beta\n");
1497        assert_eq!(plan.steps.len(), 2);
1498
1499        let backup_dir = temp.path().join("backups");
1500        let mut backups = backup_store(&backup_dir);
1501        let mut snapshots = SnapshotStore::new();
1502        let mut session_regs = RegisterStore::new();
1503        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1504        let envelope = execute_transaction(plan, &mut exec);
1505        assert!(envelope.success);
1506        assert!(envelope.complete);
1507        assert!(envelope.op_id.is_some());
1508        assert_eq!(envelope.files.len(), 2);
1509        assert_eq!(envelope.files[0].requested_path, "a.txt");
1510        assert_eq!(envelope.files[1].requested_path, "b.txt");
1511        assert_eq!(
1512            envelope.files[0].classification,
1513            FileClassification::Applied
1514        );
1515        assert_eq!(envelope.files[0].mutation_state, MutationState::Applied);
1516        assert_eq!(fs::read(&a).unwrap(), b"ALPHA\n");
1517        assert_eq!(fs::read(&b).unwrap(), b"BETA\n");
1518        assert!(envelope.summary_text.contains("2 of 2 files applied"));
1519
1520        // One real aft_safety undo restores both files under the shared op_id.
1521        let op_id = envelope.op_id.clone().unwrap();
1522        let restored = backups.restore_last_operation(SESSION).unwrap();
1523        assert_eq!(restored.op_id, op_id);
1524        assert_eq!(fs::read(&a).unwrap(), b"alpha\n");
1525        assert_eq!(fs::read(&b).unwrap(), b"beta\n");
1526    }
1527
1528    /// A8: all-failed Phase 2 returns success:false with the complete envelope.
1529    #[test]
1530    fn a8_all_failed_emits_success_false_with_envelope() {
1531        let temp = tempfile::tempdir().unwrap();
1532        let a = temp.path().join("a.txt");
1533        let b = temp.path().join("b.txt");
1534        write_file(&a, b"a\n");
1535        write_file(&b, b"b\n");
1536        let bytes_a = fs::read(&a).unwrap();
1537        let bytes_b = fs::read(&b).unwrap();
1538        let snap_a = whole_snapshot(&bytes_a);
1539        let snap_b = whole_snapshot(&bytes_b);
1540        let base_a = Baseline::from_bytes(bytes_a);
1541        let base_b = Baseline::from_bytes(bytes_b);
1542        let ops_a = vec![put_text("1", &["A"])];
1543        let ops_b = vec![put_text("1", &["B"])];
1544        let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1545        let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1546        let sections = [
1547            section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1548            section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1549        ];
1550        let registers = RegisterStore::new();
1551        let plan = plan_transaction(&sections, &registers, true).unwrap();
1552
1553        let mut backups = backup_store(&temp.path().join("backups"));
1554        let mut snapshots = SnapshotStore::new();
1555        let mut session_regs = RegisterStore::new();
1556        let mut exec = ctx(
1557            &mut backups,
1558            &mut snapshots,
1559            &mut session_regs,
1560            true,
1561            Some(ExecuteFault::BaselineDrift { step: 0 }),
1562        );
1563        let envelope = execute_transaction(plan, &mut exec);
1564        assert!(!envelope.success);
1565        assert!(!envelope.complete);
1566        assert_eq!(
1567            envelope.files[0].classification,
1568            FileClassification::FailedBaselineDrift
1569        );
1570        assert_eq!(envelope.files[0].mutation_state, MutationState::Unmutated);
1571        assert_eq!(
1572            envelope.files[1].classification,
1573            FileClassification::NotAttempted
1574        );
1575        assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
1576        assert_eq!(envelope.stop_reason, Some("hashline_baseline_drift"));
1577        assert!(envelope.summary_text.starts_with("0 of 2 files applied"));
1578        // Journal runs before baseline recheck, so a drift stop after backup still
1579        // yields op_id. Disk bytes remain unchanged (unmutated).
1580        assert!(envelope.op_id.is_some());
1581        assert_eq!(fs::read(&a).unwrap(), b"a\n");
1582        assert_eq!(fs::read(&b).unwrap(), b"b\n");
1583    }
1584
1585    /// A8: partial failure keeps earlier applications under a shared op_id.
1586    #[test]
1587    fn a8_partial_failure_keeps_prior_under_shared_op_id() {
1588        let temp = tempfile::tempdir().unwrap();
1589        let a = temp.path().join("a.txt");
1590        let b = temp.path().join("b.txt");
1591        write_file(&a, b"a\n");
1592        write_file(&b, b"b\n");
1593        let bytes_a = fs::read(&a).unwrap();
1594        let bytes_b = fs::read(&b).unwrap();
1595        let snap_a = whole_snapshot(&bytes_a);
1596        let snap_b = whole_snapshot(&bytes_b);
1597        let base_a = Baseline::from_bytes(bytes_a);
1598        let base_b = Baseline::from_bytes(bytes_b);
1599        let ops_a = vec![put_text("1", &["A"])];
1600        let ops_b = vec![put_text("1", &["B"])];
1601        let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1602        let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1603        let sections = [
1604            section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1605            section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1606        ];
1607        let registers = RegisterStore::new();
1608        let plan = plan_transaction(&sections, &registers, true).unwrap();
1609
1610        let mut backups = backup_store(&temp.path().join("backups"));
1611        let mut snapshots = SnapshotStore::new();
1612        let mut session_regs = RegisterStore::new();
1613        let mut exec = ctx(
1614            &mut backups,
1615            &mut snapshots,
1616            &mut session_regs,
1617            true,
1618            Some(ExecuteFault::Write { step: 1 }),
1619        );
1620        let envelope = execute_transaction(plan, &mut exec);
1621        assert!(envelope.success);
1622        assert!(!envelope.complete);
1623        assert!(envelope.op_id.is_some());
1624        assert_eq!(
1625            envelope.files[0].classification,
1626            FileClassification::Applied
1627        );
1628        assert_eq!(
1629            envelope.files[1].classification,
1630            FileClassification::FailedWrite
1631        );
1632        assert_eq!(
1633            envelope.files[1].mutation_state,
1634            MutationState::UnknownPossiblyMutated
1635        );
1636        assert_eq!(fs::read(&a).unwrap(), b"A\n");
1637        assert_eq!(fs::read(&b).unwrap(), b"b\n");
1638
1639        let op_id = envelope.op_id.unwrap();
1640        let restored = backups.restore_last_operation(SESSION).unwrap();
1641        assert_eq!(restored.op_id, op_id);
1642        assert_eq!(fs::read(&a).unwrap(), b"a\n");
1643    }
1644
1645    /// A8: MV destination durability before source unlink; both destination shapes.
1646    #[test]
1647    fn a8_mv_new_and_existing_destination_with_undo() {
1648        let temp = tempfile::tempdir().unwrap();
1649        let src = temp.path().join("src.txt");
1650        let new_dest = temp.path().join("new_dest.txt");
1651        write_file(&src, b"move-me\n");
1652        let bytes = fs::read(&src).unwrap();
1653        let snap = whole_snapshot(&bytes);
1654        let base = Baseline::from_bytes(bytes.clone());
1655        let ops = vec![Operation::Mv(MvOperation {
1656            destination: "new_dest.txt".into(),
1657            line: 1,
1658        })];
1659        // MV has no address; resolved slot is WholeFile.
1660        let resolved = vec![ResolvedOperation {
1661            operation_index: 0,
1662            address: ResolvedAddress::WholeFile,
1663        }];
1664        let sections = [TransactionSectionInput {
1665            canonical_path: &src,
1666            requested_path: "src.txt",
1667            baseline: &base,
1668            snapshot: &snap,
1669            operations: &ops,
1670            resolved: &resolved,
1671            mv_destination: Some(MvDestinationInput {
1672                canonical_path: &new_dest,
1673                requested_path: "new_dest.txt",
1674                baseline_bytes: None,
1675            }),
1676        }];
1677        let registers = RegisterStore::new();
1678        let plan = plan_transaction(&sections, &registers, true).unwrap();
1679        let mut backups = backup_store(&temp.path().join("backups"));
1680        let mut snapshots = SnapshotStore::new();
1681        // Seed a source snapshot so invalidation is observable.
1682        snapshots.publish(&src, snap.clone());
1683        let mut session_regs = RegisterStore::new();
1684        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1685        let envelope = execute_transaction(plan, &mut exec);
1686        assert!(envelope.success && envelope.complete);
1687        assert!(envelope.op_id.is_some());
1688        assert_eq!(envelope.files[0].role, FileRole::MvDestination);
1689        assert_eq!(envelope.files[1].role, FileRole::MvSource);
1690        assert!(envelope.files[0].final_tag.is_some());
1691        assert!(envelope.files[1].remove_file);
1692        assert_eq!(fs::read(&new_dest).unwrap(), b"move-me\n");
1693        assert!(!src.exists());
1694        // Source snapshots cleared without eviction history.
1695        assert!(snapshots.lookup(&src, &snap.tag).is_err());
1696
1697        let op_id = envelope.op_id.unwrap();
1698        let restored = backups.restore_last_operation(SESSION).unwrap();
1699        assert_eq!(restored.op_id, op_id);
1700        assert_eq!(fs::read(&src).unwrap(), b"move-me\n");
1701        assert!(!new_dest.exists(), "created destination removed on undo");
1702
1703        // Existing destination shape.
1704        let src2 = temp.path().join("src2.txt");
1705        let dest2 = temp.path().join("dest2.txt");
1706        write_file(&src2, b"from\n");
1707        write_file(&dest2, b"old-dest\n");
1708        let bytes2 = fs::read(&src2).unwrap();
1709        let snap2 = whole_snapshot(&bytes2);
1710        let base2 = Baseline::from_bytes(bytes2);
1711        let dest_bytes = fs::read(&dest2).unwrap();
1712        let ops2 = vec![Operation::Mv(MvOperation {
1713            destination: "dest2.txt".into(),
1714            line: 1,
1715        })];
1716        let resolved2 = vec![ResolvedOperation {
1717            operation_index: 0,
1718            address: ResolvedAddress::WholeFile,
1719        }];
1720        let sections2 = [TransactionSectionInput {
1721            canonical_path: &src2,
1722            requested_path: "src2.txt",
1723            baseline: &base2,
1724            snapshot: &snap2,
1725            operations: &ops2,
1726            resolved: &resolved2,
1727            mv_destination: Some(MvDestinationInput {
1728                canonical_path: &dest2,
1729                requested_path: "dest2.txt",
1730                baseline_bytes: Some(&dest_bytes),
1731            }),
1732        }];
1733        let plan2 = plan_transaction(&sections2, &registers, true).unwrap();
1734        let mut exec2 = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1735        let envelope2 = execute_transaction(plan2, &mut exec2);
1736        assert!(envelope2.success);
1737        assert_eq!(fs::read(&dest2).unwrap(), b"from\n");
1738        assert!(!src2.exists());
1739        let restored2 = backups.restore_last_operation(SESSION).unwrap();
1740        assert_eq!(restored2.op_id, envelope2.op_id.unwrap());
1741        assert_eq!(fs::read(&src2).unwrap(), b"from\n");
1742        assert_eq!(fs::read(&dest2).unwrap(), b"old-dest\n");
1743    }
1744
1745    /// A8: failed source unlink leaves destination applied under shared op_id.
1746    #[test]
1747    fn a8_mv_source_unlink_failure_is_partial_mv() {
1748        let temp = tempfile::tempdir().unwrap();
1749        let src = temp.path().join("src.txt");
1750        let dest = temp.path().join("dest.txt");
1751        write_file(&src, b"body\n");
1752        let bytes = fs::read(&src).unwrap();
1753        let snap = whole_snapshot(&bytes);
1754        let base = Baseline::from_bytes(bytes);
1755        let ops = vec![Operation::Mv(MvOperation {
1756            destination: "dest.txt".into(),
1757            line: 1,
1758        })];
1759        let resolved = vec![ResolvedOperation {
1760            operation_index: 0,
1761            address: ResolvedAddress::WholeFile,
1762        }];
1763        let sections = [TransactionSectionInput {
1764            canonical_path: &src,
1765            requested_path: "src.txt",
1766            baseline: &base,
1767            snapshot: &snap,
1768            operations: &ops,
1769            resolved: &resolved,
1770            mv_destination: Some(MvDestinationInput {
1771                canonical_path: &dest,
1772                requested_path: "dest.txt",
1773                baseline_bytes: None,
1774            }),
1775        }];
1776        let registers = RegisterStore::new();
1777        let plan = plan_transaction(&sections, &registers, true).unwrap();
1778        let mut backups = backup_store(&temp.path().join("backups"));
1779        let mut snapshots = SnapshotStore::new();
1780        let mut session_regs = RegisterStore::new();
1781        let mut exec = ctx(
1782            &mut backups,
1783            &mut snapshots,
1784            &mut session_regs,
1785            true,
1786            Some(ExecuteFault::SourceUnlink { step: 0 }),
1787        );
1788        let envelope = execute_transaction(plan, &mut exec);
1789        assert!(envelope.success);
1790        assert!(!envelope.complete);
1791        assert!(envelope.op_id.is_some());
1792        assert_eq!(
1793            envelope.files[0].classification,
1794            FileClassification::Applied
1795        );
1796        assert_eq!(
1797            envelope.files[1].classification,
1798            FileClassification::FailedSourceUnlink
1799        );
1800        assert_eq!(envelope.files[1].mutation_state, MutationState::PartialMv);
1801        assert_eq!(fs::read(&dest).unwrap(), b"body\n");
1802        assert!(src.exists(), "source remains after unlink failure");
1803    }
1804
1805    /// A8: registers commit only when every planned primary file is applied*.
1806    #[test]
1807    fn a8_register_commit_only_when_all_applied() {
1808        let temp = tempfile::tempdir().unwrap();
1809        let a = temp.path().join("a.txt");
1810        write_file(&a, b"one\ntwo\n");
1811        let bytes = fs::read(&a).unwrap();
1812        let snap = whole_snapshot(&bytes);
1813        let base = Baseline::from_bytes(bytes);
1814        let ops = vec![Operation::Cut(crate::hashline::syntax::CutOperation {
1815            address: parse_address("1").unwrap(),
1816            register: Some(RegisterRef::Named("clip".into())),
1817            line: 1,
1818        })];
1819        let resolved = vec![resolve_one(&snap, &ops[0])];
1820        let sections = [section_put(&a, "a.txt", &base, &snap, &ops, &resolved)];
1821        let registers = RegisterStore::new();
1822        let plan = plan_transaction(&sections, &registers, true).unwrap();
1823
1824        let mut backups = backup_store(&temp.path().join("backups"));
1825        let mut snapshots = SnapshotStore::new();
1826        let mut session_regs = RegisterStore::new();
1827        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1828        let envelope = execute_transaction(plan, &mut exec);
1829        assert!(envelope.registers_committed);
1830        assert_eq!(
1831            session_regs.get(&RegisterRef::Named("clip".into())),
1832            Some(["one".to_string()].as_slice())
1833        );
1834
1835        // Failure path discards staged captures.
1836        write_file(&a, b"one\ntwo\n");
1837        let plan2 = plan_transaction(&sections, &RegisterStore::new(), true).unwrap();
1838        let mut session_regs2 = RegisterStore::new();
1839        let mut exec2 = ctx(
1840            &mut backups,
1841            &mut snapshots,
1842            &mut session_regs2,
1843            true,
1844            Some(ExecuteFault::Write { step: 0 }),
1845        );
1846        let envelope2 = execute_transaction(plan2, &mut exec2);
1847        assert!(!envelope2.registers_committed);
1848        assert!(session_regs2
1849            .get(&RegisterRef::Named("clip".into()))
1850            .is_none());
1851    }
1852
1853    /// A8: applied_with_validation_failure and applied_tag_unavailable.
1854    #[test]
1855    fn a8_applied_star_variants() {
1856        let temp = tempfile::tempdir().unwrap();
1857        let path = temp.path().join("v.txt");
1858        write_file(&path, b"x\n");
1859        let bytes = fs::read(&path).unwrap();
1860        let snap = whole_snapshot(&bytes);
1861        let base = Baseline::from_bytes(bytes);
1862        let ops = vec![put_text("1", &["Y"])];
1863        let resolved = vec![resolve_one(&snap, &ops[0])];
1864        let sections = [section_put(&path, "v.txt", &base, &snap, &ops, &resolved)];
1865        let registers = RegisterStore::new();
1866
1867        let mut backups = backup_store(&temp.path().join("backups"));
1868        let mut snapshots = SnapshotStore::new();
1869        let mut session_regs = RegisterStore::new();
1870        let plan = plan_transaction(&sections, &registers, true).unwrap();
1871        let mut exec = ctx(
1872            &mut backups,
1873            &mut snapshots,
1874            &mut session_regs,
1875            true,
1876            Some(ExecuteFault::ValidationFailure { step: 0 }),
1877        );
1878        let envelope = execute_transaction(plan, &mut exec);
1879        assert_eq!(
1880            envelope.files[0].classification,
1881            FileClassification::AppliedWithValidationFailure
1882        );
1883        assert_eq!(fs::read(&path).unwrap(), b"Y\n");
1884
1885        write_file(&path, b"x\n");
1886        let bytes = fs::read(&path).unwrap();
1887        let snap = whole_snapshot(&bytes);
1888        let base = Baseline::from_bytes(bytes);
1889        let sections = [section_put(&path, "v.txt", &base, &snap, &ops, &resolved)];
1890        let plan = plan_transaction(&sections, &registers, true).unwrap();
1891        let mut exec = ctx(
1892            &mut backups,
1893            &mut snapshots,
1894            &mut session_regs,
1895            true,
1896            Some(ExecuteFault::FinalTagUnavailable { step: 0 }),
1897        );
1898        let envelope = execute_transaction(plan, &mut exec);
1899        assert_eq!(
1900            envelope.files[0].classification,
1901            FileClassification::AppliedTagUnavailable
1902        );
1903        assert!(envelope.files[0].final_tag.is_none());
1904        assert!(envelope.files[0].tag_notice.is_some());
1905    }
1906
1907    /// A10: preview mutates nothing — files, snapshots, backups, registers, op_id.
1908    #[test]
1909    fn a10_preview_mutates_nothing() {
1910        let temp = tempfile::tempdir().unwrap();
1911        let path = temp.path().join("p.txt");
1912        let dest = temp.path().join("p-dest.txt");
1913        write_file(&path, b"preview\n");
1914        let bytes = fs::read(&path).unwrap();
1915        let snap = whole_snapshot(&bytes);
1916        let base = Baseline::from_bytes(bytes.clone());
1917        let ops = vec![
1918            put_text("1", &["PREVIEWED"]),
1919            Operation::Mv(MvOperation {
1920                destination: "p-dest.txt".into(),
1921                line: 2,
1922            }),
1923        ];
1924        let resolved = vec![
1925            resolve_one(&snap, &ops[0]),
1926            ResolvedOperation {
1927                operation_index: 1,
1928                address: ResolvedAddress::WholeFile,
1929            },
1930        ];
1931        let sections = [TransactionSectionInput {
1932            canonical_path: &path,
1933            requested_path: "p.txt",
1934            baseline: &base,
1935            snapshot: &snap,
1936            operations: &ops,
1937            resolved: &resolved,
1938            mv_destination: Some(MvDestinationInput {
1939                canonical_path: &dest,
1940                requested_path: "p-dest.txt",
1941                baseline_bytes: None,
1942            }),
1943        }];
1944        let mut registers = RegisterStore::new();
1945        // Seed a register so we can prove preview does not commit staged captures.
1946        {
1947            let mut staged = registers.stage();
1948            staged
1949                .capture(RegisterRef::Named("keep".into()), vec!["seed".into()])
1950                .unwrap();
1951            registers.commit(staged);
1952        }
1953        let plan = plan_transaction(&sections, &registers, true).unwrap();
1954        let before_reg = registers
1955            .get(&RegisterRef::Named("keep".into()))
1956            .map(|lines| lines.to_vec());
1957
1958        let backups = backup_store(&temp.path().join("backups"));
1959        let tracked_before = backups.tracked_files(SESSION);
1960        let mut snapshots = SnapshotStore::new();
1961        snapshots.publish(&path, snap.clone());
1962        let envelope = preview_transaction(plan);
1963
1964        assert!(envelope.preview);
1965        assert!(envelope.op_id.is_none());
1966        assert!(!envelope.registers_committed);
1967        assert_eq!(fs::read(&path).unwrap(), b"preview\n");
1968        assert!(!dest.exists());
1969        assert_eq!(backups.tracked_files(SESSION), tracked_before);
1970        assert!(snapshots.lookup(&path, &snap.tag).is_ok());
1971        assert_eq!(
1972            registers
1973                .get(&RegisterRef::Named("keep".into()))
1974                .map(|lines| lines.to_vec()),
1975            before_reg
1976        );
1977        assert!(envelope.files.iter().all(|f| f.final_tag.is_none()));
1978        assert!(envelope
1979            .files
1980            .iter()
1981            .all(|f| f.mutation_state == MutationState::Unmutated));
1982    }
1983
1984    #[test]
1985    fn mixed_patch_keeps_distinct_file_independent_and_same_path_pair_atomic() {
1986        let temp = tempfile::tempdir().unwrap();
1987        let distinct = temp.path().join("distinct.txt");
1988        let composed = temp.path().join("composed.txt");
1989        write_file(&distinct, b"distinct\n");
1990        write_file(&composed, b"one\ntwo\nthree\n");
1991        let distinct_bytes = fs::read(&distinct).unwrap();
1992        let composed_bytes = fs::read(&composed).unwrap();
1993        let distinct_snapshot = whole_snapshot(&distinct_bytes);
1994        let composed_snapshot = whole_snapshot(&composed_bytes);
1995        let distinct_baseline = Baseline::from_bytes(distinct_bytes);
1996        let composed_baseline = Baseline::from_bytes(composed_bytes);
1997        let distinct_ops = vec![put_text("1", &["changed"])];
1998        let first_cut = vec![Operation::Cut(CutOperation {
1999            address: parse_address("1").unwrap(),
2000            register: None,
2001            line: 2,
2002        })];
2003        let last_cut = vec![Operation::Cut(CutOperation {
2004            address: parse_address("3").unwrap(),
2005            register: None,
2006            line: 4,
2007        })];
2008        let distinct_resolved = vec![resolve_one(&distinct_snapshot, &distinct_ops[0])];
2009        let first_resolved = vec![resolve_one(&composed_snapshot, &first_cut[0])];
2010        let last_resolved = vec![resolve_one(&composed_snapshot, &last_cut[0])];
2011        let sections = [
2012            section_put(
2013                &distinct,
2014                "distinct.txt",
2015                &distinct_baseline,
2016                &distinct_snapshot,
2017                &distinct_ops,
2018                &distinct_resolved,
2019            ),
2020            section_put(
2021                &composed,
2022                "composed.txt",
2023                &composed_baseline,
2024                &composed_snapshot,
2025                &first_cut,
2026                &first_resolved,
2027            ),
2028            section_put(
2029                &composed,
2030                "composed.txt",
2031                &composed_baseline,
2032                &composed_snapshot,
2033                &last_cut,
2034                &last_resolved,
2035            ),
2036        ];
2037        let registers = RegisterStore::new();
2038        let plan = plan_transaction(&sections, &registers, true).unwrap();
2039        assert_eq!(plan.steps.len(), 2);
2040
2041        let mut backups = backup_store(&temp.path().join("backups"));
2042        let mut snapshots = SnapshotStore::new();
2043        let mut session_regs = RegisterStore::new();
2044        let mut exec = ctx(
2045            &mut backups,
2046            &mut snapshots,
2047            &mut session_regs,
2048            true,
2049            Some(ExecuteFault::BaselineDrift { step: 1 }),
2050        );
2051        let envelope = execute_transaction(plan, &mut exec);
2052
2053        assert!(envelope.success);
2054        assert!(!envelope.complete);
2055        assert_eq!(envelope.summary_text, "1 of 2 files applied");
2056        assert_eq!(envelope.files.len(), 2);
2057        assert_eq!(
2058            envelope.files[0].classification,
2059            FileClassification::Applied
2060        );
2061        assert_eq!(
2062            envelope.files[1].classification,
2063            FileClassification::FailedBaselineDrift
2064        );
2065        assert_eq!(fs::read(&distinct).unwrap(), b"changed\n");
2066        assert_eq!(fs::read(&composed).unwrap(), b"one\ntwo\nthree\n");
2067    }
2068
2069    /// A12: external writer between Phase 1 and Phase 2 write → baseline drift.
2070    #[test]
2071    fn a12_baseline_drift_stops_later_files_and_keeps_prior_op_id() {
2072        let temp = tempfile::tempdir().unwrap();
2073        let a = temp.path().join("a.txt");
2074        let b = temp.path().join("b.txt");
2075        write_file(&a, b"a0\n");
2076        write_file(&b, b"b0\n");
2077        let bytes_a = fs::read(&a).unwrap();
2078        let bytes_b = fs::read(&b).unwrap();
2079        let snap_a = whole_snapshot(&bytes_a);
2080        let snap_b = whole_snapshot(&bytes_b);
2081        let base_a = Baseline::from_bytes(bytes_a);
2082        let base_b = Baseline::from_bytes(bytes_b);
2083        let ops_a = vec![put_text("1", &["A1"])];
2084        let ops_b = vec![put_text("1", &["B1"])];
2085        let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
2086        let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
2087        let sections = [
2088            section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
2089            section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
2090        ];
2091        let registers = RegisterStore::new();
2092        let plan = plan_transaction(&sections, &registers, true).unwrap();
2093
2094        // External writer mutates b after Phase 1.
2095        write_file(&b, b"b-EXTERNAL\n");
2096
2097        let mut backups = backup_store(&temp.path().join("backups"));
2098        let mut snapshots = SnapshotStore::new();
2099        let mut session_regs = RegisterStore::new();
2100        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
2101        let envelope = execute_transaction(plan, &mut exec);
2102
2103        assert!(envelope.success);
2104        assert!(!envelope.complete);
2105        assert_eq!(
2106            envelope.files[0].classification,
2107            FileClassification::Applied
2108        );
2109        assert_eq!(
2110            envelope.files[1].classification,
2111            FileClassification::FailedBaselineDrift
2112        );
2113        assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
2114        assert_eq!(envelope.stop_reason, Some("hashline_baseline_drift"));
2115        assert!(envelope.op_id.is_some());
2116        assert_eq!(fs::read(&a).unwrap(), b"A1\n");
2117        assert_eq!(fs::read(&b).unwrap(), b"b-EXTERNAL\n");
2118
2119        let op_id = envelope.op_id.unwrap();
2120        let restored = backups.restore_last_operation(SESSION).unwrap();
2121        assert_eq!(restored.op_id, op_id);
2122        assert_eq!(fs::read(&a).unwrap(), b"a0\n");
2123    }
2124
2125    /// A17: backups disabled refuses PUT and MV-onto-existing; new-dest MV plans.
2126    #[test]
2127    fn a17_backup_failures_refuse_but_policy_skips_allow_new_dest_mv() {
2128        let temp = tempfile::tempdir().unwrap();
2129        let path = temp.path().join("t.txt");
2130        write_file(&path, b"t\n");
2131        let bytes = fs::read(&path).unwrap();
2132        let snap = whole_snapshot(&bytes);
2133        let base = Baseline::from_bytes(bytes);
2134        let ops = vec![put_text("1", &["T"])];
2135        let resolved = vec![resolve_one(&snap, &ops[0])];
2136        let sections = [section_put(&path, "t.txt", &base, &snap, &ops, &resolved)];
2137        let registers = RegisterStore::new();
2138        let err = plan_transaction(&sections, &registers, false).unwrap_err();
2139        assert_eq!(
2140            err.code,
2141            crate::hashline::syntax::HashlineRejectionCode::BackupUnavailable
2142        );
2143        assert_eq!(err.stage, crate::hashline::syntax::RejectionStage::Baseline);
2144        assert_eq!(fs::read(&path).unwrap(), b"t\n");
2145
2146        // MV onto existing destination refused.
2147        let src = temp.path().join("s.txt");
2148        let dest = temp.path().join("d.txt");
2149        write_file(&src, b"s\n");
2150        write_file(&dest, b"d\n");
2151        let s_bytes = fs::read(&src).unwrap();
2152        let d_bytes = fs::read(&dest).unwrap();
2153        let s_snap = whole_snapshot(&s_bytes);
2154        let s_base = Baseline::from_bytes(s_bytes);
2155        let mv_ops = vec![Operation::Mv(MvOperation {
2156            destination: "d.txt".into(),
2157            line: 1,
2158        })];
2159        let mv_resolved = vec![ResolvedOperation {
2160            operation_index: 0,
2161            address: ResolvedAddress::WholeFile,
2162        }];
2163        let mv_sections = [TransactionSectionInput {
2164            canonical_path: &src,
2165            requested_path: "s.txt",
2166            baseline: &s_base,
2167            snapshot: &s_snap,
2168            operations: &mv_ops,
2169            resolved: &mv_resolved,
2170            mv_destination: Some(MvDestinationInput {
2171                canonical_path: &dest,
2172                requested_path: "d.txt",
2173                baseline_bytes: Some(&d_bytes),
2174            }),
2175        }];
2176        let err = plan_transaction(&mv_sections, &registers, false).unwrap_err();
2177        assert_eq!(
2178            err.code,
2179            crate::hashline::syntax::HashlineRejectionCode::BackupUnavailable
2180        );
2181        assert_eq!(fs::read(&src).unwrap(), b"s\n");
2182        assert_eq!(fs::read(&dest).unwrap(), b"d\n");
2183
2184        // New-destination MV is allowed in Phase 1 even when the backups flag is
2185        // false; execution with a live BackupStore still journals a real op_id.
2186        let src2 = temp.path().join("s2.txt");
2187        let dest2 = temp.path().join("d2.txt");
2188        write_file(&src2, b"s2\n");
2189        let s2_bytes = fs::read(&src2).unwrap();
2190        let s2_snap = whole_snapshot(&s2_bytes);
2191        let s2_base = Baseline::from_bytes(s2_bytes);
2192        let mv2_ops = vec![Operation::Mv(MvOperation {
2193            destination: "d2.txt".into(),
2194            line: 1,
2195        })];
2196        let mv2_resolved = vec![ResolvedOperation {
2197            operation_index: 0,
2198            address: ResolvedAddress::WholeFile,
2199        }];
2200        let mv2_sections = [TransactionSectionInput {
2201            canonical_path: &src2,
2202            requested_path: "s2.txt",
2203            baseline: &s2_base,
2204            snapshot: &s2_snap,
2205            operations: &mv2_ops,
2206            resolved: &mv2_resolved,
2207            mv_destination: Some(MvDestinationInput {
2208                canonical_path: &dest2,
2209                requested_path: "d2.txt",
2210                baseline_bytes: None,
2211            }),
2212        }];
2213        let plan = plan_transaction(&mv2_sections, &registers, false).expect("new dest MV plans");
2214        let mut backups = backup_store(&temp.path().join("backups"));
2215        let mut snapshots = SnapshotStore::new();
2216        let mut session_regs = RegisterStore::new();
2217        // Execution uses a real (enabled) store so the created-file tombstone and
2218        // source backup produce a genuine undo identity — never a fabricated one.
2219        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, false, None);
2220        let envelope = execute_transaction(plan, &mut exec);
2221        assert!(envelope.success);
2222        assert!(envelope.op_id.is_some(), "real journaled op_id required");
2223        assert_eq!(fs::read(&dest2).unwrap(), b"s2\n");
2224        assert!(!src2.exists());
2225        let op_id = envelope.op_id.unwrap();
2226        let restored = backups.restore_last_operation(SESSION).unwrap();
2227        assert_eq!(restored.op_id, op_id);
2228        assert_eq!(fs::read(&src2).unwrap(), b"s2\n");
2229        assert!(!dest2.exists());
2230
2231        // A policy skip is not a backup I/O failure: the move proceeds, reports
2232        // why undo is unavailable, and never advertises an op_id it did not journal.
2233        let mut disabled = BackupStore::new();
2234        disabled.set_policy(BackupPolicy {
2235            enabled: false,
2236            ..BackupPolicy::default()
2237        });
2238        write_file(&src2, b"s2\n");
2239        // Reuse the prior section coordinates; Phase 1 only needs the baseline
2240        // bytes that still match the restored source contents.
2241        let plan = plan_transaction(&mv2_sections, &registers, false).unwrap();
2242        let mut snapshots = SnapshotStore::new();
2243        let mut session_regs = RegisterStore::new();
2244        let mut exec = ctx(
2245            &mut disabled,
2246            &mut snapshots,
2247            &mut session_regs,
2248            false,
2249            None,
2250        );
2251        let envelope = execute_transaction(plan, &mut exec);
2252        assert!(envelope.success);
2253        assert!(envelope.op_id.is_none());
2254        assert_eq!(fs::read(&dest2).unwrap(), b"s2\n");
2255        assert!(!src2.exists());
2256        assert_eq!(
2257            disabled.skipped_reason_after(SESSION, None),
2258            Some(crate::backup::BackupSkippedReason::Disabled)
2259        );
2260    }
2261
2262    /// Journal entry created before a later failure still yields op_id.
2263    #[test]
2264    fn op_id_present_when_journal_entry_exists_before_failure() {
2265        let temp = tempfile::tempdir().unwrap();
2266        let a = temp.path().join("a.txt");
2267        write_file(&a, b"a\n");
2268        let bytes = fs::read(&a).unwrap();
2269        let snap = whole_snapshot(&bytes);
2270        let base = Baseline::from_bytes(bytes);
2271        let ops = vec![put_text("1", &["A"])];
2272        let resolved = vec![resolve_one(&snap, &ops[0])];
2273        let sections = [section_put(&a, "a.txt", &base, &snap, &ops, &resolved)];
2274        let registers = RegisterStore::new();
2275        let plan = plan_transaction(&sections, &registers, true).unwrap();
2276        let mut backups = backup_store(&temp.path().join("backups"));
2277        let mut snapshots = SnapshotStore::new();
2278        let mut session_regs = RegisterStore::new();
2279        // Drift after journal: backup succeeds, write never happens, op_id remains.
2280        // Force drift by mutating after plan; backup still runs first in execute.
2281        write_file(&a, b"changed\n");
2282        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
2283        let envelope = execute_transaction(plan, &mut exec);
2284        assert!(!envelope.success);
2285        assert_eq!(
2286            envelope.files[0].classification,
2287            FileClassification::FailedBaselineDrift
2288        );
2289        // Backup is taken before baseline recheck, so op_id must be present.
2290        assert!(envelope.op_id.is_some());
2291        assert_eq!(fs::read(&a).unwrap(), b"changed\n");
2292    }
2293}