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, Operation,
21    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 section without mutating the session register store or disk.
173///
174/// Any verification, eligibility, or register-bound failure rejects the whole
175/// plan. Staged register captures remain local until
176/// [`commit_registers_if_complete`].
177pub fn plan_apply(
178    sections: &[SectionPlanInput<'_>],
179    session_registers: &RegisterStore,
180) -> Result<ApplyPlan, HashlineRejection> {
181    let mut staged = session_registers.stage();
182    let mut files = Vec::with_capacity(sections.len());
183    // One working baseline per canonical path so multi-section same-path edits
184    // compose in patch order against pre-request coordinates that the syntax
185    // layer already resolved. Intra-path renumbering is applied by replaying
186    // prior planned bytes when the same path appears again.
187    let mut working_bytes: BTreeMap<PathBuf, Vec<u8>> = BTreeMap::new();
188
189    for section in sections {
190        let path = section.canonical_path.to_path_buf();
191        let baseline_bytes = working_bytes
192            .get(&path)
193            .cloned()
194            .unwrap_or_else(|| section.baseline.bytes.clone());
195        let baseline = Baseline::from_bytes(baseline_bytes.clone());
196
197        // Verify each resolved address against the common baseline before any
198        // mutation is planned. Anchor mismatches reject; span mismatches are
199        // reported as recovery-required and refuse silent apply here.
200        for resolved in section.resolved {
201            match verify_exact(section.snapshot, &baseline, resolved.address) {
202                VerificationOutcome::Exact => {}
203                VerificationOutcome::RecoveryRequired(_) => {
204                    return Err(HashlineRejection::new(
205                        HashlineRejectionCode::StaleTag,
206                        RejectionStage::Recovery,
207                        "addressed content no longer matches the Phase-1 baseline",
208                    ));
209                }
210                VerificationOutcome::Rejected(rejection) => return Err(rejection),
211                VerificationOutcome::BlockNeedsResolution { .. } => {
212                    return Err(HashlineRejection::new(
213                        HashlineRejectionCode::BoundaryIneligible,
214                        RejectionStage::Eligibility,
215                        "block address was not expanded before apply planning",
216                    ));
217                }
218            }
219        }
220
221        let planned = apply_section_ops(
222            section.requested_path,
223            section.canonical_path,
224            &baseline,
225            section.operations,
226            section.resolved,
227            &mut staged,
228        )?;
229        working_bytes.insert(path, planned.final_bytes.clone());
230        files.push(planned);
231    }
232
233    Ok(ApplyPlan {
234        files,
235        staged_registers: staged,
236    })
237}
238
239/// Apply PUT/CUT/REM operations for one section against one baseline.
240pub fn apply_section_ops(
241    requested_path: &str,
242    canonical_path: &Path,
243    baseline: &Baseline,
244    operations: &[Operation],
245    resolved: &[ResolvedOperation],
246    registers: &mut StagedRegisters,
247) -> Result<PlannedFile, HashlineRejection> {
248    if operations.len() != resolved.len() {
249        return Err(HashlineRejection::parse(
250            "resolved operation count does not match the parsed section",
251        ));
252    }
253
254    // REM is whole-file and exclusive.
255    if let Some(Operation::Rem(_)) = operations.first() {
256        if operations.len() != 1 {
257            return Err(HashlineRejection::parse(
258                "REM cannot be combined with other operations",
259            ));
260        }
261        return Ok(PlannedFile {
262            canonical_path: canonical_path.to_path_buf(),
263            requested_path: requested_path.to_string(),
264            baseline_bytes: baseline.bytes.clone(),
265            final_bytes: Vec::new(),
266            affected: AffectedRegion::default(),
267            remove_file: true,
268            warnings: Vec::new(),
269            repair_layers: Vec::new(),
270        });
271    }
272
273    // MV is owned by the transaction slice; refuse it here so this module's
274    // fence stays non-MV.
275    if operations
276        .iter()
277        .any(|operation| matches!(operation, Operation::Mv(_)))
278    {
279        return Err(HashlineRejection::parse(
280            "MV is not handled by the line-apply engine",
281        ));
282    }
283
284    let original_lines = baseline_lines(baseline)?;
285    let (default_term, trailing) = terminator_policy(&baseline.snapshot.records);
286    let mut edits = Vec::new();
287
288    for (operation, resolved_op) in operations.iter().zip(resolved.iter()) {
289        match operation {
290            Operation::Put(put) => {
291                edits.extend(lower_put(
292                    put,
293                    resolved_op.address,
294                    registers,
295                    resolved_op.operation_index,
296                )?);
297            }
298            Operation::Cut(cut) => {
299                edits.extend(lower_cut(
300                    cut,
301                    resolved_op.address,
302                    &original_lines,
303                    registers,
304                    resolved_op.operation_index,
305                )?);
306            }
307            Operation::Rem(RemOperation { .. }) | Operation::Mv(_) => unreachable!(),
308        }
309    }
310
311    let coalesced = coalesce_replacement_edits(&edits);
312    let coalesced_applied = if coalesced.len() != edits.len()
313        || coalesced
314            .iter()
315            .zip(edits.iter())
316            .any(|(left, right)| left != right)
317    {
318        true
319    } else {
320        // Even when the edit list shape is unchanged, a single multi-line
321        // replacement group is still the coalesced form.
322        find_replacement_group(&coalesced, 0).is_some()
323            && edits.iter().any(|edit| {
324                matches!(
325                    edit,
326                    LineEdit::Insert {
327                        mode: InsertMode::Replacement,
328                        ..
329                    }
330                )
331            })
332    };
333
334    let repaired = apply_repair_layers(&coalesced, &original_lines);
335    let mut repair_layers = repaired.layers_applied;
336    if coalesced_applied
337        && find_replacement_group(&coalesced, 0).is_some()
338        && !repair_layers.contains(&"replacement-coalescing")
339    {
340        // Record coalescing when a contiguous replacement group is the apply unit.
341        let has_multi_delete = coalesced
342            .iter()
343            .filter(|e| matches!(e, LineEdit::Delete { .. }))
344            .count()
345            > 1;
346        if has_multi_delete {
347            repair_layers.insert(0, "replacement-coalescing");
348        }
349    }
350
351    let final_lines = materialize_edits(&original_lines, &repaired.edits);
352    let final_bytes = join_lines(&final_lines, default_term, trailing);
353    let affected = affected_from_line_diff(&original_lines, &final_lines);
354
355    Ok(PlannedFile {
356        canonical_path: canonical_path.to_path_buf(),
357        requested_path: requested_path.to_string(),
358        baseline_bytes: baseline.bytes.clone(),
359        final_bytes,
360        affected,
361        remove_file: false,
362        warnings: repaired.warnings,
363        repair_layers,
364    })
365}
366
367fn lower_put(
368    put: &PutOperation,
369    address: ResolvedAddress,
370    registers: &mut StagedRegisters,
371    op_index: usize,
372) -> Result<Vec<LineEdit>, HashlineRejection> {
373    let target_is_span = matches!(address, ResolvedAddress::Span(_));
374    let body = match &put.source {
375        PutSource::Text(lines) => lines.clone(),
376        PutSource::Register(register) => registers.read_for_put(register, target_is_span)?,
377    };
378    Ok(lower_put_body(address, body, op_index))
379}
380
381fn lower_put_body(address: ResolvedAddress, body: Vec<String>, op_index: usize) -> Vec<LineEdit> {
382    match address {
383        ResolvedAddress::Span(span) => {
384            let mut edits = Vec::with_capacity(body.len() + (span.end - span.start + 1));
385            for text in body {
386                edits.push(LineEdit::Insert {
387                    anchor: span.start,
388                    place: InsertPlace::Before,
389                    text,
390                    mode: InsertMode::Replacement,
391                    op_index,
392                });
393            }
394            for line in span.start..=span.end {
395                edits.push(LineEdit::Delete { line, op_index });
396            }
397            edits
398        }
399        ResolvedAddress::Gap(gap) => {
400            let (anchor, place) = match (gap.before, gap.after) {
401                (None, Some(1)) | (None, None) => (1, InsertPlace::Bof),
402                (Some(before), None) => (before, InsertPlace::After),
403                (Some(before), Some(_)) => (before, InsertPlace::After),
404                (None, Some(after)) => (after, InsertPlace::Before),
405            };
406            // EOF gap with before=last uses After; pure EOF with no before uses Eof.
407            let place = if gap.before.is_some() && gap.after.is_none() {
408                InsertPlace::After
409            } else if gap.before.is_none() && gap.after.is_none() {
410                InsertPlace::Bof
411            } else if gap.before.is_none() && gap.after == Some(1) {
412                InsertPlace::Bof
413            } else {
414                place
415            };
416            let place =
417                if gap.before.is_some() && gap.after.is_none() && place == InsertPlace::After {
418                    // Prefer Eof when inserting after the last line so materialize
419                    // does not depend on the anchor still existing after deletes.
420                    InsertPlace::Eof
421                } else {
422                    place
423                };
424            body.into_iter()
425                .map(|text| LineEdit::Insert {
426                    anchor,
427                    place,
428                    text,
429                    mode: InsertMode::Plain,
430                    op_index,
431                })
432                .collect()
433        }
434        ResolvedAddress::WholeFile => {
435            // Treat whole-file PUT as replace-all when body is supplied.
436            let end = body.len().max(1);
437            let mut edits = Vec::new();
438            for text in &body {
439                edits.push(LineEdit::Insert {
440                    anchor: 1,
441                    place: InsertPlace::Before,
442                    text: text.clone(),
443                    mode: InsertMode::Replacement,
444                    op_index,
445                });
446            }
447            // Deletes are filled by the caller only when the baseline length is
448            // known; whole-file PUT via line ops is uncommon. Leave deletes to
449            // REM for full removal.
450            let _ = end;
451            edits
452        }
453        ResolvedAddress::BlockAnchor(_) | ResolvedAddress::BlockGapAnchor { .. } => Vec::new(),
454    }
455}
456
457fn lower_cut(
458    cut: &CutOperation,
459    address: ResolvedAddress,
460    lines: &[String],
461    registers: &mut StagedRegisters,
462    op_index: usize,
463) -> Result<Vec<LineEdit>, HashlineRejection> {
464    let span = match address {
465        ResolvedAddress::Span(span) => span,
466        ResolvedAddress::WholeFile => {
467            if lines.is_empty() {
468                return Ok(Vec::new());
469            }
470            crate::hashline::syntax::LineSpan {
471                start: 1,
472                end: lines.len(),
473            }
474        }
475        ResolvedAddress::Gap(_)
476        | ResolvedAddress::BlockAnchor(_)
477        | ResolvedAddress::BlockGapAnchor { .. } => {
478            return Err(HashlineRejection::eligibility(
479                HashlineRejectionCode::BoundaryIneligible,
480                "CUT requires a line or range address",
481            ));
482        }
483    };
484    let captured: RegisterLines = (span.start..=span.end)
485        .map(|line| lines.get(line - 1).cloned().unwrap_or_default())
486        .collect();
487    let register = cut.register.clone().unwrap_or(RegisterRef::Anonymous);
488    registers.capture(register, captured)?;
489    Ok((span.start..=span.end)
490        .map(|line| LineEdit::Delete { line, op_index })
491        .collect())
492}
493
494fn baseline_lines(baseline: &Baseline) -> Result<Vec<String>, HashlineRejection> {
495    let mut lines = Vec::with_capacity(baseline.snapshot.total_lines);
496    for line in 1..=baseline.snapshot.total_lines {
497        let record = baseline.raw_record(line).ok_or_else(|| {
498            HashlineRejection::parse(format!("baseline is missing raw record for line {line}"))
499        })?;
500        lines.push(line_content_utf8(record)?);
501    }
502    Ok(lines)
503}
504
505fn line_content_utf8(record: &RawLineRecord) -> Result<String, HashlineRejection> {
506    String::from_utf8(record.content.clone()).map_err(|_| {
507        HashlineRejection::new(
508            HashlineRejectionCode::UntaggablePath,
509            RejectionStage::Path,
510            "baseline line is not valid UTF-8",
511        )
512    })
513}
514
515/// Commit staged registers only when every file result is `applied*`.
516///
517/// On any stopping failure or `not_attempted` entry the staged captures are
518/// discarded and the session store is left unchanged.
519pub fn commit_registers_if_complete(
520    session: &mut RegisterStore,
521    staged: StagedRegisters,
522    classifications: &[FileClassification],
523) -> bool {
524    let all_applied = !classifications.is_empty()
525        && classifications
526            .iter()
527            .all(|classification| classification.is_applied_star());
528    if all_applied {
529        session.commit(staged);
530        true
531    } else {
532        RegisterStore::discard(staged);
533        false
534    }
535}
536
537/// Simulate ordered Phase-2 classification for non-MV files.
538///
539/// `fail_at` injects a stopping failure at the given file index so tests can
540/// lock partial and all-failed envelopes without real I/O. When `fail_at` is
541/// `None`, every planned file is classified `Applied`.
542pub fn simulate_phase2(
543    plan: ApplyPlan,
544    session_registers: &mut RegisterStore,
545    fail_at: Option<(usize, FileClassification)>,
546) -> ApplyResultEnvelope {
547    let ApplyPlan {
548        files,
549        staged_registers,
550    } = plan;
551    let mut results = Vec::with_capacity(files.len());
552    let mut stopped = false;
553    let mut stop_classification = FileClassification::NotAttempted;
554
555    for (index, file) in files.into_iter().enumerate() {
556        if stopped {
557            results.push(FileResult {
558                canonical_path: file.canonical_path,
559                requested_path: file.requested_path,
560                classification: FileClassification::NotAttempted,
561                mutation_state: MutationState::Unmutated,
562                final_bytes: None,
563                affected: AffectedRegion::default(),
564                warnings: Vec::new(),
565                remove_file: file.remove_file,
566            });
567            continue;
568        }
569        if let Some((fail_index, classification)) = fail_at {
570            if index == fail_index {
571                stopped = true;
572                stop_classification = classification;
573                results.push(FileResult {
574                    canonical_path: file.canonical_path,
575                    requested_path: file.requested_path,
576                    classification,
577                    mutation_state: classification.mutation_state(),
578                    final_bytes: None,
579                    affected: AffectedRegion::default(),
580                    warnings: file.warnings,
581                    remove_file: file.remove_file,
582                });
583                continue;
584            }
585        }
586        let classification = FileClassification::Applied;
587        results.push(FileResult {
588            canonical_path: file.canonical_path,
589            requested_path: file.requested_path,
590            classification,
591            mutation_state: classification.mutation_state(),
592            final_bytes: Some(file.final_bytes),
593            affected: file.affected,
594            warnings: file.warnings,
595            remove_file: file.remove_file,
596        });
597    }
598
599    let classifications: Vec<FileClassification> =
600        results.iter().map(|result| result.classification).collect();
601    let registers_committed =
602        commit_registers_if_complete(session_registers, staged_registers, &classifications);
603    let applied = classifications
604        .iter()
605        .filter(|classification| classification.is_applied_star())
606        .count();
607    let success = applied > 0;
608    let complete = applied == classifications.len() && !classifications.is_empty();
609    let _ = stop_classification;
610    ApplyResultEnvelope {
611        success,
612        complete,
613        files: results,
614        registers_committed,
615    }
616}
617
618/// Convenience: plan and apply a single-file PUT/CUT/REM patch body against
619/// known baseline bytes and a retained snapshot, using pre-resolved addresses.
620pub fn apply_simple_ops(
621    baseline_bytes: &[u8],
622    snapshot: &Snapshot,
623    operations: &[Operation],
624    addresses: &[ResolvedAddress],
625    registers: &mut StagedRegisters,
626) -> Result<PlannedFile, HashlineRejection> {
627    let baseline = Baseline::from_bytes(baseline_bytes.to_vec());
628    let resolved: Vec<ResolvedOperation> = addresses
629        .iter()
630        .enumerate()
631        .map(|(operation_index, address)| ResolvedOperation {
632            operation_index,
633            address: *address,
634        })
635        .collect();
636    for resolved_op in &resolved {
637        match verify_exact(snapshot, &baseline, resolved_op.address) {
638            VerificationOutcome::Exact => {}
639            VerificationOutcome::RecoveryRequired(_) => {
640                return Err(HashlineRejection::new(
641                    HashlineRejectionCode::StaleTag,
642                    RejectionStage::Recovery,
643                    "addressed content no longer matches the Phase-1 baseline",
644                ));
645            }
646            VerificationOutcome::Rejected(rejection) => return Err(rejection),
647            VerificationOutcome::BlockNeedsResolution { .. } => {
648                return Err(HashlineRejection::new(
649                    HashlineRejectionCode::BoundaryIneligible,
650                    RejectionStage::Eligibility,
651                    "block address was not expanded before apply",
652                ));
653            }
654        }
655    }
656    apply_section_ops(
657        "file",
658        Path::new("file"),
659        &baseline,
660        operations,
661        &resolved,
662        registers,
663    )
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use crate::hashline::scan::{scan_bytes, scan_bytes_with_request, CoverageInput, ScanRequest};
670    use crate::hashline::syntax::{
671        parse_address, resolve_address, LineSpan, PutOperation, RegisterRef,
672    };
673
674    fn whole_snapshot(bytes: &[u8]) -> Snapshot {
675        scan_bytes(bytes)
676    }
677
678    fn put_text(address: &str, body: &[&str]) -> Operation {
679        Operation::Put(PutOperation {
680            address: parse_address(address).unwrap(),
681            source: PutSource::Text(body.iter().map(|line| (*line).to_string()).collect()),
682            line: 1,
683        })
684    }
685
686    fn cut(address: &str, register: Option<RegisterRef>) -> Operation {
687        Operation::Cut(CutOperation {
688            address: parse_address(address).unwrap(),
689            register,
690            line: 1,
691        })
692    }
693
694    fn resolve_ops(snapshot: &Snapshot, operations: &[Operation]) -> Vec<ResolvedAddress> {
695        operations
696            .iter()
697            .map(|operation| match operation.address() {
698                Some(address) => resolve_address(address, snapshot).unwrap(),
699                None => ResolvedAddress::WholeFile,
700            })
701            .collect()
702    }
703
704    #[test]
705    fn put_replaces_a_single_line() {
706        let bytes = b"alpha\nbeta\ngamma\n";
707        let snapshot = whole_snapshot(bytes);
708        let ops = vec![put_text("2", &["BETA"])];
709        let addresses = resolve_ops(&snapshot, &ops);
710        let mut staged = RegisterStore::new().stage();
711        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
712        assert_eq!(planned.final_bytes, b"alpha\nBETA\ngamma\n");
713        assert!(!planned.affected.is_empty());
714    }
715
716    #[test]
717    fn put_inserts_into_a_gap() {
718        let bytes = b"one\ntwo\nthree\n";
719        let snapshot = whole_snapshot(bytes);
720        let ops = vec![put_text(">1", &["1.5"])];
721        let addresses = resolve_ops(&snapshot, &ops);
722        let mut staged = RegisterStore::new().stage();
723        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
724        assert_eq!(planned.final_bytes, b"one\n1.5\ntwo\nthree\n");
725    }
726
727    #[test]
728    fn cut_captures_and_deletes() {
729        let bytes = b"a\nb\nc\n";
730        let snapshot = whole_snapshot(bytes);
731        let ops = vec![cut("2", Some(RegisterRef::Named("clip".into())))];
732        let addresses = resolve_ops(&snapshot, &ops);
733        let mut store = RegisterStore::new();
734        let mut staged = store.stage();
735        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
736        assert_eq!(planned.final_bytes, b"a\nc\n");
737        assert_eq!(
738            staged.get(&RegisterRef::Named("clip".into())),
739            Some(["b".to_string()].as_slice())
740        );
741        // Not committed yet.
742        assert!(store.get(&RegisterRef::Named("clip".into())).is_none());
743        assert!(commit_registers_if_complete(
744            &mut store,
745            staged,
746            &[FileClassification::Applied]
747        ));
748        assert_eq!(
749            store.get(&RegisterRef::Named("clip".into())),
750            Some(["b".to_string()].as_slice())
751        );
752    }
753
754    #[test]
755    fn rem_clears_file_bytes() {
756        let bytes = b"gone\n";
757        let snapshot = whole_snapshot(bytes);
758        let ops = vec![Operation::Rem(RemOperation { line: 1 })];
759        let addresses = vec![ResolvedAddress::WholeFile];
760        let mut staged = RegisterStore::new().stage();
761        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
762        assert!(planned.remove_file);
763        assert!(planned.final_bytes.is_empty());
764        assert!(planned.affected.is_empty());
765    }
766
767    #[test]
768    fn cut_then_put_register_moves_lines() {
769        let bytes = b"keep\nmove-me\n";
770        let snapshot = whole_snapshot(bytes);
771        let ops = vec![
772            cut("2", Some(RegisterRef::Named("r".into()))),
773            Operation::Put(PutOperation {
774                // BOF insert form (`0`), not `<1`, which resolves a zero anchor.
775                address: parse_address("0").unwrap(),
776                source: PutSource::Register(RegisterRef::Named("r".into())),
777                line: 2,
778            }),
779        ];
780        let addresses = resolve_ops(&snapshot, &ops);
781        let mut staged = RegisterStore::new().stage();
782        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
783        assert_eq!(planned.final_bytes, b"move-me\nkeep\n");
784    }
785
786    #[test]
787    fn register_overflow_rejects_in_phase_one() {
788        let huge = "x".repeat(MAX_REGISTER_BYTES + 1);
789        let big = format!("{huge}\n");
790        let big_bytes = big.as_bytes();
791        let snapshot = whole_snapshot(big_bytes);
792        let ops = vec![cut("1", Some(RegisterRef::Named("oversized".into())))];
793        let addresses = resolve_ops(&snapshot, &ops);
794        let mut staged = RegisterStore::new().stage();
795        let err = apply_simple_ops(big_bytes, &snapshot, &ops, &addresses, &mut staged)
796            .expect_err("overflow");
797        assert_eq!(err.code, HashlineRejectionCode::RegisterOverflow);
798        assert_eq!(err.stage, RejectionStage::Register);
799    }
800
801    /// Mutation-checked negative control: a stale baseline must not be mutated
802    /// by any repair layer. The control fails the suite if apply returns
803    /// success with equal-looking "repaired" bytes from a mismatched snapshot.
804    fn repair_negative_control(repair: &'static str, bytes: &[u8], address: &str, body: &[&str]) {
805        let snapshot = whole_snapshot(bytes);
806        let ops = vec![put_text(address, body)];
807        let addresses = resolve_ops(&snapshot, &ops);
808        // Flip one content byte inside the addressed span so verification fails
809        // before any repair layer can run.
810        let mut drifted = bytes.to_vec();
811        let span = addresses
812            .iter()
813            .find_map(|address| address.addressed_span())
814            .expect("repair negative controls address a span");
815        let baseline = Baseline::from_bytes(bytes.to_vec());
816        let target = baseline
817            .raw_record(span.start)
818            .expect("addressed line exists in the original baseline");
819        // Locate the first content byte of the addressed line in the raw buffer.
820        let mut offset = 0usize;
821        for line in 1..span.start {
822            let record = baseline.raw_record(line).unwrap();
823            offset += record.to_bytes().len();
824        }
825        if !target.content.is_empty() {
826            drifted[offset] ^= 0x20;
827        } else {
828            // Empty addressed line: insert a marker byte into the content slot.
829            drifted.insert(offset, b'X');
830        }
831        let mut staged = RegisterStore::new().stage();
832        let err =
833            apply_simple_ops(&drifted, &snapshot, &ops, &addresses, &mut staged).expect_err(repair);
834        assert_eq!(
835            err.code,
836            HashlineRejectionCode::StaleTag,
837            "{repair} negative control must reject as stale"
838        );
839        // Staged captures must not have been written on the rejecting path.
840        assert_eq!(staged.writes().len(), 0);
841        // Non-vacuity: the matching baseline must still mutate under the same op.
842        let mut ok_staged = RegisterStore::new().stage();
843        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut ok_staged)
844            .unwrap_or_else(|error| panic!("{repair} positive path must apply: {error:?}"));
845        assert_ne!(
846            planned.final_bytes, bytes,
847            "{repair} positive path must mutate (control_failure_if_equal)"
848        );
849    }
850
851    #[test]
852    fn boundary_echo_repair_negative_control_is_mutation_checked() {
853        // Positive path: payload restates neighbors around a middle replacement.
854        let bytes = b"one\ntwo\nthree\n";
855        repair_negative_control("boundary-echo", bytes, "2", &["one", "TWO", "three"]);
856        // Direct layer assertion on the positive path.
857        let snapshot = whole_snapshot(bytes);
858        let ops = vec![put_text("2", &["one", "TWO", "three"])];
859        let addresses = resolve_ops(&snapshot, &ops);
860        let mut staged = RegisterStore::new().stage();
861        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
862        assert!(
863            planned.repair_layers.contains(&"boundary-echo")
864                || planned.final_bytes == b"one\nTWO\nthree\n",
865            "boundary-echo should drop restated neighbors: {:?}",
866            String::from_utf8_lossy(&planned.final_bytes)
867        );
868        assert_eq!(planned.final_bytes, b"one\nTWO\nthree\n");
869    }
870
871    #[test]
872    fn indent_repair_negative_control_is_mutation_checked() {
873        let bytes = b"    if (value > 90) {\n      result = error;\n    } else if (value > 70) {\n      result = plain;\n    } else {\n      result = warning;\n    }\n";
874        let body = [
875            "  result = error;",
876            "} else if (value > 70) {",
877            "  result = warning;",
878            "} else {",
879            "  result = plain;",
880        ];
881        repair_negative_control("indent", bytes, "2.=6", &body);
882    }
883
884    #[test]
885    fn replacement_coalescing_negative_control_is_mutation_checked() {
886        let bytes = b"old-a\nold-b\nold-c\n";
887        repair_negative_control(
888            "replacement-coalescing",
889            bytes,
890            "1.=3",
891            &["new-a", "new-b", "new-c"],
892        );
893        let snapshot = whole_snapshot(bytes);
894        let ops = vec![put_text("1.=3", &["new-a", "new-b", "new-c"])];
895        let addresses = resolve_ops(&snapshot, &ops);
896        let mut staged = RegisterStore::new().stage();
897        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
898        assert_eq!(planned.final_bytes, b"new-a\nnew-b\nnew-c\n");
899        assert!(
900            planned.repair_layers.contains(&"replacement-coalescing")
901                || find_replacement_group(
902                    &coalesce_replacement_edits(&{
903                        let mut edits = Vec::new();
904                        // ensure coalescing helper itself works on split edits
905                        edits.extend(lower_put_body(
906                            ResolvedAddress::Span(LineSpan { start: 1, end: 1 }),
907                            vec!["new-a".into()],
908                            0,
909                        ));
910                        edits.extend(lower_put_body(
911                            ResolvedAddress::Span(LineSpan { start: 2, end: 2 }),
912                            vec!["new-b".into()],
913                            0,
914                        ));
915                        edits.extend(lower_put_body(
916                            ResolvedAddress::Span(LineSpan { start: 3, end: 3 }),
917                            vec!["new-c".into()],
918                            0,
919                        ));
920                        edits
921                    }),
922                    0
923                )
924                .is_some()
925        );
926    }
927
928    #[test]
929    fn a8_phase1_is_all_or_nothing_and_mutation_free() {
930        let bytes_a = b"a1\na2\n";
931        let bytes_b = b"b1\nb2\n";
932        let snap_a = whole_snapshot(bytes_a);
933        let snap_b = whole_snapshot(bytes_b);
934        let baseline_a = Baseline::from_bytes(bytes_a.to_vec());
935        let baseline_b = Baseline::from_bytes(bytes_b.to_vec());
936        let ops_a = vec![put_text("1", &["A1"])];
937        let ops_b = vec![put_text("999", &["nope"])]; // ineligible address
938        let resolved_a: Vec<ResolvedOperation> = resolve_ops(&snap_a, &ops_a)
939            .into_iter()
940            .enumerate()
941            .map(|(operation_index, address)| ResolvedOperation {
942                operation_index,
943                address,
944            })
945            .collect();
946        // Force a bad resolved address for file B.
947        let resolved_b = vec![ResolvedOperation {
948            operation_index: 0,
949            address: ResolvedAddress::Span(LineSpan {
950                start: 999,
951                end: 999,
952            }),
953        }];
954        let sections = [
955            SectionPlanInput {
956                canonical_path: Path::new("a.txt"),
957                requested_path: "a.txt",
958                baseline: &baseline_a,
959                snapshot: &snap_a,
960                operations: &ops_a,
961                resolved: &resolved_a,
962            },
963            SectionPlanInput {
964                canonical_path: Path::new("b.txt"),
965                requested_path: "b.txt",
966                baseline: &baseline_b,
967                snapshot: &snap_b,
968                operations: &ops_b,
969                resolved: &resolved_b,
970            },
971        ];
972        let store = RegisterStore::new();
973        let err = plan_apply(&sections, &store).expect_err("phase1 rejects whole patch");
974        assert!(matches!(
975            err.code,
976            HashlineRejectionCode::UnseenLine
977                | HashlineRejectionCode::BoundaryIneligible
978                | HashlineRejectionCode::StaleTag
979        ));
980        // No session register mutation.
981        assert_eq!(store.named_count(), 0);
982    }
983
984    #[test]
985    fn a8_register_commit_only_when_every_file_is_applied_star() {
986        let bytes_a = b"src\n";
987        let bytes_b = b"dst\n";
988        let snap_a = whole_snapshot(bytes_a);
989        let snap_b = whole_snapshot(bytes_b);
990        let baseline_a = Baseline::from_bytes(bytes_a.to_vec());
991        let baseline_b = Baseline::from_bytes(bytes_b.to_vec());
992        let ops_a = vec![cut("1", Some(RegisterRef::Named("shared".into())))];
993        let ops_b = vec![Operation::Put(PutOperation {
994            address: parse_address("1").unwrap(),
995            source: PutSource::Register(RegisterRef::Named("shared".into())),
996            line: 1,
997        })];
998        let resolved_a: Vec<ResolvedOperation> = resolve_ops(&snap_a, &ops_a)
999            .into_iter()
1000            .enumerate()
1001            .map(|(operation_index, address)| ResolvedOperation {
1002                operation_index,
1003                address,
1004            })
1005            .collect();
1006        let resolved_b: Vec<ResolvedOperation> = resolve_ops(&snap_b, &ops_b)
1007            .into_iter()
1008            .enumerate()
1009            .map(|(operation_index, address)| ResolvedOperation {
1010                operation_index,
1011                address,
1012            })
1013            .collect();
1014        let sections = [
1015            SectionPlanInput {
1016                canonical_path: Path::new("a.txt"),
1017                requested_path: "a.txt",
1018                baseline: &baseline_a,
1019                snapshot: &snap_a,
1020                operations: &ops_a,
1021                resolved: &resolved_a,
1022            },
1023            SectionPlanInput {
1024                canonical_path: Path::new("b.txt"),
1025                requested_path: "b.txt",
1026                baseline: &baseline_b,
1027                snapshot: &snap_b,
1028                operations: &ops_b,
1029                resolved: &resolved_b,
1030            },
1031        ];
1032        let mut store = RegisterStore::new();
1033        let plan = plan_apply(&sections, &store).expect("phase1");
1034        assert_eq!(plan.files.len(), 2);
1035        assert_eq!(plan.files[1].final_bytes, b"src\n");
1036
1037        // Partial failure: discard registers.
1038        let plan_partial = plan_apply(&sections, &store).unwrap();
1039        let envelope = simulate_phase2(
1040            plan_partial,
1041            &mut store,
1042            Some((1, FileClassification::FailedWrite)),
1043        );
1044        assert!(envelope.success);
1045        assert!(!envelope.complete);
1046        assert!(!envelope.registers_committed);
1047        assert!(store.get(&RegisterRef::Named("shared".into())).is_none());
1048        assert_eq!(
1049            envelope.files[1].classification,
1050            FileClassification::FailedWrite
1051        );
1052        assert_eq!(envelope.files.get(2).map(|f| f.classification), None);
1053        // only two files; file 0 applied, file 1 failed — no not_attempted after last
1054        assert_eq!(
1055            envelope.files[0].classification,
1056            FileClassification::Applied
1057        );
1058
1059        // Stopping failure on first file → second not_attempted, registers discarded.
1060        let plan_all_fail_prefix = plan_apply(&sections, &store).unwrap();
1061        let envelope = simulate_phase2(
1062            plan_all_fail_prefix,
1063            &mut store,
1064            Some((0, FileClassification::FailedBaselineDrift)),
1065        );
1066        assert!(!envelope.success);
1067        assert!(!envelope.complete);
1068        assert!(!envelope.registers_committed);
1069        assert_eq!(
1070            envelope.files[1].classification,
1071            FileClassification::NotAttempted
1072        );
1073        assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
1074
1075        // Full success commits registers.
1076        let plan_ok = plan_apply(&sections, &store).unwrap();
1077        let envelope = simulate_phase2(plan_ok, &mut store, None);
1078        assert!(envelope.success);
1079        assert!(envelope.complete);
1080        assert!(envelope.registers_committed);
1081        assert_eq!(
1082            store.get(&RegisterRef::Named("shared".into())),
1083            Some(["src".to_string()].as_slice())
1084        );
1085    }
1086
1087    #[test]
1088    fn a8_applied_star_variants_still_commit_registers() {
1089        let mut store = RegisterStore::new();
1090        let mut staged = store.stage();
1091        staged
1092            .capture(RegisterRef::Named("n".into()), vec!["v".into()])
1093            .unwrap();
1094        assert!(commit_registers_if_complete(
1095            &mut store,
1096            staged,
1097            &[
1098                FileClassification::Applied,
1099                FileClassification::AppliedWithValidationFailure,
1100                FileClassification::AppliedTagUnavailable,
1101            ]
1102        ));
1103        assert!(store.get(&RegisterRef::Named("n".into())).is_some());
1104    }
1105
1106    #[test]
1107    fn crlf_baseline_preserves_terminator_kind() {
1108        let bytes = b"a\r\nb\r\n";
1109        let snapshot = whole_snapshot(bytes);
1110        let ops = vec![put_text("1", &["A"])];
1111        let addresses = resolve_ops(&snapshot, &ops);
1112        let mut staged = RegisterStore::new().stage();
1113        let planned = apply_simple_ops(bytes, &snapshot, &ops, &addresses, &mut staged).unwrap();
1114        assert_eq!(planned.final_bytes, b"A\r\nb\r\n");
1115    }
1116
1117    #[test]
1118    fn unseen_line_never_reaches_apply() {
1119        let bytes = b"only\n";
1120        let snapshot = scan_bytes_with_request(bytes, ScanRequest::new(CoverageInput::range(1, 1)))
1121            .snapshot
1122            .unwrap();
1123        // Snapshot saw line 1; address line 1 is fine. Craft resolved span for
1124        // line 1 against a snapshot that did not retain it.
1125        let empty_seen = scan_bytes_with_request(bytes, ScanRequest::new(CoverageInput::lines([])))
1126            .snapshot
1127            .unwrap();
1128        let ops = vec![put_text("1", &["x"])];
1129        let addresses = vec![ResolvedAddress::Span(LineSpan { start: 1, end: 1 })];
1130        let mut staged = RegisterStore::new().stage();
1131        let err = apply_simple_ops(bytes, &empty_seen, &ops, &addresses, &mut staged)
1132            .expect_err("unseen");
1133        assert_eq!(err.code, HashlineRejectionCode::UnseenLine);
1134        let _ = snapshot;
1135    }
1136
1137    /// Corpus-driven coverage for the apply/repair/register rows this slice owns.
1138    ///
1139    /// Categories deferred elsewhere (with an explicit owner):
1140    /// - byte-model families → scan slice
1141    /// - bof/eof/block/one-line addressing → syntax slice
1142    /// - registered-deviation* → oracle parity / deviation controls
1143    /// - exact-verbatim-remap *landing search* → recovery slice (rows still
1144    ///   exercise matching-baseline apply and stale rejection here)
1145    #[test]
1146    fn oracle_corpus_apply_repair_register_rows() {
1147        use base64::Engine as _;
1148        use serde_json::Value;
1149
1150        const OWNED: &[&str] = &[
1151            "repair",
1152            "repair-negative-control",
1153            "named-register",
1154            "anonymous-register",
1155            "cross-file-register",
1156            "register-overflow",
1157        ];
1158        const DEFERRED: &[&str] = &[
1159            "lf",
1160            "lf-rejection",
1161            "crlf",
1162            "crlf-rejection",
1163            "mixed-terminators",
1164            "mixed-terminators-rejection",
1165            "bom",
1166            "bom-rejection",
1167            "empty",
1168            "empty-rejection",
1169            "missing-final-newline",
1170            "missing-final-newline-rejection",
1171            "bof",
1172            "bof-rejection",
1173            "eof",
1174            "eof-rejection",
1175            "eof-relative",
1176            "eof-relative-rejection",
1177            "one-line",
1178            "one-line-rejection",
1179            "empty-boundary",
1180            "empty-boundary-rejection",
1181            "block",
1182            "block-rejection",
1183            "unicode",
1184            "unicode-rejection",
1185            "trailing-whitespace",
1186            "trailing-whitespace-rejection",
1187            "registered-deviation",
1188            "registered-deviation-negative-control",
1189        ];
1190
1191        let mut consumed = 0usize;
1192        let mut deferred = 0usize;
1193        for line in include_str!("../oracle/fixtures.jsonl").lines() {
1194            let row: Value = serde_json::from_str(line).expect("oracle fixture JSON must parse");
1195            let category = row["fixture_category"]
1196                .as_str()
1197                .expect("oracle fixture category must be a string");
1198            if !OWNED.contains(&category) {
1199                assert!(
1200                    DEFERRED.contains(&category),
1201                    "new oracle category {category:?} needs an explicit slice owner"
1202                );
1203                deferred += 1;
1204                continue;
1205            }
1206            consumed += 1;
1207
1208            let id = row["id"].as_str().unwrap();
1209            let bytes = base64::engine::general_purpose::STANDARD
1210                .decode(row["initial_base64"].as_str().unwrap())
1211                .expect("oracle fixture initial_base64 must decode");
1212            let snapshot = whole_snapshot(&bytes);
1213            assert_eq!(
1214                snapshot.tag,
1215                row["snapshot_tag"].as_str().unwrap(),
1216                "fixture {id} tag"
1217            );
1218            assert_eq!(
1219                row["operation"].as_str().unwrap(),
1220                "PUT",
1221                "fixture {id} operation"
1222            );
1223
1224            let outcome = row["oracle_outcome"].as_str().unwrap();
1225            let expected_response = row["expected_response"].as_str().unwrap();
1226            let mutation = row["mutation"].as_str().unwrap();
1227            match outcome {
1228                "accepted" => {
1229                    assert_eq!(expected_response, "applied", "fixture {id}");
1230                    assert_eq!(mutation, "mutates", "fixture {id}");
1231                    assert!(row["rejection_code"].is_null(), "fixture {id}");
1232                }
1233                "rejected" => {
1234                    assert_eq!(expected_response, "rejected", "fixture {id}");
1235                    assert_eq!(mutation, "unchanged", "fixture {id}");
1236                    assert!(
1237                        row["rejection_code"].as_str().is_some(),
1238                        "fixture {id} needs a rejection code"
1239                    );
1240                }
1241                other => panic!("fixture {id}: unknown oracle_outcome {other}"),
1242            }
1243
1244            match category {
1245                "repair" => drive_repair_accepted(&row, &bytes, &snapshot),
1246                "repair-negative-control" => drive_repair_negative(&row, &bytes, &snapshot),
1247                "named-register" | "anonymous-register" | "cross-file-register" => {
1248                    drive_register_accepted(&row, &bytes, &snapshot)
1249                }
1250                "register-overflow" => drive_register_overflow(&row, &bytes, &snapshot),
1251                _ => unreachable!("owned category must be handled"),
1252            }
1253        }
1254
1255        assert_eq!(
1256            consumed, 12,
1257            "apply/repair/register corpus must consume exactly 12 owned rows"
1258        );
1259        assert_eq!(
1260            deferred, 116,
1261            "remaining corpus rows must stay explicitly deferred to other slices"
1262        );
1263    }
1264
1265    fn fixture_address(address: &str) -> String {
1266        if let Some(rest) = address.strip_prefix("line:") {
1267            return rest.to_string();
1268        }
1269        if let Some(rest) = address.strip_prefix("range:") {
1270            // Corpus uses `1-3`; the parser's canonical form is `1.=3`.
1271            return rest.replace('-', ".=");
1272        }
1273        if let Some(rest) = address.strip_prefix("gap:") {
1274            // `gap:1/2` is the insertion point after line 1 (before line 2).
1275            if let Some((left, _right)) = rest.split_once('/') {
1276                if left.eq_ignore_ascii_case("BOF") {
1277                    return "0".into();
1278                }
1279                return format!(">{left}");
1280            }
1281        }
1282        address.to_string()
1283    }
1284
1285    fn repair_body(repair: &str, fixture_address_label: &str, bytes: &[u8]) -> Vec<String> {
1286        let lines = baseline_lines(&Baseline::from_bytes(bytes.to_vec())).unwrap();
1287        match repair {
1288            "boundary-echo" if fixture_address_label.starts_with("gap:") => {
1289                // Corpus gap row: a plain insert proves the address applies. The
1290                // echo layer itself is locked by the hand-written span cases.
1291                vec!["inserted".into()]
1292            }
1293            "boundary-echo" => {
1294                // Span form: restate neighbors so the echo layer can fire.
1295                let mid = lines.get(1).cloned().unwrap_or_else(|| "TWO".into());
1296                vec![
1297                    lines.first().cloned().unwrap_or_default(),
1298                    mid.to_ascii_uppercase(),
1299                    lines.get(2).cloned().unwrap_or_default(),
1300                ]
1301            }
1302            "indent" => vec!["    run_now()".into()],
1303            "replacement-coalescing" => vec!["new-a".into(), "new-b".into(), "new-c".into()],
1304            // Exact landing search is owned by the recovery slice; here the row
1305            // still proves matching-baseline apply and stale rejection.
1306            "exact-verbatim-remap" => vec!["moved".into()],
1307            other => panic!("unexpected repair label {other} at {fixture_address_label}"),
1308        }
1309    }
1310
1311    fn drive_repair_accepted(row: &serde_json::Value, bytes: &[u8], snapshot: &Snapshot) {
1312        let id = row["id"].as_str().unwrap();
1313        let repair = row["repair"].as_str().expect("repair row names its layer");
1314        let fixture_addr = row["address"].as_str().unwrap();
1315        let address = fixture_address(fixture_addr);
1316        let body = repair_body(repair, fixture_addr, bytes);
1317        let body_refs: Vec<&str> = body.iter().map(String::as_str).collect();
1318        let ops = vec![put_text(&address, &body_refs)];
1319        let addresses = resolve_ops(snapshot, &ops);
1320        let mut staged = RegisterStore::new().stage();
1321        let planned = apply_simple_ops(bytes, snapshot, &ops, &addresses, &mut staged)
1322            .unwrap_or_else(|error| panic!("{id} accepted repair must apply: {error:?}"));
1323        assert_ne!(
1324            planned.final_bytes, bytes,
1325            "{id} accepted repair must mutate"
1326        );
1327        match repair {
1328            "boundary-echo" => {
1329                assert!(
1330                    String::from_utf8_lossy(&planned.final_bytes).contains("inserted")
1331                        || planned.repair_layers.contains(&"boundary-echo"),
1332                    "{id} boundary-echo row must apply"
1333                );
1334            }
1335            "indent" => {
1336                assert!(
1337                    String::from_utf8_lossy(&planned.final_bytes).contains("run_now()"),
1338                    "{id} indent repair path must land the body"
1339                );
1340            }
1341            "replacement-coalescing" => {
1342                assert_eq!(
1343                    planned.final_bytes, b"new-a\nnew-b\nnew-c\n",
1344                    "{id} coalesced replacement"
1345                );
1346                assert!(
1347                    planned.repair_layers.contains(&"replacement-coalescing"),
1348                    "{id} should record replacement-coalescing"
1349                );
1350            }
1351            "exact-verbatim-remap" => {
1352                assert_eq!(
1353                    planned.final_bytes, b"moved\nkeep\nneedle\n",
1354                    "{id} matching-baseline apply (remap landing deferred)"
1355                );
1356            }
1357            other => panic!("{id}: unhandled repair {other}"),
1358        }
1359    }
1360
1361    fn drive_repair_negative(row: &serde_json::Value, bytes: &[u8], snapshot: &Snapshot) {
1362        let id = row["id"].as_str().unwrap();
1363        assert_eq!(row["negative_control"], true, "{id}");
1364        assert_eq!(row["mutation_check"].as_str().unwrap(), "must_not_mutate");
1365        assert_eq!(row["control_failure_if_equal"], true, "{id}");
1366        assert_eq!(
1367            row["rejection_code"].as_str().unwrap(),
1368            "hashline_stale_tag",
1369            "{id}"
1370        );
1371        let repair = row["repair"].as_str().unwrap();
1372        let fixture_addr = row["address"].as_str().unwrap();
1373        let address = fixture_address(fixture_addr);
1374        let body = repair_body(repair, fixture_addr, bytes);
1375        let body_refs: Vec<&str> = body.iter().map(String::as_str).collect();
1376        let ops = vec![put_text(&address, &body_refs)];
1377        let addresses = resolve_ops(snapshot, &ops);
1378        let span = addresses
1379            .iter()
1380            .find_map(|address| address.addressed_span())
1381            .or_else(|| {
1382                // Gap inserts verify anchors, not a span; flip an anchor line.
1383                addresses.iter().find_map(|address| match address {
1384                    ResolvedAddress::Gap(gap) => gap.after.or(gap.before).map(|line| LineSpan {
1385                        start: line,
1386                        end: line,
1387                    }),
1388                    _ => None,
1389                })
1390            })
1391            .expect("repair negative control needs an addressable line");
1392        let baseline = Baseline::from_bytes(bytes.to_vec());
1393        let mut drifted = bytes.to_vec();
1394        let mut offset = 0usize;
1395        for line in 1..span.start {
1396            offset += baseline.raw_record(line).unwrap().to_bytes().len();
1397        }
1398        let target = baseline.raw_record(span.start).unwrap();
1399        if !target.content.is_empty() {
1400            drifted[offset] ^= 0x20;
1401        } else {
1402            drifted.insert(offset, b'X');
1403        }
1404        let mut staged = RegisterStore::new().stage();
1405        let err = apply_simple_ops(&drifted, snapshot, &ops, &addresses, &mut staged)
1406            .expect_err("{id} negative control must reject");
1407        assert_eq!(
1408            err.code,
1409            HashlineRejectionCode::StaleTag,
1410            "{id} must reject as stale before repair mutates"
1411        );
1412        assert_eq!(staged.writes().len(), 0, "{id} must not stage registers");
1413
1414        // Non-vacuity: matching baseline still mutates under the same op.
1415        let mut ok = RegisterStore::new().stage();
1416        let planned = apply_simple_ops(bytes, snapshot, &ops, &addresses, &mut ok)
1417            .unwrap_or_else(|error| panic!("{id} positive twin must apply: {error:?}"));
1418        assert_ne!(planned.final_bytes, bytes, "{id} control_failure_if_equal");
1419    }
1420
1421    fn drive_register_accepted(row: &serde_json::Value, bytes: &[u8], snapshot: &Snapshot) {
1422        let id = row["id"].as_str().unwrap();
1423        let register_label = row["register"].as_str().unwrap();
1424        let register = match register_label {
1425            "@_" => RegisterRef::Anonymous,
1426            label => {
1427                let name = label.strip_prefix('@').unwrap_or(label);
1428                RegisterRef::Named(name.to_string())
1429            }
1430        };
1431        let category = row["fixture_category"].as_str().unwrap();
1432
1433        if category == "cross-file-register" {
1434            let bytes_b = b"dst\n";
1435            let snap_b = whole_snapshot(bytes_b);
1436            let baseline_a = Baseline::from_bytes(bytes.to_vec());
1437            let baseline_b = Baseline::from_bytes(bytes_b.to_vec());
1438            let ops_a = vec![cut("1", Some(register.clone()))];
1439            let ops_b = vec![Operation::Put(PutOperation {
1440                address: parse_address("1").unwrap(),
1441                source: PutSource::Register(register.clone()),
1442                line: 1,
1443            })];
1444            let resolved_a: Vec<ResolvedOperation> = resolve_ops(snapshot, &ops_a)
1445                .into_iter()
1446                .enumerate()
1447                .map(|(operation_index, address)| ResolvedOperation {
1448                    operation_index,
1449                    address,
1450                })
1451                .collect();
1452            let resolved_b: Vec<ResolvedOperation> = resolve_ops(&snap_b, &ops_b)
1453                .into_iter()
1454                .enumerate()
1455                .map(|(operation_index, address)| ResolvedOperation {
1456                    operation_index,
1457                    address,
1458                })
1459                .collect();
1460            let sections = [
1461                SectionPlanInput {
1462                    canonical_path: Path::new("src.txt"),
1463                    requested_path: "src.txt",
1464                    baseline: &baseline_a,
1465                    snapshot,
1466                    operations: &ops_a,
1467                    resolved: &resolved_a,
1468                },
1469                SectionPlanInput {
1470                    canonical_path: Path::new("dst.txt"),
1471                    requested_path: "dst.txt",
1472                    baseline: &baseline_b,
1473                    snapshot: &snap_b,
1474                    operations: &ops_b,
1475                    resolved: &resolved_b,
1476                },
1477            ];
1478            let mut store = RegisterStore::new();
1479            let plan = plan_apply(&sections, &store).expect("{id} cross-file plan");
1480            assert_eq!(plan.files[1].final_bytes, bytes, "{id} paste destination");
1481            let envelope = simulate_phase2(plan, &mut store, None);
1482            assert!(envelope.registers_committed, "{id} commits on full apply");
1483            assert_eq!(
1484                store.get(&register).map(|lines| lines.join("\n")),
1485                Some(
1486                    String::from_utf8_lossy(bytes)
1487                        .trim_end_matches('\n')
1488                        .to_string()
1489                ),
1490                "{id} session register"
1491            );
1492            return;
1493        }
1494
1495        let ops = vec![cut("1", Some(register.clone()))];
1496        let addresses = resolve_ops(snapshot, &ops);
1497        let mut store = RegisterStore::new();
1498        let mut staged = store.stage();
1499        let planned = apply_simple_ops(bytes, snapshot, &ops, &addresses, &mut staged)
1500            .unwrap_or_else(|error| panic!("{id} register cut must apply: {error:?}"));
1501        assert_ne!(planned.final_bytes, bytes, "{id} cut mutates");
1502        let captured = staged
1503            .get(&register)
1504            .unwrap_or_else(|| panic!("{id} must stage {register_label}"));
1505        assert_eq!(
1506            captured.join("\n"),
1507            String::from_utf8_lossy(bytes).trim_end_matches('\n'),
1508            "{id} capture bytes"
1509        );
1510        assert!(
1511            commit_registers_if_complete(&mut store, staged, &[FileClassification::Applied]),
1512            "{id} commit"
1513        );
1514        assert!(store.get(&register).is_some(), "{id} session publish");
1515    }
1516
1517    fn drive_register_overflow(row: &serde_json::Value, _bytes: &[u8], _snapshot: &Snapshot) {
1518        let id = row["id"].as_str().unwrap();
1519        assert_eq!(
1520            row["rejection_code"].as_str().unwrap(),
1521            "hashline_register_overflow",
1522            "{id}"
1523        );
1524        let huge = "x".repeat(MAX_REGISTER_BYTES + 1);
1525        let big = format!("{huge}\n");
1526        let big_bytes = big.as_bytes();
1527        let snapshot = whole_snapshot(big_bytes);
1528        let ops = vec![cut("1", Some(RegisterRef::Named("oversized".into())))];
1529        let addresses = resolve_ops(&snapshot, &ops);
1530        let mut staged = RegisterStore::new().stage();
1531        let err = apply_simple_ops(big_bytes, &snapshot, &ops, &addresses, &mut staged)
1532            .expect_err("{id} must overflow");
1533        assert_eq!(err.code, HashlineRejectionCode::RegisterOverflow, "{id}");
1534        assert_eq!(err.stage, RejectionStage::Register, "{id}");
1535        assert_eq!(staged.writes().len(), 0, "{id} stages nothing on overflow");
1536    }
1537}