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