1mod 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#[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 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#[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#[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 pub remove_file: bool,
128 pub warnings: Vec<String>,
129 pub repair_layers: Vec<&'static str>,
130}
131
132#[derive(Clone, Debug)]
134pub struct ApplyPlan {
135 pub files: Vec<PlannedFile>,
136 pub staged_registers: StagedRegisters,
137}
138
139#[derive(Clone, Debug, Eq, PartialEq)]
141pub struct ApplyResultEnvelope {
142 pub success: bool,
143 pub complete: bool,
144 pub files: Vec<FileResult>,
145 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#[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
172pub 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 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 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
239pub 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 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 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 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 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 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 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 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 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
515pub 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
537pub 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
618pub 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 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 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 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 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 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 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 assert_eq!(staged.writes().len(), 0);
841 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 let bytes = b"one\ntwo\nthree\n";
855 repair_negative_control("boundary-echo", bytes, "2", &["one", "TWO", "three"]);
856 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 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"])]; 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 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(§ions, &store).expect_err("phase1 rejects whole patch");
974 assert!(matches!(
975 err.code,
976 HashlineRejectionCode::UnseenLine
977 | HashlineRejectionCode::BoundaryIneligible
978 | HashlineRejectionCode::StaleTag
979 ));
980 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(§ions, &store).expect("phase1");
1034 assert_eq!(plan.files.len(), 2);
1035 assert_eq!(plan.files[1].final_bytes, b"src\n");
1036
1037 let plan_partial = plan_apply(§ions, &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 assert_eq!(
1055 envelope.files[0].classification,
1056 FileClassification::Applied
1057 );
1058
1059 let plan_all_fail_prefix = plan_apply(§ions, &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 let plan_ok = plan_apply(§ions, &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 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 #[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 return rest.replace('-', ".=");
1272 }
1273 if let Some(rest) = address.strip_prefix("gap:") {
1274 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 vec!["inserted".into()]
1292 }
1293 "boundary-echo" => {
1294 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-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 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 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(§ions, &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(®ister).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(®ister)
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(®ister).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}