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