Skip to main content

aft/hashline/apply/
mod.rs

1//! In-memory hashline apply: PUT/CUT/REM, repair layers, registers, regions.
2//!
3//! This module owns Phase-1 planning of line mutations and the pure apply that
4//! produces final bytes. It does not open files, take backups, or mint
5//! snapshots — those belong to the transaction layer. Register commits are
6//! gated here so a later stopping failure can discard staged captures without
7//! ever publishing them to the session store.
8
9mod edits;
10mod region;
11mod registers;
12mod repair;
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16
17use crate::hashline::scan::{RawLineRecord, Snapshot};
18use crate::hashline::snapshot::AffectedRegion;
19use crate::hashline::syntax::{
20    verify_exact, Baseline, CutOperation, HashlineRejection, HashlineRejectionCode, LineSpan,
21    Operation, PutOperation, PutSource, RegisterRef, RejectionStage, RemOperation, ResolvedAddress,
22    ResolvedOperation, VerificationOutcome,
23};
24
25pub use edits::{
26    coalesce_replacement_edits, find_replacement_group, join_lines, materialize_edits,
27    terminator_policy, InsertMode, InsertPlace, LineEdit, ReplacementGroup,
28};
29pub use region::{affected_from_line_diff, build_affected_region, RegionDelta};
30pub use registers::{
31    RegisterLines, RegisterStore, RegisterWrite, StagedRegisters, MAX_NAMED_REGISTERS,
32    MAX_REGISTER_BYTES, MAX_REGISTER_TOTAL_BYTES,
33};
34pub use repair::{apply_repair_layers, replacement_group_from_payload, RepairOutcome};
35
36/// Canonical per-file Phase-2 classification enum.
37#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
38pub enum FileClassification {
39    Applied,
40    AppliedWithValidationFailure,
41    AppliedTagUnavailable,
42    FailedBackup,
43    FailedWrite,
44    FailedDurability,
45    FailedSourceUnlink,
46    FailedBaselineDrift,
47    NotAttempted,
48}
49
50impl FileClassification {
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::Applied => "applied",
54            Self::AppliedWithValidationFailure => "applied_with_validation_failure",
55            Self::AppliedTagUnavailable => "applied_tag_unavailable",
56            Self::FailedBackup => "failed_backup",
57            Self::FailedWrite => "failed_write",
58            Self::FailedDurability => "failed_durability",
59            Self::FailedSourceUnlink => "failed_source_unlink",
60            Self::FailedBaselineDrift => "failed_baseline_drift",
61            Self::NotAttempted => "not_attempted",
62        }
63    }
64
65    /// True for every `applied*` classification that authorizes register commit.
66    pub const fn is_applied_star(self) -> bool {
67        matches!(
68            self,
69            Self::Applied | Self::AppliedWithValidationFailure | Self::AppliedTagUnavailable
70        )
71    }
72
73    pub const fn is_stopping_failure(self) -> bool {
74        matches!(
75            self,
76            Self::FailedBackup
77                | Self::FailedWrite
78                | Self::FailedDurability
79                | Self::FailedSourceUnlink
80                | Self::FailedBaselineDrift
81        )
82    }
83
84    pub const fn mutation_state(self) -> MutationState {
85        match self {
86            Self::Applied | Self::AppliedWithValidationFailure | Self::AppliedTagUnavailable => {
87                MutationState::Applied
88            }
89            Self::FailedBackup | Self::FailedBaselineDrift | Self::NotAttempted => {
90                MutationState::Unmutated
91            }
92            Self::FailedWrite | Self::FailedDurability => MutationState::UnknownPossiblyMutated,
93            Self::FailedSourceUnlink => MutationState::PartialMv,
94        }
95    }
96}
97
98/// Required per-file mutation-state field paired with [`FileClassification`].
99#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
100pub enum MutationState {
101    Unmutated,
102    Applied,
103    UnknownPossiblyMutated,
104    PartialMv,
105}
106
107impl MutationState {
108    pub const fn as_str(self) -> &'static str {
109        match self {
110            Self::Unmutated => "unmutated",
111            Self::Applied => "applied",
112            Self::UnknownPossiblyMutated => "unknown_possibly_mutated",
113            Self::PartialMv => "partial_mv",
114        }
115    }
116}
117
118/// One file's pure apply plan: final bytes, affected region, and diagnostics.
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct PlannedFile {
121    pub canonical_path: PathBuf,
122    pub requested_path: String,
123    pub baseline_bytes: Vec<u8>,
124    pub final_bytes: Vec<u8>,
125    pub affected: AffectedRegion,
126    /// Whole-file removal (REM). Transaction layer deletes rather than writes.
127    pub remove_file: bool,
128    pub warnings: Vec<String>,
129    pub repair_layers: Vec<&'static str>,
130}
131
132/// Ordered Phase-1 plan for every section in a patch.
133#[derive(Clone, Debug)]
134pub struct ApplyPlan {
135    pub files: Vec<PlannedFile>,
136    pub staged_registers: StagedRegisters,
137}
138
139/// Ordered Phase-2 result envelope (without host display fields).
140#[derive(Clone, Debug, Eq, PartialEq)]
141pub struct ApplyResultEnvelope {
142    pub success: bool,
143    pub complete: bool,
144    pub files: Vec<FileResult>,
145    /// True when staged registers were published to the session store.
146    pub registers_committed: bool,
147}
148
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct FileResult {
151    pub canonical_path: PathBuf,
152    pub requested_path: String,
153    pub classification: FileClassification,
154    pub mutation_state: MutationState,
155    pub final_bytes: Option<Vec<u8>>,
156    pub affected: AffectedRegion,
157    pub warnings: Vec<String>,
158    pub remove_file: bool,
159}
160
161/// Inputs for planning one already-resolved section.
162#[derive(Clone, Debug)]
163pub struct SectionPlanInput<'a> {
164    pub canonical_path: &'a Path,
165    pub requested_path: &'a str,
166    pub baseline: &'a Baseline,
167    pub snapshot: &'a Snapshot,
168    pub operations: &'a [Operation],
169    pub resolved: &'a [ResolvedOperation],
170}
171
172/// Plan every canonical path without mutating the session register store or disk.
173///
174/// Every section is verified against its path's single pre-request baseline before
175/// byte planning begins. Repeated canonical paths then compose in patch order, but
176/// produce one final file plan and therefore one transaction write. This deliberately
177/// differs from the pinned oracle's duplicate-path refusal: retained tags make the
178/// pre-request coordinates unambiguous and avoid requiring agents to hand-merge
179/// sections. Any verification, eligibility, composition, or register-bound failure
180/// rejects the whole plan. Staged register captures remain local until
181/// [`commit_registers_if_complete`].
182pub fn plan_apply(
183    sections: &[SectionPlanInput<'_>],
184    session_registers: &RegisterStore,
185) -> Result<ApplyPlan, HashlineRejection> {
186    let mut section_counts = BTreeMap::<PathBuf, usize>::new();
187    let mut common_baselines = BTreeMap::<PathBuf, Vec<u8>>::new();
188
189    // Verification deliberately precedes all register staging and byte planning.
190    // A stale address in any repeated section rejects the entire request before a
191    // composed unit can acquire a backup or write its first intermediate result.
192    for section in sections {
193        let path = section.canonical_path.to_path_buf();
194        *section_counts.entry(path.clone()).or_default() += 1;
195        if let Some(existing) = common_baselines.get(&path) {
196            if existing != &section.baseline.bytes {
197                return Err(HashlineRejection::new(
198                    HashlineRejectionCode::StaleTag,
199                    RejectionStage::Baseline,
200                    "sections for one canonical path did not retain one Phase-1 baseline",
201                ));
202            }
203        } else {
204            common_baselines.insert(path, section.baseline.bytes.clone());
205        }
206        verify_section_against_pre_request_baseline(section)?;
207    }
208
209    let mut staged = session_registers.stage();
210    let mut slots = Vec::<ApplyPlanSlot>::new();
211    let mut slot_by_path = BTreeMap::<PathBuf, usize>::new();
212
213    for section in sections {
214        let path = section.canonical_path.to_path_buf();
215        if section_counts.get(&path).copied().unwrap_or_default() == 1 {
216            let planned = apply_section_ops(
217                section.requested_path,
218                section.canonical_path,
219                section.baseline,
220                section.operations,
221                section.resolved,
222                &mut staged,
223            )?;
224            slot_by_path.insert(path, slots.len());
225            slots.push(ApplyPlanSlot::Complete(planned));
226            continue;
227        }
228
229        let slot = if let Some(slot) = slot_by_path.get(&path).copied() {
230            slot
231        } else {
232            let slot = slots.len();
233            slots.push(ApplyPlanSlot::Composing(CompositionState::new(section)));
234            slot_by_path.insert(path, slot);
235            slot
236        };
237        let ApplyPlanSlot::Composing(state) = &mut slots[slot] else {
238            unreachable!("repeated canonical path always owns a composition state")
239        };
240        state.apply_section(section, &mut staged)?;
241    }
242
243    let files = slots
244        .into_iter()
245        .map(|slot| match slot {
246            ApplyPlanSlot::Complete(file) => Ok(file),
247            ApplyPlanSlot::Composing(state) => state.finish(),
248        })
249        .collect::<Result<Vec<_>, HashlineRejection>>()?;
250
251    Ok(ApplyPlan {
252        files,
253        staged_registers: staged,
254    })
255}
256
257fn verify_section_against_pre_request_baseline(
258    section: &SectionPlanInput<'_>,
259) -> Result<(), HashlineRejection> {
260    for resolved in section.resolved {
261        match verify_exact(section.snapshot, section.baseline, resolved.address) {
262            VerificationOutcome::Exact => {}
263            VerificationOutcome::RecoveryRequired(_) => {
264                return Err(HashlineRejection::new(
265                    HashlineRejectionCode::StaleTag,
266                    RejectionStage::Recovery,
267                    "addressed content no longer matches the Phase-1 baseline",
268                ));
269            }
270            VerificationOutcome::Rejected(rejection) => return Err(rejection),
271            VerificationOutcome::BlockNeedsResolution { .. } => {
272                return Err(HashlineRejection::new(
273                    HashlineRejectionCode::BoundaryIneligible,
274                    RejectionStage::Eligibility,
275                    "block address was not expanded before apply planning",
276                ));
277            }
278        }
279    }
280    Ok(())
281}
282
283#[derive(Clone, Debug)]
284enum ApplyPlanSlot {
285    Complete(PlannedFile),
286    Composing(CompositionState),
287}
288
289#[derive(Clone, Debug)]
290struct CompositionState {
291    canonical_path: PathBuf,
292    requested_path: String,
293    original: Baseline,
294    current: Baseline,
295    origins: Vec<LineOrigin>,
296    deleted_by: BTreeMap<usize, String>,
297    removed_by: Option<String>,
298    warnings: Vec<String>,
299    repair_layers: Vec<&'static str>,
300    remove_file: bool,
301}
302
303#[derive(Clone, Debug, Eq, PartialEq)]
304enum LineOrigin {
305    Original(usize),
306    Replacement { source: LineSpan, operation: String },
307    Inserted { gap: OriginalGap },
308}
309
310#[derive(Clone, Copy, Debug, Eq, PartialEq)]
311struct OriginalGap {
312    before: Option<usize>,
313    after: Option<usize>,
314}
315
316impl CompositionState {
317    fn new(section: &SectionPlanInput<'_>) -> Self {
318        Self {
319            canonical_path: section.canonical_path.to_path_buf(),
320            requested_path: section.requested_path.to_string(),
321            original: section.baseline.clone(),
322            current: section.baseline.clone(),
323            origins: (1..=section.baseline.snapshot.total_lines)
324                .map(LineOrigin::Original)
325                .collect(),
326            deleted_by: BTreeMap::new(),
327            removed_by: None,
328            warnings: Vec::new(),
329            repair_layers: Vec::new(),
330            remove_file: false,
331        }
332    }
333
334    fn apply_section(
335        &mut self,
336        section: &SectionPlanInput<'_>,
337        registers: &mut StagedRegisters,
338    ) -> Result<(), HashlineRejection> {
339        if section.operations.len() != section.resolved.len() {
340            return Err(HashlineRejection::parse(
341                "resolved operation count does not match the parsed section",
342            ));
343        }
344        for (operation, resolved) in section.operations.iter().zip(section.resolved.iter()) {
345            self.apply_operation(operation, resolved, registers)?;
346        }
347        Ok(())
348    }
349
350    fn apply_operation(
351        &mut self,
352        operation: &Operation,
353        resolved: &ResolvedOperation,
354        registers: &mut StagedRegisters,
355    ) -> Result<(), HashlineRejection> {
356        let current_operation = operation_label(operation);
357        if let Some(previous) = &self.removed_by {
358            return Err(composition_conflict(
359                &current_operation,
360                previous,
361                "the earlier operation removed the whole pre-request file",
362            ));
363        }
364
365        let original_address = resolved.address;
366        let current_address = self.remap_address(original_address, &current_operation)?;
367        let current_resolved = ResolvedOperation {
368            operation_index: resolved.operation_index,
369            address: current_address,
370        };
371        let before_lines = self.current.snapshot.total_lines;
372        let planned = apply_section_ops(
373            &self.requested_path,
374            &self.canonical_path,
375            &self.current,
376            std::slice::from_ref(operation),
377            std::slice::from_ref(&current_resolved),
378            registers,
379        )?;
380        let after = Baseline::from_bytes(planned.final_bytes.clone());
381        let after_lines = after.snapshot.total_lines;
382        self.update_origins(
383            operation,
384            original_address,
385            current_address,
386            &current_operation,
387            before_lines,
388            after_lines,
389        )?;
390        self.current = after;
391        self.remove_file = planned.remove_file;
392        self.warnings.extend(planned.warnings);
393        for layer in planned.repair_layers {
394            if !self.repair_layers.contains(&layer) {
395                self.repair_layers.push(layer);
396            }
397        }
398        Ok(())
399    }
400
401    fn remap_address(
402        &self,
403        address: ResolvedAddress,
404        current_operation: &str,
405    ) -> Result<ResolvedAddress, HashlineRejection> {
406        match address {
407            ResolvedAddress::Span(span) => self
408                .remap_span(span, current_operation)
409                .map(ResolvedAddress::Span),
410            ResolvedAddress::Gap(gap) => self
411                .remap_gap(
412                    OriginalGap {
413                        before: gap.before,
414                        after: gap.after,
415                    },
416                    current_operation,
417                )
418                .map(ResolvedAddress::Gap),
419            ResolvedAddress::WholeFile
420            | ResolvedAddress::BlockAnchor(_)
421            | ResolvedAddress::BlockGapAnchor { .. } => Ok(address),
422        }
423    }
424
425    fn remap_span(
426        &self,
427        span: LineSpan,
428        current_operation: &str,
429    ) -> Result<LineSpan, HashlineRejection> {
430        for line in span.lines() {
431            if let Some(previous) = self.deleted_by.get(&line) {
432                return Err(composition_conflict(
433                    current_operation,
434                    previous,
435                    &format!("pre-request line {line} was deleted earlier"),
436                ));
437            }
438        }
439
440        let mut selected = Vec::new();
441        for (index, origin) in self.origins.iter().enumerate() {
442            match origin {
443                LineOrigin::Original(line) if span.start <= *line && *line <= span.end => {
444                    selected.push(index + 1);
445                }
446                LineOrigin::Replacement { source, operation } if spans_overlap(*source, span) => {
447                    if !span_contains(span, *source) {
448                        return Err(composition_conflict(
449                            current_operation,
450                            operation,
451                            "the later pre-request span only partially overlaps an earlier replacement",
452                        ));
453                    }
454                    selected.push(index + 1);
455                }
456                LineOrigin::Original(_)
457                | LineOrigin::Replacement { .. }
458                | LineOrigin::Inserted { .. } => {}
459            }
460        }
461
462        let Some(start) = selected.first().copied() else {
463            return Err(HashlineRejection::eligibility(
464                HashlineRejectionCode::BoundaryIneligible,
465                format!(
466                    "{current_operation} cannot resolve its pre-request span after earlier same-path operations"
467                ),
468            ));
469        };
470        let end = selected.last().copied().unwrap_or(start);
471        Ok(LineSpan { start, end })
472    }
473
474    fn remap_gap(
475        &self,
476        gap: OriginalGap,
477        current_operation: &str,
478    ) -> Result<crate::hashline::syntax::ResolvedGap, HashlineRejection> {
479        for line in [gap.before, gap.after].into_iter().flatten() {
480            if let Some(previous) = self.deleted_by.get(&line) {
481                return Err(composition_conflict(
482                    current_operation,
483                    previous,
484                    &format!("required pre-request gap anchor line {line} was deleted earlier"),
485                ));
486            }
487        }
488
489        let before = self.last_index_for_original(gap.before);
490        let after = self.first_index_for_original(gap.after);
491        let last_inserted = self
492            .origins
493            .iter()
494            .enumerate()
495            .filter_map(|(index, origin)| match origin {
496                LineOrigin::Inserted { gap: inserted } if *inserted == gap => Some(index + 1),
497                _ => None,
498            })
499            .last();
500
501        Ok(crate::hashline::syntax::ResolvedGap {
502            before: last_inserted.or(before),
503            after,
504        })
505    }
506
507    fn first_index_for_original(&self, line: Option<usize>) -> Option<usize> {
508        let line = line?;
509        self.origins
510            .iter()
511            .position(|origin| origin_covers_line(origin, line))
512            .map(|index| index + 1)
513    }
514
515    fn last_index_for_original(&self, line: Option<usize>) -> Option<usize> {
516        let line = line?;
517        self.origins
518            .iter()
519            .rposition(|origin| origin_covers_line(origin, line))
520            .map(|index| index + 1)
521    }
522
523    fn update_origins(
524        &mut self,
525        operation: &Operation,
526        original_address: ResolvedAddress,
527        current_address: ResolvedAddress,
528        operation_name: &str,
529        before_lines: usize,
530        after_lines: usize,
531    ) -> Result<(), HashlineRejection> {
532        match operation {
533            Operation::Put(_) => match (original_address, current_address) {
534                (ResolvedAddress::Span(original), ResolvedAddress::Span(current)) => {
535                    let replaced = current.end - current.start + 1;
536                    let inserted = after_lines
537                        .checked_add(replaced)
538                        .and_then(|count| count.checked_sub(before_lines))
539                        .ok_or_else(|| {
540                            HashlineRejection::parse(
541                                "same-path replacement produced inconsistent line accounting",
542                            )
543                        })?;
544                    let replacement = (0..inserted).map(|_| LineOrigin::Replacement {
545                        source: original,
546                        operation: operation_name.to_string(),
547                    });
548                    self.origins
549                        .splice(current.start - 1..current.end, replacement);
550                }
551                (ResolvedAddress::Gap(original), ResolvedAddress::Gap(current)) => {
552                    let inserted = after_lines.checked_sub(before_lines).ok_or_else(|| {
553                        HashlineRejection::parse(
554                            "same-path gap insertion produced inconsistent line accounting",
555                        )
556                    })?;
557                    let index = current.before.unwrap_or(0);
558                    let gap = OriginalGap {
559                        before: original.before,
560                        after: original.after,
561                    };
562                    self.origins.splice(
563                        index..index,
564                        (0..inserted).map(|_| LineOrigin::Inserted { gap }),
565                    );
566                }
567                _ => {}
568            },
569            Operation::Cut(_) => {
570                let ResolvedAddress::Span(original) = original_address else {
571                    return Ok(());
572                };
573                let ResolvedAddress::Span(current) = current_address else {
574                    return Ok(());
575                };
576                self.origins.drain(current.start - 1..current.end);
577                for line in original.lines() {
578                    self.deleted_by.insert(line, operation_name.to_string());
579                }
580            }
581            Operation::Rem(_) => {
582                for line in 1..=self.original.snapshot.total_lines {
583                    self.deleted_by.insert(line, operation_name.to_string());
584                }
585                self.origins.clear();
586                self.removed_by = Some(operation_name.to_string());
587            }
588            Operation::Mv(_) => {}
589        }
590        Ok(())
591    }
592
593    fn finish(self) -> Result<PlannedFile, HashlineRejection> {
594        let affected = if self.remove_file {
595            AffectedRegion::default()
596        } else {
597            let original_lines = baseline_lines(&self.original)?;
598            let final_lines = baseline_lines(&self.current)?;
599            affected_from_line_diff(&original_lines, &final_lines)
600        };
601        Ok(PlannedFile {
602            canonical_path: self.canonical_path,
603            requested_path: self.requested_path,
604            baseline_bytes: self.original.bytes,
605            final_bytes: self.current.bytes,
606            affected,
607            remove_file: self.remove_file,
608            warnings: self.warnings,
609            repair_layers: self.repair_layers,
610        })
611    }
612}
613
614fn operation_label(operation: &Operation) -> String {
615    match operation {
616        Operation::Put(operation) => format!("PUT at patch line {}", operation.line),
617        Operation::Cut(operation) => format!("CUT at patch line {}", operation.line),
618        Operation::Rem(operation) => format!("REM at patch line {}", operation.line),
619        Operation::Mv(operation) => format!("MV at patch line {}", operation.line),
620    }
621}
622
623fn composition_conflict(
624    current_operation: &str,
625    previous_operation: &str,
626    reason: &str,
627) -> HashlineRejection {
628    HashlineRejection::eligibility(
629        HashlineRejectionCode::BoundaryIneligible,
630        format!(
631            "same-path composition conflict: {current_operation} conflicts with {previous_operation}: {reason}"
632        ),
633    )
634}
635
636fn origin_covers_line(origin: &LineOrigin, line: usize) -> bool {
637    match origin {
638        LineOrigin::Original(original) => *original == line,
639        LineOrigin::Replacement { source, .. } => source.start <= line && line <= source.end,
640        LineOrigin::Inserted { .. } => false,
641    }
642}
643
644fn spans_overlap(left: LineSpan, right: LineSpan) -> bool {
645    left.start <= right.end && right.start <= left.end
646}
647
648fn span_contains(outer: LineSpan, inner: LineSpan) -> bool {
649    outer.start <= inner.start && inner.end <= outer.end
650}
651
652/// Apply PUT/CUT/REM operations for one section against one baseline.
653pub fn apply_section_ops(
654    requested_path: &str,
655    canonical_path: &Path,
656    baseline: &Baseline,
657    operations: &[Operation],
658    resolved: &[ResolvedOperation],
659    registers: &mut StagedRegisters,
660) -> Result<PlannedFile, HashlineRejection> {
661    if operations.len() != resolved.len() {
662        return Err(HashlineRejection::parse(
663            "resolved operation count does not match the parsed section",
664        ));
665    }
666
667    // REM is whole-file and exclusive.
668    if let Some(Operation::Rem(_)) = operations.first() {
669        if operations.len() != 1 {
670            return Err(HashlineRejection::parse(
671                "REM cannot be combined with other operations",
672            ));
673        }
674        return Ok(PlannedFile {
675            canonical_path: canonical_path.to_path_buf(),
676            requested_path: requested_path.to_string(),
677            baseline_bytes: baseline.bytes.clone(),
678            final_bytes: Vec::new(),
679            affected: AffectedRegion::default(),
680            remove_file: true,
681            warnings: Vec::new(),
682            repair_layers: Vec::new(),
683        });
684    }
685
686    // MV is owned by the transaction slice; refuse it here so this module's
687    // fence stays non-MV.
688    if operations
689        .iter()
690        .any(|operation| matches!(operation, Operation::Mv(_)))
691    {
692        return Err(HashlineRejection::parse(
693            "MV is not handled by the line-apply engine",
694        ));
695    }
696
697    let original_lines = baseline_lines(baseline)?;
698    let (default_term, trailing) = terminator_policy(&baseline.snapshot.records);
699    let mut edits = Vec::new();
700
701    for (operation, resolved_op) in operations.iter().zip(resolved.iter()) {
702        match operation {
703            Operation::Put(put) => {
704                edits.extend(lower_put(
705                    put,
706                    resolved_op.address,
707                    registers,
708                    resolved_op.operation_index,
709                )?);
710            }
711            Operation::Cut(cut) => {
712                edits.extend(lower_cut(
713                    cut,
714                    resolved_op.address,
715                    &original_lines,
716                    registers,
717                    resolved_op.operation_index,
718                )?);
719            }
720            Operation::Rem(RemOperation { .. }) | Operation::Mv(_) => unreachable!(),
721        }
722    }
723
724    let coalesced = coalesce_replacement_edits(&edits);
725    let coalesced_applied = if coalesced.len() != edits.len()
726        || coalesced
727            .iter()
728            .zip(edits.iter())
729            .any(|(left, right)| left != right)
730    {
731        true
732    } else {
733        // Even when the edit list shape is unchanged, a single multi-line
734        // replacement group is still the coalesced form.
735        find_replacement_group(&coalesced, 0).is_some()
736            && edits.iter().any(|edit| {
737                matches!(
738                    edit,
739                    LineEdit::Insert {
740                        mode: InsertMode::Replacement,
741                        ..
742                    }
743                )
744            })
745    };
746
747    let repaired = apply_repair_layers(&coalesced, &original_lines);
748    let mut repair_layers = repaired.layers_applied;
749    if coalesced_applied
750        && find_replacement_group(&coalesced, 0).is_some()
751        && !repair_layers.contains(&"replacement-coalescing")
752    {
753        // Record coalescing when a contiguous replacement group is the apply unit.
754        let has_multi_delete = coalesced
755            .iter()
756            .filter(|e| matches!(e, LineEdit::Delete { .. }))
757            .count()
758            > 1;
759        if has_multi_delete {
760            repair_layers.insert(0, "replacement-coalescing");
761        }
762    }
763
764    let final_lines = materialize_edits(&original_lines, &repaired.edits);
765    let final_bytes = join_lines(&final_lines, default_term, trailing);
766    let affected = affected_from_line_diff(&original_lines, &final_lines);
767
768    Ok(PlannedFile {
769        canonical_path: canonical_path.to_path_buf(),
770        requested_path: requested_path.to_string(),
771        baseline_bytes: baseline.bytes.clone(),
772        final_bytes,
773        affected,
774        remove_file: false,
775        warnings: repaired.warnings,
776        repair_layers,
777    })
778}
779
780fn lower_put(
781    put: &PutOperation,
782    address: ResolvedAddress,
783    registers: &mut StagedRegisters,
784    op_index: usize,
785) -> Result<Vec<LineEdit>, HashlineRejection> {
786    let target_is_span = matches!(address, ResolvedAddress::Span(_));
787    let body = match &put.source {
788        PutSource::Text(lines) => lines.clone(),
789        PutSource::Register(register) => registers.read_for_put(register, target_is_span)?,
790    };
791    Ok(lower_put_body(address, body, op_index))
792}
793
794fn lower_put_body(address: ResolvedAddress, body: Vec<String>, op_index: usize) -> Vec<LineEdit> {
795    match address {
796        ResolvedAddress::Span(span) => {
797            let mut edits = Vec::with_capacity(body.len() + (span.end - span.start + 1));
798            for text in body {
799                edits.push(LineEdit::Insert {
800                    anchor: span.start,
801                    place: InsertPlace::Before,
802                    text,
803                    mode: InsertMode::Replacement,
804                    op_index,
805                });
806            }
807            for line in span.start..=span.end {
808                edits.push(LineEdit::Delete { line, op_index });
809            }
810            edits
811        }
812        ResolvedAddress::Gap(gap) => {
813            let (anchor, place) = match (gap.before, gap.after) {
814                (None, Some(1)) | (None, None) => (1, InsertPlace::Bof),
815                (Some(before), None) => (before, InsertPlace::After),
816                (Some(before), Some(_)) => (before, InsertPlace::After),
817                (None, Some(after)) => (after, InsertPlace::Before),
818            };
819            // EOF gap with before=last uses After; pure EOF with no before uses Eof.
820            let place = if gap.before.is_some() && gap.after.is_none() {
821                InsertPlace::After
822            } else if gap.before.is_none() && gap.after.is_none() {
823                InsertPlace::Bof
824            } else if gap.before.is_none() && gap.after == Some(1) {
825                InsertPlace::Bof
826            } else {
827                place
828            };
829            let place =
830                if gap.before.is_some() && gap.after.is_none() && place == InsertPlace::After {
831                    // Prefer Eof when inserting after the last line so materialize
832                    // does not depend on the anchor still existing after deletes.
833                    InsertPlace::Eof
834                } else {
835                    place
836                };
837            body.into_iter()
838                .map(|text| LineEdit::Insert {
839                    anchor,
840                    place,
841                    text,
842                    mode: InsertMode::Plain,
843                    op_index,
844                })
845                .collect()
846        }
847        ResolvedAddress::WholeFile => {
848            // Treat whole-file PUT as replace-all when body is supplied.
849            let end = body.len().max(1);
850            let mut edits = Vec::new();
851            for text in &body {
852                edits.push(LineEdit::Insert {
853                    anchor: 1,
854                    place: InsertPlace::Before,
855                    text: text.clone(),
856                    mode: InsertMode::Replacement,
857                    op_index,
858                });
859            }
860            // Deletes are filled by the caller only when the baseline length is
861            // known; whole-file PUT via line ops is uncommon. Leave deletes to
862            // REM for full removal.
863            let _ = end;
864            edits
865        }
866        ResolvedAddress::BlockAnchor(_) | ResolvedAddress::BlockGapAnchor { .. } => Vec::new(),
867    }
868}
869
870fn lower_cut(
871    cut: &CutOperation,
872    address: ResolvedAddress,
873    lines: &[String],
874    registers: &mut StagedRegisters,
875    op_index: usize,
876) -> Result<Vec<LineEdit>, HashlineRejection> {
877    let span = match address {
878        ResolvedAddress::Span(span) => span,
879        ResolvedAddress::WholeFile => {
880            if lines.is_empty() {
881                return Ok(Vec::new());
882            }
883            crate::hashline::syntax::LineSpan {
884                start: 1,
885                end: lines.len(),
886            }
887        }
888        ResolvedAddress::Gap(_)
889        | ResolvedAddress::BlockAnchor(_)
890        | ResolvedAddress::BlockGapAnchor { .. } => {
891            return Err(HashlineRejection::eligibility(
892                HashlineRejectionCode::BoundaryIneligible,
893                "CUT requires a line or range address",
894            ));
895        }
896    };
897    let captured: RegisterLines = (span.start..=span.end)
898        .map(|line| lines.get(line - 1).cloned().unwrap_or_default())
899        .collect();
900    let register = cut.register.clone().unwrap_or(RegisterRef::Anonymous);
901    registers.capture(register, captured)?;
902    Ok((span.start..=span.end)
903        .map(|line| LineEdit::Delete { line, op_index })
904        .collect())
905}
906
907fn baseline_lines(baseline: &Baseline) -> Result<Vec<String>, HashlineRejection> {
908    let mut lines = Vec::with_capacity(baseline.snapshot.total_lines);
909    for line in 1..=baseline.snapshot.total_lines {
910        let record = baseline.raw_record(line).ok_or_else(|| {
911            HashlineRejection::parse(format!("baseline is missing raw record for line {line}"))
912        })?;
913        lines.push(line_content_utf8(record)?);
914    }
915    Ok(lines)
916}
917
918fn line_content_utf8(record: &RawLineRecord) -> Result<String, HashlineRejection> {
919    String::from_utf8(record.content.clone()).map_err(|_| {
920        HashlineRejection::new(
921            HashlineRejectionCode::UntaggablePath,
922            RejectionStage::Path,
923            "baseline line is not valid UTF-8",
924        )
925    })
926}
927
928/// Commit staged registers only when every file result is `applied*`.
929///
930/// On any stopping failure or `not_attempted` entry the staged captures are
931/// discarded and the session store is left unchanged.
932pub fn commit_registers_if_complete(
933    session: &mut RegisterStore,
934    staged: StagedRegisters,
935    classifications: &[FileClassification],
936) -> bool {
937    let all_applied = !classifications.is_empty()
938        && classifications
939            .iter()
940            .all(|classification| classification.is_applied_star());
941    if all_applied {
942        session.commit(staged);
943        true
944    } else {
945        RegisterStore::discard(staged);
946        false
947    }
948}
949
950/// Simulate ordered Phase-2 classification for non-MV files.
951///
952/// `fail_at` injects a stopping failure at the given file index so tests can
953/// lock partial and all-failed envelopes without real I/O. When `fail_at` is
954/// `None`, every planned file is classified `Applied`.
955pub fn simulate_phase2(
956    plan: ApplyPlan,
957    session_registers: &mut RegisterStore,
958    fail_at: Option<(usize, FileClassification)>,
959) -> ApplyResultEnvelope {
960    let ApplyPlan {
961        files,
962        staged_registers,
963    } = plan;
964    let mut results = Vec::with_capacity(files.len());
965    let mut stopped = false;
966    let mut stop_classification = FileClassification::NotAttempted;
967
968    for (index, file) in files.into_iter().enumerate() {
969        if stopped {
970            results.push(FileResult {
971                canonical_path: file.canonical_path,
972                requested_path: file.requested_path,
973                classification: FileClassification::NotAttempted,
974                mutation_state: MutationState::Unmutated,
975                final_bytes: None,
976                affected: AffectedRegion::default(),
977                warnings: Vec::new(),
978                remove_file: file.remove_file,
979            });
980            continue;
981        }
982        if let Some((fail_index, classification)) = fail_at {
983            if index == fail_index {
984                stopped = true;
985                stop_classification = classification;
986                results.push(FileResult {
987                    canonical_path: file.canonical_path,
988                    requested_path: file.requested_path,
989                    classification,
990                    mutation_state: classification.mutation_state(),
991                    final_bytes: None,
992                    affected: AffectedRegion::default(),
993                    warnings: file.warnings,
994                    remove_file: file.remove_file,
995                });
996                continue;
997            }
998        }
999        let classification = FileClassification::Applied;
1000        results.push(FileResult {
1001            canonical_path: file.canonical_path,
1002            requested_path: file.requested_path,
1003            classification,
1004            mutation_state: classification.mutation_state(),
1005            final_bytes: Some(file.final_bytes),
1006            affected: file.affected,
1007            warnings: file.warnings,
1008            remove_file: file.remove_file,
1009        });
1010    }
1011
1012    let classifications: Vec<FileClassification> =
1013        results.iter().map(|result| result.classification).collect();
1014    let registers_committed =
1015        commit_registers_if_complete(session_registers, staged_registers, &classifications);
1016    let applied = classifications
1017        .iter()
1018        .filter(|classification| classification.is_applied_star())
1019        .count();
1020    let success = applied > 0;
1021    let complete = applied == classifications.len() && !classifications.is_empty();
1022    let _ = stop_classification;
1023    ApplyResultEnvelope {
1024        success,
1025        complete,
1026        files: results,
1027        registers_committed,
1028    }
1029}
1030
1031/// Convenience: plan and apply a single-file PUT/CUT/REM patch body against
1032/// known baseline bytes and a retained snapshot, using pre-resolved addresses.
1033pub fn apply_simple_ops(
1034    baseline_bytes: &[u8],
1035    snapshot: &Snapshot,
1036    operations: &[Operation],
1037    addresses: &[ResolvedAddress],
1038    registers: &mut StagedRegisters,
1039) -> Result<PlannedFile, HashlineRejection> {
1040    let baseline = Baseline::from_bytes(baseline_bytes.to_vec());
1041    let resolved: Vec<ResolvedOperation> = addresses
1042        .iter()
1043        .enumerate()
1044        .map(|(operation_index, address)| ResolvedOperation {
1045            operation_index,
1046            address: *address,
1047        })
1048        .collect();
1049    for resolved_op in &resolved {
1050        match verify_exact(snapshot, &baseline, resolved_op.address) {
1051            VerificationOutcome::Exact => {}
1052            VerificationOutcome::RecoveryRequired(_) => {
1053                return Err(HashlineRejection::new(
1054                    HashlineRejectionCode::StaleTag,
1055                    RejectionStage::Recovery,
1056                    "addressed content no longer matches the Phase-1 baseline",
1057                ));
1058            }
1059            VerificationOutcome::Rejected(rejection) => return Err(rejection),
1060            VerificationOutcome::BlockNeedsResolution { .. } => {
1061                return Err(HashlineRejection::new(
1062                    HashlineRejectionCode::BoundaryIneligible,
1063                    RejectionStage::Eligibility,
1064                    "block address was not expanded before apply",
1065                ));
1066            }
1067        }
1068    }
1069    apply_section_ops(
1070        "file",
1071        Path::new("file"),
1072        &baseline,
1073        operations,
1074        &resolved,
1075        registers,
1076    )
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081    use super::*;
1082    use crate::hashline::scan::{scan_bytes, scan_bytes_with_request, CoverageInput, ScanRequest};
1083    use crate::hashline::syntax::{
1084        parse_address, resolve_address, LineSpan, PutOperation, RegisterRef,
1085    };
1086
1087    fn whole_snapshot(bytes: &[u8]) -> Snapshot {
1088        scan_bytes(bytes)
1089    }
1090
1091    fn put_text(address: &str, body: &[&str]) -> Operation {
1092        Operation::Put(PutOperation {
1093            address: parse_address(address).unwrap(),
1094            source: PutSource::Text(body.iter().map(|line| (*line).to_string()).collect()),
1095            line: 1,
1096        })
1097    }
1098
1099    fn cut(address: &str, register: Option<RegisterRef>) -> Operation {
1100        Operation::Cut(CutOperation {
1101            address: parse_address(address).unwrap(),
1102            register,
1103            line: 1,
1104        })
1105    }
1106
1107    fn resolve_ops(snapshot: &Snapshot, operations: &[Operation]) -> Vec<ResolvedAddress> {
1108        operations
1109            .iter()
1110            .map(|operation| match operation.address() {
1111                Some(address) => resolve_address(address, snapshot).unwrap(),
1112                None => ResolvedAddress::WholeFile,
1113            })
1114            .collect()
1115    }
1116
1117    #[test]
1118    fn put_replaces_a_single_line() {
1119        let bytes = b"alpha\nbeta\ngamma\n";
1120        let snapshot = whole_snapshot(bytes);
1121        let ops = vec![put_text("2", &["BETA"])];
1122        let addresses = resolve_ops(&snapshot, &ops);
1123        let mut staged = RegisterStore::new().stage();
1124        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
1125        assert_eq!(planned.final_bytes, b"alpha\nBETA\ngamma\n");
1126        assert!(!planned.affected.is_empty());
1127    }
1128
1129    #[test]
1130    fn put_inserts_into_a_gap() {
1131        let bytes = b"one\ntwo\nthree\n";
1132        let snapshot = whole_snapshot(bytes);
1133        let ops = vec![put_text(">1", &["1.5"])];
1134        let addresses = resolve_ops(&snapshot, &ops);
1135        let mut staged = RegisterStore::new().stage();
1136        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
1137        assert_eq!(planned.final_bytes, b"one\n1.5\ntwo\nthree\n");
1138    }
1139
1140    #[test]
1141    fn cut_captures_and_deletes() {
1142        let bytes = b"a\nb\nc\n";
1143        let snapshot = whole_snapshot(bytes);
1144        let ops = vec![cut("2", Some(RegisterRef::Named("clip".into())))];
1145        let addresses = resolve_ops(&snapshot, &ops);
1146        let mut store = RegisterStore::new();
1147        let mut staged = store.stage();
1148        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
1149        assert_eq!(planned.final_bytes, b"a\nc\n");
1150        assert_eq!(
1151            staged.get(&RegisterRef::Named("clip".into())),
1152            Some(["b".to_string()].as_slice())
1153        );
1154        // Not committed yet.
1155        assert!(store.get(&RegisterRef::Named("clip".into())).is_none());
1156        assert!(commit_registers_if_complete(
1157            &mut store,
1158            staged,
1159            &[FileClassification::Applied]
1160        ));
1161        assert_eq!(
1162            store.get(&RegisterRef::Named("clip".into())),
1163            Some(["b".to_string()].as_slice())
1164        );
1165    }
1166
1167    #[test]
1168    fn rem_clears_file_bytes() {
1169        let bytes = b"gone\n";
1170        let snapshot = whole_snapshot(bytes);
1171        let ops = vec![Operation::Rem(RemOperation { line: 1 })];
1172        let addresses = vec![ResolvedAddress::WholeFile];
1173        let mut staged = RegisterStore::new().stage();
1174        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
1175        assert!(planned.remove_file);
1176        assert!(planned.final_bytes.is_empty());
1177        assert!(planned.affected.is_empty());
1178    }
1179
1180    #[test]
1181    fn cut_then_put_register_moves_lines() {
1182        let bytes = b"keep\nmove-me\n";
1183        let snapshot = whole_snapshot(bytes);
1184        let ops = vec![
1185            cut("2", Some(RegisterRef::Named("r".into()))),
1186            Operation::Put(PutOperation {
1187                // BOF insert form (`0`), not `<1`, which resolves a zero anchor.
1188                address: parse_address("0").unwrap(),
1189                source: PutSource::Register(RegisterRef::Named("r".into())),
1190                line: 2,
1191            }),
1192        ];
1193        let addresses = resolve_ops(&snapshot, &ops);
1194        let mut staged = RegisterStore::new().stage();
1195        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
1196        assert_eq!(planned.final_bytes, b"move-me\nkeep\n");
1197    }
1198
1199    #[test]
1200    fn register_overflow_rejects_in_phase_one() {
1201        let huge = "x".repeat(MAX_REGISTER_BYTES + 1);
1202        let big = format!("{huge}\n");
1203        let big_bytes = big.as_bytes();
1204        let snapshot = whole_snapshot(big_bytes);
1205        let ops = vec![cut("1", Some(RegisterRef::Named("oversized".into())))];
1206        let addresses = resolve_ops(&snapshot, &ops);
1207        let mut staged = RegisterStore::new().stage();
1208        let err = apply_simple_ops(big_bytes, &snapshot, &ops, &addresses, &mut staged)
1209            .expect_err("overflow");
1210        assert_eq!(err.code, HashlineRejectionCode::RegisterOverflow);
1211        assert_eq!(err.stage, RejectionStage::Register);
1212    }
1213
1214    /// Mutation-checked negative control: a stale baseline must not be mutated
1215    /// by any repair layer. The control fails the suite if apply returns
1216    /// success with equal-looking "repaired" bytes from a mismatched snapshot.
1217    fn repair_negative_control(repair: &'static str, bytes: &[u8], address: &str, body: &[&str]) {
1218        let snapshot = whole_snapshot(bytes);
1219        let ops = vec![put_text(address, body)];
1220        let addresses = resolve_ops(&snapshot, &ops);
1221        // Flip one content byte inside the addressed span so verification fails
1222        // before any repair layer can run.
1223        let mut drifted = bytes.to_vec();
1224        let span = addresses
1225            .iter()
1226            .find_map(|address| address.addressed_span())
1227            .expect("repair negative controls address a span");
1228        let baseline = Baseline::from_bytes(bytes.to_vec());
1229        let target = baseline
1230            .raw_record(span.start)
1231            .expect("addressed line exists in the original baseline");
1232        // Locate the first content byte of the addressed line in the raw buffer.
1233        let mut offset = 0usize;
1234        for line in 1..span.start {
1235            let record = baseline.raw_record(line).unwrap();
1236            offset += record.to_bytes().len();
1237        }
1238        if !target.content.is_empty() {
1239            drifted[offset] ^= 0x20;
1240        } else {
1241            // Empty addressed line: insert a marker byte into the content slot.
1242            drifted.insert(offset, b'X');
1243        }
1244        let mut staged = RegisterStore::new().stage();
1245        let err =
1246            apply_simple_ops(&drifted, &snapshot, &ops, &addresses, &mut staged).expect_err(repair);
1247        assert_eq!(
1248            err.code,
1249            HashlineRejectionCode::StaleTag,
1250            "{repair} negative control must reject as stale"
1251        );
1252        // Staged captures must not have been written on the rejecting path.
1253        assert_eq!(staged.writes().len(), 0);
1254        // Non-vacuity: the matching baseline must still mutate under the same op.
1255        let mut ok_staged = RegisterStore::new().stage();
1256        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut ok_staged)
1257            .unwrap_or_else(|error| panic!("{repair} positive path must apply: {error:?}"));
1258        assert_ne!(
1259            planned.final_bytes, bytes,
1260            "{repair} positive path must mutate (control_failure_if_equal)"
1261        );
1262    }
1263
1264    #[test]
1265    fn boundary_echo_repair_negative_control_is_mutation_checked() {
1266        // Positive path: payload restates neighbors around a middle replacement.
1267        let bytes = b"one\ntwo\nthree\n";
1268        repair_negative_control("boundary-echo", bytes, "2", &["one", "TWO", "three"]);
1269        // Direct layer assertion on the positive path.
1270        let snapshot = whole_snapshot(bytes);
1271        let ops = vec![put_text("2", &["one", "TWO", "three"])];
1272        let addresses = resolve_ops(&snapshot, &ops);
1273        let mut staged = RegisterStore::new().stage();
1274        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
1275        assert!(
1276            planned.repair_layers.contains(&"boundary-echo")
1277                || planned.final_bytes == b"one\nTWO\nthree\n",
1278            "boundary-echo should drop restated neighbors: {:?}",
1279            String::from_utf8_lossy(&planned.final_bytes)
1280        );
1281        assert_eq!(planned.final_bytes, b"one\nTWO\nthree\n");
1282    }
1283
1284    #[test]
1285    fn indent_repair_negative_control_is_mutation_checked() {
1286        let bytes = b"    if (value > 90) {\n      result = error;\n    } else if (value > 70) {\n      result = plain;\n    } else {\n      result = warning;\n    }\n";
1287        let body = [
1288            "  result = error;",
1289            "} else if (value > 70) {",
1290            "  result = warning;",
1291            "} else {",
1292            "  result = plain;",
1293        ];
1294        repair_negative_control("indent", bytes, "2.=6", &body);
1295    }
1296
1297    #[test]
1298    fn replacement_coalescing_negative_control_is_mutation_checked() {
1299        let bytes = b"old-a\nold-b\nold-c\n";
1300        repair_negative_control(
1301            "replacement-coalescing",
1302            bytes,
1303            "1.=3",
1304            &["new-a", "new-b", "new-c"],
1305        );
1306        let snapshot = whole_snapshot(bytes);
1307        let ops = vec![put_text("1.=3", &["new-a", "new-b", "new-c"])];
1308        let addresses = resolve_ops(&snapshot, &ops);
1309        let mut staged = RegisterStore::new().stage();
1310        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
1311        assert_eq!(planned.final_bytes, b"new-a\nnew-b\nnew-c\n");
1312        assert!(
1313            planned.repair_layers.contains(&"replacement-coalescing")
1314                || find_replacement_group(
1315                    &coalesce_replacement_edits(&{
1316                        let mut edits = Vec::new();
1317                        // ensure coalescing helper itself works on split edits
1318                        edits.extend(lower_put_body(
1319                            ResolvedAddress::Span(LineSpan { start: 1, end: 1 }),
1320                            vec!["new-a".into()],
1321                            0,
1322                        ));
1323                        edits.extend(lower_put_body(
1324                            ResolvedAddress::Span(LineSpan { start: 2, end: 2 }),
1325                            vec!["new-b".into()],
1326                            0,
1327                        ));
1328                        edits.extend(lower_put_body(
1329                            ResolvedAddress::Span(LineSpan { start: 3, end: 3 }),
1330                            vec!["new-c".into()],
1331                            0,
1332                        ));
1333                        edits
1334                    }),
1335                    0
1336                )
1337                .is_some()
1338        );
1339    }
1340
1341    #[test]
1342    fn a8_phase1_is_all_or_nothing_and_mutation_free() {
1343        let bytes_a = b"a1\na2\n";
1344        let bytes_b = b"b1\nb2\n";
1345        let snap_a = whole_snapshot(bytes_a);
1346        let snap_b = whole_snapshot(bytes_b);
1347        let baseline_a = Baseline::from_bytes(bytes_a.to_vec());
1348        let baseline_b = Baseline::from_bytes(bytes_b.to_vec());
1349        let ops_a = vec![put_text("1", &["A1"])];
1350        let ops_b = vec![put_text("999", &["nope"])]; // ineligible address
1351        let resolved_a: Vec<ResolvedOperation> = resolve_ops(&snap_a, &ops_a)
1352            .into_iter()
1353            .enumerate()
1354            .map(|(operation_index, address)| ResolvedOperation {
1355                operation_index,
1356                address,
1357            })
1358            .collect();
1359        // Force a bad resolved address for file B.
1360        let resolved_b = vec![ResolvedOperation {
1361            operation_index: 0,
1362            address: ResolvedAddress::Span(LineSpan {
1363                start: 999,
1364                end: 999,
1365            }),
1366        }];
1367        let sections = [
1368            SectionPlanInput {
1369                canonical_path: Path::new("a.txt"),
1370                requested_path: "a.txt",
1371                baseline: &baseline_a,
1372                snapshot: &snap_a,
1373                operations: &ops_a,
1374                resolved: &resolved_a,
1375            },
1376            SectionPlanInput {
1377                canonical_path: Path::new("b.txt"),
1378                requested_path: "b.txt",
1379                baseline: &baseline_b,
1380                snapshot: &snap_b,
1381                operations: &ops_b,
1382                resolved: &resolved_b,
1383            },
1384        ];
1385        let store = RegisterStore::new();
1386        let err = plan_apply(&sections, &store).expect_err("phase1 rejects whole patch");
1387        assert!(matches!(
1388            err.code,
1389            HashlineRejectionCode::UnseenLine
1390                | HashlineRejectionCode::BoundaryIneligible
1391                | HashlineRejectionCode::StaleTag
1392        ));
1393        // No session register mutation.
1394        assert_eq!(store.named_count(), 0);
1395    }
1396
1397    #[test]
1398    fn a8_register_commit_only_when_every_file_is_applied_star() {
1399        let bytes_a = b"src\n";
1400        let bytes_b = b"dst\n";
1401        let snap_a = whole_snapshot(bytes_a);
1402        let snap_b = whole_snapshot(bytes_b);
1403        let baseline_a = Baseline::from_bytes(bytes_a.to_vec());
1404        let baseline_b = Baseline::from_bytes(bytes_b.to_vec());
1405        let ops_a = vec![cut("1", Some(RegisterRef::Named("shared".into())))];
1406        let ops_b = vec![Operation::Put(PutOperation {
1407            address: parse_address("1").unwrap(),
1408            source: PutSource::Register(RegisterRef::Named("shared".into())),
1409            line: 1,
1410        })];
1411        let resolved_a: Vec<ResolvedOperation> = resolve_ops(&snap_a, &ops_a)
1412            .into_iter()
1413            .enumerate()
1414            .map(|(operation_index, address)| ResolvedOperation {
1415                operation_index,
1416                address,
1417            })
1418            .collect();
1419        let resolved_b: Vec<ResolvedOperation> = resolve_ops(&snap_b, &ops_b)
1420            .into_iter()
1421            .enumerate()
1422            .map(|(operation_index, address)| ResolvedOperation {
1423                operation_index,
1424                address,
1425            })
1426            .collect();
1427        let sections = [
1428            SectionPlanInput {
1429                canonical_path: Path::new("a.txt"),
1430                requested_path: "a.txt",
1431                baseline: &baseline_a,
1432                snapshot: &snap_a,
1433                operations: &ops_a,
1434                resolved: &resolved_a,
1435            },
1436            SectionPlanInput {
1437                canonical_path: Path::new("b.txt"),
1438                requested_path: "b.txt",
1439                baseline: &baseline_b,
1440                snapshot: &snap_b,
1441                operations: &ops_b,
1442                resolved: &resolved_b,
1443            },
1444        ];
1445        let mut store = RegisterStore::new();
1446        let plan = plan_apply(&sections, &store).expect("phase1");
1447        assert_eq!(plan.files.len(), 2);
1448        assert_eq!(plan.files[1].final_bytes, b"src\n");
1449
1450        // Partial failure: discard registers.
1451        let plan_partial = plan_apply(&sections, &store).unwrap();
1452        let envelope = simulate_phase2(
1453            plan_partial,
1454            &mut store,
1455            Some((1, FileClassification::FailedWrite)),
1456        );
1457        assert!(envelope.success);
1458        assert!(!envelope.complete);
1459        assert!(!envelope.registers_committed);
1460        assert!(store.get(&RegisterRef::Named("shared".into())).is_none());
1461        assert_eq!(
1462            envelope.files[1].classification,
1463            FileClassification::FailedWrite
1464        );
1465        assert_eq!(envelope.files.get(2).map(|f| f.classification), None);
1466        // only two files; file 0 applied, file 1 failed — no not_attempted after last
1467        assert_eq!(
1468            envelope.files[0].classification,
1469            FileClassification::Applied
1470        );
1471
1472        // Stopping failure on first file → second not_attempted, registers discarded.
1473        let plan_all_fail_prefix = plan_apply(&sections, &store).unwrap();
1474        let envelope = simulate_phase2(
1475            plan_all_fail_prefix,
1476            &mut store,
1477            Some((0, FileClassification::FailedBaselineDrift)),
1478        );
1479        assert!(!envelope.success);
1480        assert!(!envelope.complete);
1481        assert!(!envelope.registers_committed);
1482        assert_eq!(
1483            envelope.files[1].classification,
1484            FileClassification::NotAttempted
1485        );
1486        assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
1487
1488        // Full success commits registers.
1489        let plan_ok = plan_apply(&sections, &store).unwrap();
1490        let envelope = simulate_phase2(plan_ok, &mut store, None);
1491        assert!(envelope.success);
1492        assert!(envelope.complete);
1493        assert!(envelope.registers_committed);
1494        assert_eq!(
1495            store.get(&RegisterRef::Named("shared".into())),
1496            Some(["src".to_string()].as_slice())
1497        );
1498    }
1499
1500    #[test]
1501    fn a8_applied_star_variants_still_commit_registers() {
1502        let mut store = RegisterStore::new();
1503        let mut staged = store.stage();
1504        staged
1505            .capture(RegisterRef::Named("n".into()), vec!["v".into()])
1506            .unwrap();
1507        assert!(commit_registers_if_complete(
1508            &mut store,
1509            staged,
1510            &[
1511                FileClassification::Applied,
1512                FileClassification::AppliedWithValidationFailure,
1513                FileClassification::AppliedTagUnavailable,
1514            ]
1515        ));
1516        assert!(store.get(&RegisterRef::Named("n".into())).is_some());
1517    }
1518
1519    #[test]
1520    fn crlf_baseline_preserves_terminator_kind() {
1521        let bytes = b"a\r\nb\r\n";
1522        let snapshot = whole_snapshot(bytes);
1523        let ops = vec![put_text("1", &["A"])];
1524        let addresses = resolve_ops(&snapshot, &ops);
1525        let mut staged = RegisterStore::new().stage();
1526        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
1527        assert_eq!(planned.final_bytes, b"A\r\nb\r\n");
1528    }
1529
1530    #[test]
1531    fn unseen_line_never_reaches_apply() {
1532        let bytes = b"only\n";
1533        let snapshot = scan_bytes_with_request(bytes, ScanRequest::new(CoverageInput::range(1, 1)))
1534            .snapshot
1535            .unwrap();
1536        // Snapshot saw line 1; address line 1 is fine. Craft resolved span for
1537        // line 1 against a snapshot that did not retain it.
1538        let empty_seen = scan_bytes_with_request(bytes, ScanRequest::new(CoverageInput::lines([])))
1539            .snapshot
1540            .unwrap();
1541        let ops = vec![put_text("1", &["x"])];
1542        let addresses = vec![ResolvedAddress::Span(LineSpan { start: 1, end: 1 })];
1543        let mut staged = RegisterStore::new().stage();
1544        let err = apply_simple_ops(bytes, &empty_seen, &ops, &addresses, &mut staged)
1545            .expect_err("unseen");
1546        assert_eq!(err.code, HashlineRejectionCode::UnseenLine);
1547        let _ = snapshot;
1548    }
1549
1550    /// Corpus-driven coverage for the apply/repair/register rows this slice owns.
1551    ///
1552    /// Categories deferred elsewhere (with an explicit owner):
1553    /// - byte-model families → scan slice
1554    /// - bof/eof/block/one-line addressing → syntax slice
1555    /// - registered-deviation* → oracle parity / deviation controls
1556    /// - exact-verbatim-remap *landing search* → recovery slice (rows still
1557    ///   exercise matching-baseline apply and stale rejection here)
1558    #[test]
1559    fn oracle_corpus_apply_repair_register_rows() {
1560        use base64::Engine as _;
1561        use serde_json::Value;
1562
1563        const OWNED: &[&str] = &[
1564            "repair",
1565            "repair-negative-control",
1566            "named-register",
1567            "anonymous-register",
1568            "cross-file-register",
1569            "register-overflow",
1570        ];
1571        const DEFERRED: &[&str] = &[
1572            "lf",
1573            "lf-rejection",
1574            "crlf",
1575            "crlf-rejection",
1576            "mixed-terminators",
1577            "mixed-terminators-rejection",
1578            "bom",
1579            "bom-rejection",
1580            "empty",
1581            "empty-rejection",
1582            "missing-final-newline",
1583            "missing-final-newline-rejection",
1584            "bof",
1585            "bof-rejection",
1586            "eof",
1587            "eof-rejection",
1588            "eof-relative",
1589            "eof-relative-rejection",
1590            "one-line",
1591            "one-line-rejection",
1592            "empty-boundary",
1593            "empty-boundary-rejection",
1594            "block",
1595            "block-rejection",
1596            "unicode",
1597            "unicode-rejection",
1598            "trailing-whitespace",
1599            "trailing-whitespace-rejection",
1600            "registered-deviation",
1601            "registered-deviation-negative-control",
1602        ];
1603
1604        let mut consumed = 0usize;
1605        let mut deferred = 0usize;
1606        for line in include_str!("../oracle/fixtures.jsonl").lines() {
1607            let row: Value = serde_json::from_str(line).expect("oracle fixture JSON must parse");
1608            let category = row["fixture_category"]
1609                .as_str()
1610                .expect("oracle fixture category must be a string");
1611            if !OWNED.contains(&category) {
1612                assert!(
1613                    DEFERRED.contains(&category),
1614                    "new oracle category {category:?} needs an explicit slice owner"
1615                );
1616                deferred += 1;
1617                continue;
1618            }
1619            consumed += 1;
1620
1621            let id = row["id"].as_str().unwrap();
1622            let bytes = base64::engine::general_purpose::STANDARD
1623                .decode(row["initial_base64"].as_str().unwrap())
1624                .expect("oracle fixture initial_base64 must decode");
1625            let snapshot = whole_snapshot(&bytes);
1626            assert_eq!(
1627                snapshot.tag,
1628                row["snapshot_tag"].as_str().unwrap(),
1629                "fixture {id} tag"
1630            );
1631            assert_eq!(
1632                row["operation"].as_str().unwrap(),
1633                "PUT",
1634                "fixture {id} operation"
1635            );
1636
1637            let outcome = row["oracle_outcome"].as_str().unwrap();
1638            let expected_response = row["expected_response"].as_str().unwrap();
1639            let mutation = row["mutation"].as_str().unwrap();
1640            match outcome {
1641                "accepted" => {
1642                    assert_eq!(expected_response, "applied", "fixture {id}");
1643                    assert_eq!(mutation, "mutates", "fixture {id}");
1644                    assert!(row["rejection_code"].is_null(), "fixture {id}");
1645                }
1646                "rejected" => {
1647                    assert_eq!(expected_response, "rejected", "fixture {id}");
1648                    assert_eq!(mutation, "unchanged", "fixture {id}");
1649                    assert!(
1650                        row["rejection_code"].as_str().is_some(),
1651                        "fixture {id} needs a rejection code"
1652                    );
1653                }
1654                other => panic!("fixture {id}: unknown oracle_outcome {other}"),
1655            }
1656
1657            match category {
1658                "repair" => drive_repair_accepted(&row, &bytes, &snapshot),
1659                "repair-negative-control" => drive_repair_negative(&row, &bytes, &snapshot),
1660                "named-register" | "anonymous-register" | "cross-file-register" => {
1661                    drive_register_accepted(&row, &bytes, &snapshot)
1662                }
1663                "register-overflow" => drive_register_overflow(&row, &bytes, &snapshot),
1664                _ => unreachable!("owned category must be handled"),
1665            }
1666        }
1667
1668        assert_eq!(
1669            consumed, 12,
1670            "apply/repair/register corpus must consume exactly 12 owned rows"
1671        );
1672        assert_eq!(
1673            deferred, 116,
1674            "remaining corpus rows must stay explicitly deferred to other slices"
1675        );
1676    }
1677
1678    fn fixture_address(address: &str) -> String {
1679        if let Some(rest) = address.strip_prefix("line:") {
1680            return rest.to_string();
1681        }
1682        if let Some(rest) = address.strip_prefix("range:") {
1683            // Corpus uses `1-3`; the parser's canonical form is `1.=3`.
1684            return rest.replace('-', ".=");
1685        }
1686        if let Some(rest) = address.strip_prefix("gap:") {
1687            // `gap:1/2` is the insertion point after line 1 (before line 2).
1688            if let Some((left, _right)) = rest.split_once('/') {
1689                if left.eq_ignore_ascii_case("BOF") {
1690                    return "0".into();
1691                }
1692                return format!(">{left}");
1693            }
1694        }
1695        address.to_string()
1696    }
1697
1698    fn repair_body(repair: &str, fixture_address_label: &str, bytes: &[u8]) -> Vec<String> {
1699        let lines = baseline_lines(&Baseline::from_bytes(bytes.to_vec())).unwrap();
1700        match repair {
1701            "boundary-echo" if fixture_address_label.starts_with("gap:") => {
1702                // Corpus gap row: a plain insert proves the address applies. The
1703                // echo layer itself is locked by the hand-written span cases.
1704                vec!["inserted".into()]
1705            }
1706            "boundary-echo" => {
1707                // Span form: restate neighbors so the echo layer can fire.
1708                let mid = lines.get(1).cloned().unwrap_or_else(|| "TWO".into());
1709                vec![
1710                    lines.first().cloned().unwrap_or_default(),
1711                    mid.to_ascii_uppercase(),
1712                    lines.get(2).cloned().unwrap_or_default(),
1713                ]
1714            }
1715            "indent" => vec!["    run_now()".into()],
1716            "replacement-coalescing" => vec!["new-a".into(), "new-b".into(), "new-c".into()],
1717            // Exact landing search is owned by the recovery slice; here the row
1718            // still proves matching-baseline apply and stale rejection.
1719            "exact-verbatim-remap" => vec!["moved".into()],
1720            other => panic!("unexpected repair label {other} at {fixture_address_label}"),
1721        }
1722    }
1723
1724    fn drive_repair_accepted(row: &serde_json::Value, bytes: &[u8], snapshot: &Snapshot) {
1725        let id = row["id"].as_str().unwrap();
1726        let repair = row["repair"].as_str().expect("repair row names its layer");
1727        let fixture_addr = row["address"].as_str().unwrap();
1728        let address = fixture_address(fixture_addr);
1729        let body = repair_body(repair, fixture_addr, bytes);
1730        let body_refs: Vec<&str> = body.iter().map(String::as_str).collect();
1731        let ops = vec![put_text(&address, &body_refs)];
1732        let addresses = resolve_ops(snapshot, &ops);
1733        let mut staged = RegisterStore::new().stage();
1734        let planned = apply_simple_ops(bytes, snapshot, &ops, &addresses, &mut staged)
1735            .unwrap_or_else(|error| panic!("{id} accepted repair must apply: {error:?}"));
1736        assert_ne!(
1737            planned.final_bytes, bytes,
1738            "{id} accepted repair must mutate"
1739        );
1740        match repair {
1741            "boundary-echo" => {
1742                assert!(
1743                    String::from_utf8_lossy(&planned.final_bytes).contains("inserted")
1744                        || planned.repair_layers.contains(&"boundary-echo"),
1745                    "{id} boundary-echo row must apply"
1746                );
1747            }
1748            "indent" => {
1749                assert!(
1750                    String::from_utf8_lossy(&planned.final_bytes).contains("run_now()"),
1751                    "{id} indent repair path must land the body"
1752                );
1753            }
1754            "replacement-coalescing" => {
1755                assert_eq!(
1756                    planned.final_bytes, b"new-a\nnew-b\nnew-c\n",
1757                    "{id} coalesced replacement"
1758                );
1759                assert!(
1760                    planned.repair_layers.contains(&"replacement-coalescing"),
1761                    "{id} should record replacement-coalescing"
1762                );
1763            }
1764            "exact-verbatim-remap" => {
1765                assert_eq!(
1766                    planned.final_bytes, b"moved\nkeep\nneedle\n",
1767                    "{id} matching-baseline apply (remap landing deferred)"
1768                );
1769            }
1770            other => panic!("{id}: unhandled repair {other}"),
1771        }
1772    }
1773
1774    fn drive_repair_negative(row: &serde_json::Value, bytes: &[u8], snapshot: &Snapshot) {
1775        let id = row["id"].as_str().unwrap();
1776        assert_eq!(row["negative_control"], true, "{id}");
1777        assert_eq!(row["mutation_check"].as_str().unwrap(), "must_not_mutate");
1778        assert_eq!(row["control_failure_if_equal"], true, "{id}");
1779        assert_eq!(
1780            row["rejection_code"].as_str().unwrap(),
1781            "hashline_stale_tag",
1782            "{id}"
1783        );
1784        let repair = row["repair"].as_str().unwrap();
1785        let fixture_addr = row["address"].as_str().unwrap();
1786        let address = fixture_address(fixture_addr);
1787        let body = repair_body(repair, fixture_addr, bytes);
1788        let body_refs: Vec<&str> = body.iter().map(String::as_str).collect();
1789        let ops = vec![put_text(&address, &body_refs)];
1790        let addresses = resolve_ops(snapshot, &ops);
1791        let span = addresses
1792            .iter()
1793            .find_map(|address| address.addressed_span())
1794            .or_else(|| {
1795                // Gap inserts verify anchors, not a span; flip an anchor line.
1796                addresses.iter().find_map(|address| match address {
1797                    ResolvedAddress::Gap(gap) => gap.after.or(gap.before).map(|line| LineSpan {
1798                        start: line,
1799                        end: line,
1800                    }),
1801                    _ => None,
1802                })
1803            })
1804            .expect("repair negative control needs an addressable line");
1805        let baseline = Baseline::from_bytes(bytes.to_vec());
1806        let mut drifted = bytes.to_vec();
1807        let mut offset = 0usize;
1808        for line in 1..span.start {
1809            offset += baseline.raw_record(line).unwrap().to_bytes().len();
1810        }
1811        let target = baseline.raw_record(span.start).unwrap();
1812        if !target.content.is_empty() {
1813            drifted[offset] ^= 0x20;
1814        } else {
1815            drifted.insert(offset, b'X');
1816        }
1817        let mut staged = RegisterStore::new().stage();
1818        let err = apply_simple_ops(&drifted, snapshot, &ops, &addresses, &mut staged)
1819            .expect_err("{id} negative control must reject");
1820        assert_eq!(
1821            err.code,
1822            HashlineRejectionCode::StaleTag,
1823            "{id} must reject as stale before repair mutates"
1824        );
1825        assert_eq!(staged.writes().len(), 0, "{id} must not stage registers");
1826
1827        // Non-vacuity: matching baseline still mutates under the same op.
1828        let mut ok = RegisterStore::new().stage();
1829        let planned = apply_simple_ops(bytes, snapshot, &ops, &addresses, &mut ok)
1830            .unwrap_or_else(|error| panic!("{id} positive twin must apply: {error:?}"));
1831        assert_ne!(planned.final_bytes, bytes, "{id} control_failure_if_equal");
1832    }
1833
1834    fn drive_register_accepted(row: &serde_json::Value, bytes: &[u8], snapshot: &Snapshot) {
1835        let id = row["id"].as_str().unwrap();
1836        let register_label = row["register"].as_str().unwrap();
1837        let register = match register_label {
1838            "@_" => RegisterRef::Anonymous,
1839            label => {
1840                let name = label.strip_prefix('@').unwrap_or(label);
1841                RegisterRef::Named(name.to_string())
1842            }
1843        };
1844        let category = row["fixture_category"].as_str().unwrap();
1845
1846        if category == "cross-file-register" {
1847            let bytes_b = b"dst\n";
1848            let snap_b = whole_snapshot(bytes_b);
1849            let baseline_a = Baseline::from_bytes(bytes.to_vec());
1850            let baseline_b = Baseline::from_bytes(bytes_b.to_vec());
1851            let ops_a = vec![cut("1", Some(register.clone()))];
1852            let ops_b = vec![Operation::Put(PutOperation {
1853                address: parse_address("1").unwrap(),
1854                source: PutSource::Register(register.clone()),
1855                line: 1,
1856            })];
1857            let resolved_a: Vec<ResolvedOperation> = resolve_ops(snapshot, &ops_a)
1858                .into_iter()
1859                .enumerate()
1860                .map(|(operation_index, address)| ResolvedOperation {
1861                    operation_index,
1862                    address,
1863                })
1864                .collect();
1865            let resolved_b: Vec<ResolvedOperation> = resolve_ops(&snap_b, &ops_b)
1866                .into_iter()
1867                .enumerate()
1868                .map(|(operation_index, address)| ResolvedOperation {
1869                    operation_index,
1870                    address,
1871                })
1872                .collect();
1873            let sections = [
1874                SectionPlanInput {
1875                    canonical_path: Path::new("src.txt"),
1876                    requested_path: "src.txt",
1877                    baseline: &baseline_a,
1878                    snapshot,
1879                    operations: &ops_a,
1880                    resolved: &resolved_a,
1881                },
1882                SectionPlanInput {
1883                    canonical_path: Path::new("dst.txt"),
1884                    requested_path: "dst.txt",
1885                    baseline: &baseline_b,
1886                    snapshot: &snap_b,
1887                    operations: &ops_b,
1888                    resolved: &resolved_b,
1889                },
1890            ];
1891            let mut store = RegisterStore::new();
1892            let plan = plan_apply(&sections, &store).expect("{id} cross-file plan");
1893            assert_eq!(plan.files[1].final_bytes, bytes, "{id} paste destination");
1894            let envelope = simulate_phase2(plan, &mut store, None);
1895            assert!(envelope.registers_committed, "{id} commits on full apply");
1896            assert_eq!(
1897                store.get(&register).map(|lines| lines.join("\n")),
1898                Some(
1899                    String::from_utf8_lossy(bytes)
1900                        .trim_end_matches('\n')
1901                        .to_string()
1902                ),
1903                "{id} session register"
1904            );
1905            return;
1906        }
1907
1908        let ops = vec![cut("1", Some(register.clone()))];
1909        let addresses = resolve_ops(snapshot, &ops);
1910        let mut store = RegisterStore::new();
1911        let mut staged = store.stage();
1912        let planned = apply_simple_ops(bytes, snapshot, &ops, &addresses, &mut staged)
1913            .unwrap_or_else(|error| panic!("{id} register cut must apply: {error:?}"));
1914        assert_ne!(planned.final_bytes, bytes, "{id} cut mutates");
1915        let captured = staged
1916            .get(&register)
1917            .unwrap_or_else(|| panic!("{id} must stage {register_label}"));
1918        assert_eq!(
1919            captured.join("\n"),
1920            String::from_utf8_lossy(bytes).trim_end_matches('\n'),
1921            "{id} capture bytes"
1922        );
1923        assert!(
1924            commit_registers_if_complete(&mut store, staged, &[FileClassification::Applied]),
1925            "{id} commit"
1926        );
1927        assert!(store.get(&register).is_some(), "{id} session publish");
1928    }
1929
1930    fn drive_register_overflow(row: &serde_json::Value, _bytes: &[u8], _snapshot: &Snapshot) {
1931        let id = row["id"].as_str().unwrap();
1932        assert_eq!(
1933            row["rejection_code"].as_str().unwrap(),
1934            "hashline_register_overflow",
1935            "{id}"
1936        );
1937        let huge = "x".repeat(MAX_REGISTER_BYTES + 1);
1938        let big = format!("{huge}\n");
1939        let big_bytes = big.as_bytes();
1940        let snapshot = whole_snapshot(big_bytes);
1941        let ops = vec![cut("1", Some(RegisterRef::Named("oversized".into())))];
1942        let addresses = resolve_ops(&snapshot, &ops);
1943        let mut staged = RegisterStore::new().stage();
1944        let err = apply_simple_ops(big_bytes, &snapshot, &ops, &addresses, &mut staged)
1945            .expect_err("{id} must overflow");
1946        assert_eq!(err.code, HashlineRejectionCode::RegisterOverflow, "{id}");
1947        assert_eq!(err.stage, RejectionStage::Register, "{id}");
1948        assert_eq!(staged.writes().len(), 0, "{id} stages nothing on overflow");
1949    }
1950}