1use std::collections::BTreeMap;
10use std::fmt;
11use std::path::{Path, PathBuf};
12
13use serde_json::Value;
14
15use crate::hashline::scan::{scan_bytes, RawLineRecord, Snapshot};
16use crate::hashline::snapshot::{equivalent_snapshots, SnapshotLookupError, SnapshotStore};
17
18#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct HashlineRequest {
22 pub patch: String,
23}
24
25pub fn validate_raw_arguments(arguments: &Value) -> Result<HashlineRequest, HashlineRejection> {
32 let Some(object) = arguments.as_object() else {
33 return Err(HashlineRejection::parse(
34 "hashline edit arguments must be an object containing only patch",
35 ));
36 };
37 if object.len() != 1 || !object.contains_key("patch") {
38 return Err(HashlineRejection::parse(
39 "hashline edit arguments must contain only the patch field",
40 ));
41 }
42 let Some(patch) = object.get("patch").and_then(Value::as_str) else {
43 return Err(HashlineRejection::parse("patch must be a string"));
44 };
45 if patch.trim().is_empty() {
46 return Err(HashlineRejection::parse("patch must not be empty"));
47 }
48 Ok(HashlineRequest {
49 patch: patch.to_string(),
50 })
51}
52
53#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
55pub enum HashlineRejectionCode {
56 MissingTag,
57 MalformedTag,
58 UnknownTag,
59 EvictedTag,
60 AmbiguousTag,
61 StaleTag,
62 UnseenLine,
63 BoundaryIneligible,
64 UntaggablePath,
65 RegisterOverflow,
66 BackupUnavailable,
67 ParseError,
68}
69
70impl HashlineRejectionCode {
71 pub const fn as_str(self) -> &'static str {
72 match self {
73 Self::MissingTag => "hashline_missing_tag",
74 Self::MalformedTag => "hashline_malformed_tag",
75 Self::UnknownTag => "hashline_unknown_tag",
76 Self::EvictedTag => "hashline_evicted_tag",
77 Self::AmbiguousTag => "hashline_ambiguous_tag",
78 Self::StaleTag => "hashline_stale_tag",
79 Self::UnseenLine => "hashline_unseen_line",
80 Self::BoundaryIneligible => "hashline_boundary_ineligible",
81 Self::UntaggablePath => "hashline_untaggable_path",
82 Self::RegisterOverflow => "hashline_register_overflow",
83 Self::BackupUnavailable => "hashline_backup_unavailable",
84 Self::ParseError => "hashline_parse_error",
85 }
86 }
87}
88
89impl fmt::Display for HashlineRejectionCode {
90 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91 formatter.write_str(self.as_str())
92 }
93}
94
95#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
97pub enum RejectionStage {
98 Parse,
99 Header,
100 Path,
101 Resolution,
102 Eligibility,
103 Verification,
104 Recovery,
105 Register,
106 Baseline,
107}
108
109impl RejectionStage {
110 pub const fn as_str(self) -> &'static str {
111 match self {
112 Self::Parse => "parse",
113 Self::Header => "header",
114 Self::Path => "path",
115 Self::Resolution => "resolution",
116 Self::Eligibility => "eligibility",
117 Self::Verification => "verification",
118 Self::Recovery => "recovery",
119 Self::Register => "register",
120 Self::Baseline => "baseline",
121 }
122 }
123}
124
125impl fmt::Display for RejectionStage {
126 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127 formatter.write_str(self.as_str())
128 }
129}
130
131#[derive(Clone, Debug, Eq, PartialEq)]
133pub struct HashlineRejection {
134 pub code: HashlineRejectionCode,
135 pub stage: RejectionStage,
136 pub message: String,
137 pub steering: String,
138}
139
140impl HashlineRejection {
141 pub fn new(
142 code: HashlineRejectionCode,
143 stage: RejectionStage,
144 message: impl Into<String>,
145 ) -> Self {
146 Self {
147 code,
148 stage,
149 message: message.into(),
150 steering: steering_for(code, stage).to_string(),
151 }
152 }
153
154 pub fn parse(message: impl Into<String>) -> Self {
155 Self::new(
156 HashlineRejectionCode::ParseError,
157 RejectionStage::Parse,
158 message,
159 )
160 }
161
162 pub fn missing_tag(message: impl Into<String>) -> Self {
163 Self::new(
164 HashlineRejectionCode::MissingTag,
165 RejectionStage::Header,
166 message,
167 )
168 }
169
170 pub fn malformed_tag(message: impl Into<String>) -> Self {
171 Self::new(
172 HashlineRejectionCode::MalformedTag,
173 RejectionStage::Header,
174 message,
175 )
176 }
177
178 pub fn resolution(code: HashlineRejectionCode, message: impl Into<String>) -> Self {
179 debug_assert!(matches!(
180 code,
181 HashlineRejectionCode::UnknownTag
182 | HashlineRejectionCode::EvictedTag
183 | HashlineRejectionCode::AmbiguousTag
184 ));
185 Self::new(code, RejectionStage::Resolution, message)
186 }
187
188 pub fn eligibility(code: HashlineRejectionCode, message: impl Into<String>) -> Self {
189 debug_assert!(matches!(
190 code,
191 HashlineRejectionCode::UnseenLine | HashlineRejectionCode::BoundaryIneligible
192 ));
193 Self::new(code, RejectionStage::Eligibility, message)
194 }
195
196 pub fn stale_verification(message: impl Into<String>) -> Self {
197 Self::new(
198 HashlineRejectionCode::StaleTag,
199 RejectionStage::Verification,
200 message,
201 )
202 }
203
204 pub fn stale_recovery(message: impl Into<String>) -> Self {
205 Self::new(
206 HashlineRejectionCode::StaleTag,
207 RejectionStage::Recovery,
208 message,
209 )
210 }
211
212 pub fn ambiguous_recovery(message: impl Into<String>) -> Self {
213 Self::new(
214 HashlineRejectionCode::AmbiguousTag,
215 RejectionStage::Recovery,
216 message,
217 )
218 }
219
220 pub fn untaggable_path(message: impl Into<String>) -> Self {
221 Self::new(
222 HashlineRejectionCode::UntaggablePath,
223 RejectionStage::Path,
224 message,
225 )
226 }
227
228 pub fn register_overflow(message: impl Into<String>) -> Self {
229 Self::new(
230 HashlineRejectionCode::RegisterOverflow,
231 RejectionStage::Register,
232 message,
233 )
234 }
235
236 pub fn backup_unavailable(message: impl Into<String>) -> Self {
237 Self::new(
238 HashlineRejectionCode::BackupUnavailable,
239 RejectionStage::Baseline,
240 message,
241 )
242 }
243}
244
245impl fmt::Display for HashlineRejection {
246 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
247 write!(
248 formatter,
249 "{} at {}: {}",
250 self.code, self.stage, self.message
251 )
252 }
253}
254
255impl std::error::Error for HashlineRejection {}
256
257fn steering_for(code: HashlineRejectionCode, stage: RejectionStage) -> &'static str {
258 match (code, stage) {
259 (HashlineRejectionCode::MissingTag | HashlineRejectionCode::MalformedTag, _) => {
260 "read the current file with the tagged read surface, then include its four-hex tag"
261 }
262 (
263 HashlineRejectionCode::UnknownTag | HashlineRejectionCode::EvictedTag,
264 RejectionStage::Resolution,
265 ) => "re-read the current tagged content before editing",
266 (HashlineRejectionCode::AmbiguousTag, RejectionStage::Resolution) => {
267 "use apply_patch or another available non-hashline edit surface; re-reading preserves this colliding four-hex tag"
268 }
269 (HashlineRejectionCode::AmbiguousTag, RejectionStage::Recovery) => {
270 "re-address the current tagged content; the stale span has multiple verbatim landings"
271 }
272 (HashlineRejectionCode::StaleTag, RejectionStage::Verification) => {
273 "perform a ranged tagged re-read because required boundary context changed"
274 }
275 (HashlineRejectionCode::StaleTag, RejectionStage::Recovery) => {
276 "re-address the current tagged content; the stale span no longer occurs verbatim"
277 }
278 (HashlineRejectionCode::UnseenLine | HashlineRejectionCode::BoundaryIneligible, _) => {
279 "read the addressed rows and their boundary context with the tagged read surface"
280 }
281 (HashlineRejectionCode::UntaggablePath, _) => {
282 "choose a writable regular text file or use an available non-hashline surface"
283 }
284 (HashlineRejectionCode::RegisterOverflow, _) => {
285 "reduce register contents before retrying the patch"
286 }
287 (HashlineRejectionCode::BackupUnavailable, _) => {
288 "enable backups or use apply_patch for this destructive change"
289 }
290 (HashlineRejectionCode::ParseError, _) => {
291 "submit only a hashline patch with tagged section headers and valid operations"
292 }
293 _ => "re-read the current tagged content before editing",
294 }
295}
296
297#[derive(Clone, Debug, Eq, PartialEq)]
300pub struct Patch {
301 pub sections: Vec<PatchSection>,
302}
303
304impl Patch {
305 pub fn is_empty(&self) -> bool {
306 self.sections.is_empty()
307 }
308}
309
310#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct PatchSection {
313 pub header: SectionHeader,
314 pub operations: Vec<Operation>,
315 pub line: usize,
316}
317
318#[derive(Clone, Debug, Eq, PartialEq)]
320pub struct SectionHeader {
321 pub requested_path: String,
322 pub tag: String,
323}
324
325impl SectionHeader {
326 pub fn new(
327 requested_path: impl Into<String>,
328 tag: impl AsRef<str>,
329 ) -> Result<Self, HashlineRejection> {
330 let requested_path = requested_path.into();
331 if requested_path.is_empty() {
332 return Err(HashlineRejection::missing_tag(
333 "a section header must name a path before its tag",
334 ));
335 }
336 let tag = normalize_tag(tag.as_ref())?;
337 Ok(Self {
338 requested_path,
339 tag,
340 })
341 }
342}
343
344#[derive(Clone, Debug, Eq, PartialEq)]
347pub enum Operation {
348 Put(PutOperation),
349 Cut(CutOperation),
350 Rem(RemOperation),
351 Mv(MvOperation),
352}
353
354impl Operation {
355 pub fn address(&self) -> Option<&Address> {
356 match self {
357 Self::Put(operation) => Some(&operation.address),
358 Self::Cut(operation) => Some(&operation.address),
359 Self::Rem(_) | Self::Mv(_) => None,
360 }
361 }
362
363 fn append_body_line(&mut self, line: &str) -> bool {
364 match self {
365 Self::Put(PutOperation {
366 source: PutSource::Text(lines),
367 ..
368 }) => {
369 let Some(content) = line.strip_prefix('+') else {
370 return false;
371 };
372 lines.push(content.to_string());
373 true
374 }
375 _ => false,
376 }
377 }
378}
379
380#[derive(Clone, Debug, Eq, PartialEq)]
381pub struct PutOperation {
382 pub address: Address,
383 pub source: PutSource,
384 pub line: usize,
385}
386
387#[derive(Clone, Debug, Eq, PartialEq)]
388pub enum PutSource {
389 Text(Vec<String>),
390 Register(RegisterRef),
391}
392
393#[derive(Clone, Debug, Eq, PartialEq)]
394pub struct CutOperation {
395 pub address: Address,
396 pub register: Option<RegisterRef>,
397 pub line: usize,
398}
399
400#[derive(Clone, Debug, Eq, PartialEq)]
401pub struct RemOperation {
402 pub line: usize,
403}
404
405#[derive(Clone, Debug, Eq, PartialEq)]
406pub struct MvOperation {
407 pub destination: String,
408 pub line: usize,
409}
410
411#[derive(Clone, Debug, Eq, Hash, PartialEq)]
414pub enum RegisterRef {
415 Anonymous,
416 Named(String),
417}
418
419#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
421pub enum LineReference {
422 Absolute(usize),
423 EofRelative(usize),
425}
426
427#[derive(Clone, Debug, Eq, PartialEq)]
429pub enum Address {
430 Bof,
432 Line(LineReference),
433 Range {
434 start: LineReference,
435 end: LineReference,
436 },
437 Gap {
438 side: GapSide,
439 line: LineReference,
440 },
441 Block(LineReference),
442 BlockGap {
444 side: GapSide,
445 line: LineReference,
446 },
447}
448
449#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
450pub enum GapSide {
451 Before,
452 After,
453}
454
455pub fn parse_hashline_patch(patch: &str) -> Result<Patch, HashlineRejection> {
459 if patch.trim().is_empty() {
460 return Err(HashlineRejection::parse("patch must not be empty"));
461 }
462
463 let mut sections = Vec::new();
464 let mut envelope_started = false;
465 let mut envelope_ended = false;
466 for (index, raw_line) in patch.split('\n').enumerate() {
467 let line_number = index + 1;
468 let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
469 let trimmed = line.trim();
470
471 if trimmed == "*** Begin Patch" {
472 if envelope_started || !sections.is_empty() {
473 return Err(HashlineRejection::parse(format!(
474 "unexpected patch envelope start at line {line_number}"
475 )));
476 }
477 envelope_started = true;
478 continue;
479 }
480 if trimmed == "*** End Patch" {
481 if !envelope_started || envelope_ended {
482 return Err(HashlineRejection::parse(format!(
483 "unexpected patch envelope end at line {line_number}"
484 )));
485 }
486 envelope_ended = true;
487 continue;
488 }
489 if envelope_ended {
490 if !trimmed.is_empty() {
491 return Err(HashlineRejection::parse(format!(
492 "content follows the patch envelope at line {line_number}"
493 )));
494 }
495 continue;
496 }
497
498 if is_header_line(trimmed) {
499 let header = parse_section_header(trimmed)?;
500 sections.push(PatchSection {
501 header,
502 operations: Vec::new(),
503 line: line_number,
504 });
505 continue;
506 }
507
508 if is_directive(line) {
509 let section = sections.last_mut().ok_or_else(|| {
510 HashlineRejection::parse(format!(
511 "operation at line {line_number} appears before a tagged section header"
512 ))
513 })?;
514 section.operations.push(parse_operation(line, line_number)?);
515 continue;
516 }
517
518 if trimmed.is_empty() && sections.is_empty() {
519 continue;
520 }
521
522 let Some(section) = sections.last_mut() else {
523 return Err(HashlineRejection::parse(format!(
524 "expected a tagged section header at line {line_number}"
525 )));
526 };
527 let Some(operation) = section.operations.last_mut() else {
528 return Err(HashlineRejection::parse(format!(
529 "expected an operation after section header at line {}",
530 section.line
531 )));
532 };
533 if !operation.append_body_line(line) {
534 return Err(HashlineRejection::parse(format!(
535 "PUT body rows must begin with +; unexpected content at line {line_number}"
536 )));
537 }
538 }
539
540 if envelope_started && !envelope_ended {
541 return Err(HashlineRejection::parse(
542 "patch envelope is missing *** End Patch",
543 ));
544 }
545 if sections.is_empty() {
546 return Err(HashlineRejection::parse(
547 "patch must contain at least one tagged section header",
548 ));
549 }
550 if let Some(section) = sections
551 .iter()
552 .find(|section| section.operations.is_empty())
553 {
554 return Err(HashlineRejection::parse(format!(
555 "section at line {} contains no operation",
556 section.line
557 )));
558 }
559 for section in §ions {
560 validate_section_composition(section)?;
561 for operation in §ion.operations {
562 if let Operation::Put(PutOperation {
563 source: PutSource::Text(lines),
564 line,
565 ..
566 }) = operation
567 {
568 if lines.is_empty() {
569 return Err(HashlineRejection::parse(format!(
570 "PUT at line {line} requires one or more + body rows"
571 )));
572 }
573 }
574 }
575 }
576 Ok(Patch { sections })
577}
578
579fn validate_section_composition(section: &PatchSection) -> Result<(), HashlineRejection> {
580 let rem_count = section
581 .operations
582 .iter()
583 .filter(|operation| matches!(operation, Operation::Rem(_)))
584 .count();
585 if rem_count > 0 && section.operations.len() != 1 {
586 return Err(HashlineRejection::parse(format!(
587 "REM at section line {} cannot be combined with line operations",
588 section.line
589 )));
590 }
591 let mv_positions: Vec<usize> = section
592 .operations
593 .iter()
594 .enumerate()
595 .filter_map(|(index, operation)| matches!(operation, Operation::Mv(_)).then_some(index))
596 .collect();
597 if mv_positions.len() > 1
598 || mv_positions
599 .first()
600 .is_some_and(|index| *index + 1 != section.operations.len())
601 {
602 return Err(HashlineRejection::parse(format!(
603 "MV at section line {} must occur once and after all line operations",
604 section.line
605 )));
606 }
607 Ok(())
608}
609
610pub fn parse_section_header(line: &str) -> Result<SectionHeader, HashlineRejection> {
612 let trimmed = line.trim();
613 if !is_header_line(trimmed) {
614 return Err(HashlineRejection::parse(
615 "section headers must use [requested-path#TAG]",
616 ));
617 }
618 let inner = &trimmed[1..trimmed.len() - 1];
619 let Some((path, tag)) = inner.split_once('#') else {
620 return Err(HashlineRejection::missing_tag(
621 "section header is missing its #TAG handle",
622 ));
623 };
624 if path.is_empty() {
625 return Err(HashlineRejection::missing_tag(
626 "section header must name a path before #TAG",
627 ));
628 }
629 if path.contains('#') || path.contains(['\r', '\n']) {
630 return Err(HashlineRejection::parse(
631 "section paths cannot contain #, carriage return, or newline",
632 ));
633 }
634 SectionHeader::new(path, tag)
635}
636
637fn is_header_line(line: &str) -> bool {
638 line.starts_with('[') && line.ends_with(']') && line.len() >= 2
639}
640
641fn is_directive(line: &str) -> bool {
642 ["PUT", "CUT", "REM", "MV"].into_iter().any(|keyword| {
643 line == keyword
644 || line
645 .strip_prefix(keyword)
646 .is_some_and(|remainder| remainder.starts_with(char::is_whitespace))
647 })
648}
649
650fn parse_operation(line: &str, line_number: usize) -> Result<Operation, HashlineRejection> {
651 let trimmed = line.trim();
652 if trimmed == "REM" {
653 return parse_rem("", line_number);
654 }
655 let (keyword, remainder) = trimmed.split_once(char::is_whitespace).ok_or_else(|| {
656 HashlineRejection::parse(format!(
657 "operation at line {line_number} lacks required input"
658 ))
659 })?;
660 let remainder = remainder.trim();
661 match keyword {
662 "PUT" => parse_put(remainder, line_number),
663 "CUT" => parse_cut(remainder, line_number),
664 "REM" => parse_rem(remainder, line_number),
665 "MV" => parse_mv(remainder, line_number),
666 _ => Err(HashlineRejection::parse(format!(
667 "unknown operation {keyword:?} at line {line_number}"
668 ))),
669 }
670}
671
672fn parse_put(remainder: &str, line: usize) -> Result<Operation, HashlineRejection> {
673 let Some(prefix) = remainder.strip_suffix(':') else {
674 let mut parts = remainder.split_whitespace();
675 let address = parts.next().ok_or_else(|| {
676 HashlineRejection::parse(format!("PUT at line {line} lacks an address"))
677 })?;
678 let register = match parts.next() {
679 Some(register) => parse_register(register)?,
680 None => RegisterRef::Anonymous,
683 };
684 if parts.next().is_some() {
685 return Err(HashlineRejection::parse(format!(
686 "PUT at line {line} accepts at most one register source"
687 )));
688 }
689 return Ok(Operation::Put(PutOperation {
690 address: parse_address(address)?,
691 source: PutSource::Register(register),
692 line,
693 }));
694 };
695 let address = prefix.trim();
696 if address.is_empty() {
697 return Err(HashlineRejection::parse(format!(
698 "PUT at line {line} lacks an address before :"
699 )));
700 }
701 Ok(Operation::Put(PutOperation {
702 address: parse_address(address)?,
703 source: PutSource::Text(Vec::new()),
704 line,
705 }))
706}
707
708fn parse_cut(remainder: &str, line: usize) -> Result<Operation, HashlineRejection> {
709 let mut parts = remainder.split_whitespace();
710 let address = parts
711 .next()
712 .ok_or_else(|| HashlineRejection::parse(format!("CUT at line {line} lacks an address")))?;
713 let register = match parts.next() {
714 Some(register) => Some(parse_register(register)?),
715 None => None,
716 };
717 if parts.next().is_some() {
718 return Err(HashlineRejection::parse(format!(
719 "CUT at line {line} has unexpected trailing input"
720 )));
721 }
722 Ok(Operation::Cut(CutOperation {
723 address: parse_address(address)?,
724 register,
725 line,
726 }))
727}
728
729fn parse_rem(remainder: &str, line: usize) -> Result<Operation, HashlineRejection> {
730 if !remainder.trim().is_empty() {
731 return Err(HashlineRejection::parse(format!(
732 "REM at line {line} removes the whole section file and accepts no address"
733 )));
734 }
735 Ok(Operation::Rem(RemOperation { line }))
736}
737
738fn parse_mv(remainder: &str, line: usize) -> Result<Operation, HashlineRejection> {
739 let destination = remainder.trim();
740 if destination.is_empty() || destination.split_whitespace().count() != 1 {
741 return Err(HashlineRejection::parse(format!(
742 "MV at line {line} requires exactly one destination path"
743 )));
744 }
745 Ok(Operation::Mv(MvOperation {
746 destination: unquote(destination).to_string(),
747 line,
748 }))
749}
750
751fn parse_register(input: &str) -> Result<RegisterRef, HashlineRejection> {
752 let Some(name) = input.strip_prefix('@') else {
753 return Err(HashlineRejection::parse("registers must begin with @"));
754 };
755 if name.is_empty()
756 || !name.chars().all(|character| {
757 character.is_ascii_alphanumeric() || character == '_' || character == '-'
758 })
759 {
760 return Err(HashlineRejection::parse(
761 "register names may contain only ASCII letters, digits, _, and -",
762 ));
763 }
764 Ok(RegisterRef::Named(name.to_string()))
765}
766
767pub fn parse_address(input: &str) -> Result<Address, HashlineRejection> {
770 let input = input.trim();
771 if input == "0" {
772 return Ok(Address::Bof);
773 }
774 if let Some((side, line)) = input
775 .strip_prefix('<')
776 .map(|line| (GapSide::Before, line))
777 .or_else(|| input.strip_prefix('>').map(|line| (GapSide::After, line)))
778 {
779 if let Some(block_line) = line.strip_suffix('*') {
780 return Ok(Address::BlockGap {
781 side,
782 line: parse_line_reference(block_line)?,
783 });
784 }
785 return Ok(Address::Gap {
786 side,
787 line: parse_line_reference(line)?,
788 });
789 }
790 if let Some(line) = input.strip_suffix('*') {
791 return Ok(Address::Block(parse_line_reference(line)?));
792 }
793 for separator in ["..=", ".=", ".."] {
796 if let Some((start, end)) = input.split_once(separator) {
797 return Ok(Address::Range {
798 start: parse_line_reference(start)?,
799 end: parse_line_reference(end)?,
800 });
801 }
802 }
803 Ok(Address::Line(parse_line_reference(input)?))
804}
805
806fn parse_line_reference(input: &str) -> Result<LineReference, HashlineRejection> {
807 let input = input.trim();
808 if input == "$" {
809 return Ok(LineReference::EofRelative(0));
810 }
811 if let Some(offset) = input.strip_prefix("$-") {
812 return Ok(LineReference::EofRelative(parse_positive_usize(offset)?));
813 }
814 let line = parse_positive_usize(input)?;
815 Ok(LineReference::Absolute(line))
816}
817
818fn parse_positive_usize(input: &str) -> Result<usize, HashlineRejection> {
819 let value = input.parse::<usize>().map_err(|_| {
820 HashlineRejection::parse(format!("{input:?} is not a valid positive line number"))
821 })?;
822 if value == 0 {
823 return Err(HashlineRejection::parse(
824 "line number zero is only valid as the standalone BOF address",
825 ));
826 }
827 Ok(value)
828}
829
830fn unquote(value: &str) -> &str {
831 value
832 .strip_prefix('"')
833 .and_then(|value| value.strip_suffix('"'))
834 .or_else(|| {
835 value
836 .strip_prefix('\'')
837 .and_then(|value| value.strip_suffix('\''))
838 })
839 .unwrap_or(value)
840}
841
842fn normalize_tag(tag: &str) -> Result<String, HashlineRejection> {
843 if tag.len() != 4 || !tag.bytes().all(|byte| byte.is_ascii_hexdigit()) {
844 return Err(HashlineRejection::malformed_tag(
845 "section tags must be exactly four hexadecimal digits",
846 ));
847 }
848 Ok(tag.to_ascii_uppercase())
849}
850
851#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
853pub struct LineSpan {
854 pub start: usize,
855 pub end: usize,
856}
857
858impl LineSpan {
859 pub fn new(start: usize, end: usize) -> Option<Self> {
860 (start > 0 && start <= end).then_some(Self { start, end })
861 }
862
863 pub fn lines(self) -> impl Iterator<Item = usize> {
864 self.start..=self.end
865 }
866}
867
868#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
871pub struct ResolvedGap {
872 pub before: Option<usize>,
873 pub after: Option<usize>,
874}
875
876#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
878pub enum ResolvedAddress {
879 WholeFile,
881 Span(LineSpan),
882 Gap(ResolvedGap),
883 BlockAnchor(usize),
886 BlockGapAnchor {
889 side: GapSide,
890 anchor: usize,
891 },
892}
893
894impl ResolvedAddress {
895 pub fn addressed_span(self) -> Option<LineSpan> {
896 match self {
897 Self::Span(span) => Some(span),
898 Self::WholeFile | Self::Gap(_) | Self::BlockAnchor(_) | Self::BlockGapAnchor { .. } => {
899 None
900 }
901 }
902 }
903
904 pub fn required_anchors(self) -> Vec<usize> {
905 match self {
906 Self::Gap(gap) => [gap.before, gap.after].into_iter().flatten().collect(),
907 Self::WholeFile
908 | Self::Span(_)
909 | Self::BlockAnchor(_)
910 | Self::BlockGapAnchor { .. } => Vec::new(),
911 }
912 }
913}
914
915pub fn resolve_address(
918 address: &Address,
919 snapshot: &Snapshot,
920) -> Result<ResolvedAddress, HashlineRejection> {
921 let total_lines = snapshot.total_lines;
922 match address {
923 Address::Bof => Ok(ResolvedAddress::Gap(ResolvedGap {
924 before: None,
925 after: (total_lines > 0).then_some(1),
926 })),
927 Address::Line(reference) => {
928 let line = resolve_line_reference(*reference, total_lines)?;
929 Ok(ResolvedAddress::Span(LineSpan {
930 start: line,
931 end: line,
932 }))
933 }
934 Address::Range { start, end } => {
935 let start = resolve_line_reference(*start, total_lines)?;
936 let end = resolve_line_reference(*end, total_lines)?;
937 let Some(span) = LineSpan::new(start, end) else {
938 return Err(HashlineRejection::eligibility(
939 HashlineRejectionCode::BoundaryIneligible,
940 "range start occurs after range end in the retained snapshot",
941 ));
942 };
943 Ok(ResolvedAddress::Span(span))
944 }
945 Address::Gap { side, line } => {
946 let line = resolve_line_reference(*line, total_lines)?;
947 let gap = match side {
948 GapSide::Before => ResolvedGap {
949 before: line.checked_sub(1),
950 after: Some(line),
951 },
952 GapSide::After => ResolvedGap {
953 before: Some(line),
954 after: (line < total_lines).then_some(line + 1),
955 },
956 };
957 Ok(ResolvedAddress::Gap(gap))
958 }
959 Address::Block(reference) => Ok(ResolvedAddress::BlockAnchor(resolve_line_reference(
960 *reference,
961 total_lines,
962 )?)),
963 Address::BlockGap { side, line } => Ok(ResolvedAddress::BlockGapAnchor {
964 side: *side,
965 anchor: resolve_line_reference(*line, total_lines)?,
966 }),
967 }
968}
969
970fn resolve_line_reference(
971 reference: LineReference,
972 total_lines: usize,
973) -> Result<usize, HashlineRejection> {
974 let line = match reference {
975 LineReference::Absolute(line) if line <= total_lines => Some(line),
976 LineReference::EofRelative(offset) if offset < total_lines => Some(total_lines - offset),
977 LineReference::Absolute(_) | LineReference::EofRelative(_) => None,
978 };
979 line.ok_or_else(|| {
980 HashlineRejection::eligibility(
981 HashlineRejectionCode::BoundaryIneligible,
982 "address falls outside the retained snapshot boundary",
983 )
984 })
985}
986
987pub fn expand_block(
990 resolved: ResolvedAddress,
991 span: LineSpan,
992 snapshot: &Snapshot,
993) -> Result<ResolvedAddress, HashlineRejection> {
994 let (side, anchor) = match resolved {
995 ResolvedAddress::BlockAnchor(anchor) => (None, anchor),
996 ResolvedAddress::BlockGapAnchor { side, anchor } => (Some(side), anchor),
997 ResolvedAddress::WholeFile | ResolvedAddress::Span(_) | ResolvedAddress::Gap(_) => {
998 return Err(HashlineRejection::parse(
999 "only a parsed block address can be expanded as a block",
1000 ));
1001 }
1002 };
1003 if !span.lines().any(|line| line == anchor) || span.end > snapshot.total_lines {
1004 return Err(HashlineRejection::eligibility(
1005 HashlineRejectionCode::BoundaryIneligible,
1006 "block resolver returned a span outside the retained snapshot",
1007 ));
1008 }
1009 match side {
1010 None => Ok(ResolvedAddress::Span(span)),
1011 Some(GapSide::Before) => Ok(ResolvedAddress::Gap(ResolvedGap {
1012 before: span.start.checked_sub(1),
1013 after: Some(span.start),
1014 })),
1015 Some(GapSide::After) => Ok(ResolvedAddress::Gap(ResolvedGap {
1016 before: Some(span.end),
1017 after: (span.end < snapshot.total_lines).then_some(span.end + 1),
1018 })),
1019 }
1020}
1021
1022pub fn check_eligibility(
1026 snapshot: &Snapshot,
1027 address: ResolvedAddress,
1028) -> Result<(), HashlineRejection> {
1029 match address {
1030 ResolvedAddress::WholeFile => {
1031 if snapshot.records.len() != snapshot.total_lines
1032 || (1..=snapshot.total_lines).any(|line| !snapshot.is_seen(line))
1033 {
1034 return Err(HashlineRejection::eligibility(
1035 HashlineRejectionCode::UnseenLine,
1036 "REM and MV require a tagged read that retained the whole source file",
1037 ));
1038 }
1039 }
1040 ResolvedAddress::Span(span) => {
1041 for line in span.lines() {
1042 if !snapshot.is_seen(line) {
1043 return Err(HashlineRejection::eligibility(
1044 HashlineRejectionCode::UnseenLine,
1045 format!("line {line} was not retained by the tagged read"),
1046 ));
1047 }
1048 }
1049 }
1050 ResolvedAddress::BlockAnchor(line)
1051 | ResolvedAddress::BlockGapAnchor { anchor: line, .. } => {
1052 if !snapshot.is_seen(line) {
1053 return Err(HashlineRejection::eligibility(
1054 HashlineRejectionCode::UnseenLine,
1055 format!("block anchor line {line} was not retained by the tagged read"),
1056 ));
1057 }
1058 }
1059 ResolvedAddress::Gap(gap) => {
1060 if gap.before.is_none() && gap.after.is_none() {
1061 if !snapshot.boundary.empty_file {
1062 return Err(HashlineRejection::eligibility(
1063 HashlineRejectionCode::BoundaryIneligible,
1064 "empty-file gap has no retained empty-file boundary evidence",
1065 ));
1066 }
1067 return Ok(());
1068 }
1069 for line in [gap.before, gap.after].into_iter().flatten() {
1070 if !snapshot.is_seen(line) {
1071 return Err(HashlineRejection::eligibility(
1072 HashlineRejectionCode::UnseenLine,
1073 format!("gap boundary line {line} was not retained by the tagged read"),
1074 ));
1075 }
1076 }
1077 if gap.before.is_none() && !snapshot.boundary.bof_observed {
1078 return Err(HashlineRejection::eligibility(
1079 HashlineRejectionCode::BoundaryIneligible,
1080 "BOF boundary evidence was not retained",
1081 ));
1082 }
1083 if gap.after.is_none() && !snapshot.boundary.eof_observed {
1084 return Err(HashlineRejection::eligibility(
1085 HashlineRejectionCode::BoundaryIneligible,
1086 "EOF boundary evidence was not retained",
1087 ));
1088 }
1089 }
1090 }
1091 Ok(())
1092}
1093
1094#[derive(Clone, Debug)]
1098pub struct ResolvedPatchSection {
1099 pub section_index: usize,
1100 pub canonical_path: PathBuf,
1101 pub snapshot: Snapshot,
1102 pub operations: Vec<ResolvedOperation>,
1103}
1104
1105#[derive(Clone, Debug, Eq, PartialEq)]
1106pub struct ResolvedOperation {
1107 pub operation_index: usize,
1108 pub address: ResolvedAddress,
1110}
1111
1112pub fn resolve_patch_sections<F>(
1113 store: &mut SnapshotStore,
1114 patch: &Patch,
1115 mut canonicalize: F,
1116) -> Result<Vec<ResolvedPatchSection>, HashlineRejection>
1117where
1118 F: FnMut(&str) -> Result<PathBuf, HashlineRejection>,
1119{
1120 let mut views: BTreeMap<PathBuf, Snapshot> = BTreeMap::new();
1121 let mut resolved = Vec::with_capacity(patch.sections.len());
1122 for (section_index, section) in patch.sections.iter().enumerate() {
1123 let canonical_path = canonicalize(§ion.header.requested_path)?;
1124 let snapshot = resolve_snapshot(store, &canonical_path, §ion.header.tag)?;
1125 if let Some(existing) = views.get(&canonical_path) {
1126 if !equivalent_snapshots(existing, &snapshot) {
1127 return Err(HashlineRejection::resolution(
1128 HashlineRejectionCode::AmbiguousTag,
1129 "sections for one canonical path selected different retained verification evidence",
1130 ));
1131 }
1132 } else {
1133 views.insert(canonical_path.clone(), snapshot.clone());
1134 }
1135
1136 let mut operations = Vec::with_capacity(section.operations.len());
1137 for (operation_index, operation) in section.operations.iter().enumerate() {
1138 let address = match operation.address() {
1139 Some(address) => resolve_address(address, &snapshot)?,
1140 None => ResolvedAddress::WholeFile,
1141 };
1142 check_eligibility(&snapshot, address)?;
1143 operations.push(ResolvedOperation {
1144 operation_index,
1145 address,
1146 });
1147 }
1148 resolved.push(ResolvedPatchSection {
1149 section_index,
1150 canonical_path,
1151 snapshot,
1152 operations,
1153 });
1154 }
1155 Ok(resolved)
1156}
1157
1158pub fn resolve_snapshot(
1161 store: &mut SnapshotStore,
1162 canonical_path: impl AsRef<Path>,
1163 tag: &str,
1164) -> Result<Snapshot, HashlineRejection> {
1165 let tag = normalize_tag(tag)?;
1166 store
1167 .lookup(canonical_path, &tag)
1168 .map_err(|error| match error {
1169 SnapshotLookupError::UnknownTag => HashlineRejection::resolution(
1170 HashlineRejectionCode::UnknownTag,
1171 "the tagged snapshot is not resident for this path",
1172 ),
1173 SnapshotLookupError::EvictedTag => HashlineRejection::resolution(
1174 HashlineRejectionCode::EvictedTag,
1175 "the tagged snapshot was evicted from this session",
1176 ),
1177 SnapshotLookupError::AmbiguousTag => HashlineRejection::resolution(
1178 HashlineRejectionCode::AmbiguousTag,
1179 "the four-hex tag collides across different normalized file content",
1180 ),
1181 })
1182}
1183
1184#[derive(Clone, Debug, Eq, PartialEq)]
1186pub struct Baseline {
1187 pub bytes: Vec<u8>,
1188 pub snapshot: Snapshot,
1189}
1190
1191impl Baseline {
1192 pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
1193 let bytes = bytes.into();
1194 Self {
1195 snapshot: scan_bytes(&bytes),
1196 bytes,
1197 }
1198 }
1199
1200 pub fn raw_record(&self, line: usize) -> Option<&RawLineRecord> {
1201 self.snapshot.raw_record(line)
1202 }
1203}
1204
1205#[derive(Clone, Debug, Default)]
1209pub struct BaselineCache {
1210 baselines: BTreeMap<PathBuf, Baseline>,
1211}
1212
1213impl BaselineCache {
1214 pub fn load_once(
1215 &mut self,
1216 canonical_path: impl Into<PathBuf>,
1217 bytes: impl Into<Vec<u8>>,
1218 ) -> &Baseline {
1219 let path = canonical_path.into();
1220 self.baselines
1221 .entry(path)
1222 .or_insert_with(|| Baseline::from_bytes(bytes))
1223 }
1224
1225 pub fn get(&self, canonical_path: impl AsRef<Path>) -> Option<&Baseline> {
1226 self.baselines.get(canonical_path.as_ref())
1227 }
1228
1229 pub fn len(&self) -> usize {
1230 self.baselines.len()
1231 }
1232
1233 pub fn is_empty(&self) -> bool {
1234 self.baselines.is_empty()
1235 }
1236}
1237
1238#[derive(Clone, Debug, Eq, PartialEq)]
1243pub enum VerificationOutcome {
1244 Exact,
1245 RecoveryRequired(RecoveryPlan),
1246 Rejected(HashlineRejection),
1247 BlockNeedsResolution { anchor: usize },
1248}
1249
1250#[derive(Clone, Debug, Eq, PartialEq)]
1253pub struct RecoveryPlan {
1254 pub old_span: LineSpan,
1255 pub expected_records: Vec<RawLineRecord>,
1256}
1257
1258pub fn verify_exact(
1261 snapshot: &Snapshot,
1262 baseline: &Baseline,
1263 address: ResolvedAddress,
1264) -> VerificationOutcome {
1265 if let Err(rejection) = check_eligibility(snapshot, address) {
1266 return VerificationOutcome::Rejected(rejection);
1267 }
1268 match address {
1269 ResolvedAddress::WholeFile => {
1270 if snapshot.total_lines != baseline.snapshot.total_lines
1271 || snapshot.records != baseline.snapshot.records
1272 {
1273 VerificationOutcome::Rejected(HashlineRejection::stale_verification(
1274 "the whole-file source changed since the tagged read",
1275 ))
1276 } else {
1277 VerificationOutcome::Exact
1278 }
1279 }
1280 ResolvedAddress::BlockAnchor(anchor) | ResolvedAddress::BlockGapAnchor { anchor, .. } => {
1281 VerificationOutcome::BlockNeedsResolution { anchor }
1282 }
1283 ResolvedAddress::Gap(gap) => {
1284 for line in [gap.before, gap.after].into_iter().flatten() {
1285 if snapshot.raw_record(line) != baseline.raw_record(line) {
1286 return VerificationOutcome::Rejected(HashlineRejection::stale_verification(
1287 format!("required gap anchor line {line} changed since the tagged read"),
1288 ));
1289 }
1290 }
1291 VerificationOutcome::Exact
1292 }
1293 ResolvedAddress::Span(span) => {
1294 let expected_records: Vec<RawLineRecord> = span
1295 .lines()
1296 .filter_map(|line| snapshot.raw_record(line).cloned())
1297 .collect();
1298 let changed = span
1299 .lines()
1300 .any(|line| snapshot.raw_record(line) != baseline.raw_record(line));
1301 if changed {
1302 VerificationOutcome::RecoveryRequired(RecoveryPlan {
1303 old_span: span,
1304 expected_records,
1305 })
1306 } else {
1307 VerificationOutcome::Exact
1308 }
1309 }
1310 }
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use super::*;
1316 use crate::hashline::scan::{scan_bytes_with_request, CoverageInput, ScanRequest};
1317 use crate::hashline::snapshot::MAX_SNAPSHOT_PATHS;
1318
1319 fn snapshot(bytes: &[u8], lines: impl IntoIterator<Item = usize>) -> Snapshot {
1320 scan_bytes_with_request(bytes, ScanRequest::new(CoverageInput::lines(lines)))
1321 .snapshot
1322 .expect("in-memory snapshots reach EOF")
1323 }
1324
1325 #[test]
1326 fn raw_arguments_allow_only_a_nonempty_patch_string() {
1327 let request = validate_raw_arguments(&serde_json::json!({
1328 "patch": "[src/lib.rs#cafe]\nREM"
1329 }))
1330 .expect("hashline request");
1331 assert!(request.patch.contains("REM"));
1332
1333 for value in [
1334 serde_json::json!({"patch": "", "path": "src/lib.rs"}),
1335 serde_json::json!({"patch": 3}),
1336 serde_json::json!({"path": "src/lib.rs", "edits": []}),
1337 ] {
1338 let rejection = validate_raw_arguments(&value).expect_err("legacy shape is rejected");
1339 assert_eq!(rejection.code, HashlineRejectionCode::ParseError);
1340 assert_eq!(rejection.stage, RejectionStage::Parse);
1341 }
1342 }
1343
1344 #[test]
1345 fn headers_distinguish_missing_and_malformed_tags() {
1346 let missing = parse_section_header("[src/lib.rs]").expect_err("missing tag");
1347 assert_eq!(missing.code, HashlineRejectionCode::MissingTag);
1348 assert_eq!(missing.stage, RejectionStage::Header);
1349
1350 for header in ["[src/lib.rs#]", "[src/lib.rs#abc]", "[src/lib.rs#ABCDE]"] {
1351 let malformed = parse_section_header(header).expect_err("malformed tag");
1352 assert_eq!(malformed.code, HashlineRejectionCode::MalformedTag);
1353 assert_eq!(malformed.stage, RejectionStage::Header);
1354 }
1355 assert_eq!(
1356 parse_section_header("[src/lib.rs#cAfE]")
1357 .expect("valid header")
1358 .tag,
1359 "CAFE"
1360 );
1361 }
1362
1363 #[test]
1364 fn parser_retains_multisection_operations_and_put_body() {
1365 let patch = parse_hashline_patch(
1366 "*** Begin Patch\n[a.rs#cafe]\nPUT <2:\n+first\n+second\nCUT 3 @copied\n[b.rs#BEEF]\nPUT $ @copied\nMV c.rs\n*** End Patch",
1367 )
1368 .expect("patch parses");
1369 assert_eq!(patch.sections.len(), 2);
1370 assert_eq!(patch.sections[0].operations.len(), 2);
1371 let Operation::Put(put) = &patch.sections[0].operations[0] else {
1372 panic!("first operation is PUT");
1373 };
1374 assert_eq!(
1375 put.source,
1376 PutSource::Text(vec!["first".into(), "second".into()])
1377 );
1378 assert!(matches!(
1379 patch.sections[1].operations[0],
1380 Operation::Put(PutOperation {
1381 source: PutSource::Register(RegisterRef::Named(_)),
1382 ..
1383 })
1384 ));
1385 }
1386
1387 #[test]
1388 fn parser_accepts_all_pre_request_address_forms() {
1389 assert_eq!(parse_address("0").unwrap(), Address::Bof);
1390 assert!(matches!(
1391 parse_address("2.=4").unwrap(),
1392 Address::Range { .. }
1393 ));
1394 assert!(matches!(
1395 parse_address("2..=4").unwrap(),
1396 Address::Range { .. }
1397 ));
1398 assert!(matches!(parse_address("<1").unwrap(), Address::Gap { .. }));
1399 assert!(matches!(
1400 parse_address(">$-1").unwrap(),
1401 Address::Gap { .. }
1402 ));
1403 assert!(matches!(
1404 parse_address(">8*").unwrap(),
1405 Address::BlockGap {
1406 side: GapSide::After,
1407 ..
1408 }
1409 ));
1410 assert!(matches!(parse_address("$*").unwrap(), Address::Block(_)));
1411 }
1412
1413 #[test]
1414 fn parser_rejects_body_rows_without_plus_and_noncanonical_file_operations() {
1415 let body = parse_hashline_patch("[a.rs#CAFE]\nPUT 1:\nreplacement")
1416 .expect_err("body rows require the + marker");
1417 assert_eq!(body.stage, RejectionStage::Parse);
1418 assert!(parse_hashline_patch("[a.rs#CAFE]\nREM 1").is_err());
1419 assert!(parse_hashline_patch("[a.rs#CAFE]\nMV 1 -> b.rs").is_err());
1420 assert!(parse_hashline_patch("[a.rs#CAFE]\nREM\nPUT 1:\n+x").is_err());
1421 assert!(parse_hashline_patch("[a.rs#CAFE]\nMV b.rs\nCUT 1").is_err());
1422 }
1423
1424 #[test]
1425 fn snapshot_lookup_maps_all_store_outcomes_to_resolution_stage() {
1426 let mut store = SnapshotStore::new();
1427 let unknown = resolve_snapshot(&mut store, "/not-known", "CAFE").expect_err("unknown");
1428 assert_eq!(unknown.code, HashlineRejectionCode::UnknownTag);
1429 assert_eq!(unknown.stage, RejectionStage::Resolution);
1430
1431 let path = PathBuf::from("/same-tag");
1432 let first = snapshot(b"shared\none\n", [1]);
1433 let mut collision = snapshot(b"shared\nother\n", [1]);
1434 let tag = first.tag.clone();
1435 collision.tag = tag.clone();
1436 store.publish(&path, first);
1437 store.publish(&path, collision);
1438 let ambiguous = resolve_snapshot(&mut store, &path, &tag).expect_err("collision");
1439 assert_eq!(store.snapshot_count(), 2);
1440 assert_eq!(
1441 store.lookup_state(&path, &tag),
1442 crate::hashline::snapshot::SnapshotLookup::Ambiguous
1443 );
1444 assert_eq!(ambiguous.code, HashlineRejectionCode::AmbiguousTag);
1445 assert_eq!(ambiguous.stage, RejectionStage::Resolution);
1446 assert_eq!(
1447 ambiguous.steering,
1448 "use apply_patch or another available non-hashline edit surface; re-reading preserves this colliding four-hex tag"
1449 );
1450 }
1451
1452 #[test]
1453 fn evicted_handles_keep_their_distinct_resolution_code() {
1454 let mut store = SnapshotStore::new();
1455 let first_path = PathBuf::from("/evicted-0");
1456 let first = snapshot(b"row\n", [1]);
1457 let tag = first.tag.clone();
1458 store.publish(&first_path, first);
1459 for index in 1..=MAX_SNAPSHOT_PATHS {
1460 store.publish(
1461 PathBuf::from(format!("/evicted-{index}")),
1462 snapshot(b"row\n", [1]),
1463 );
1464 }
1465 let rejection = resolve_snapshot(&mut store, &first_path, &tag).expect_err("evicted");
1466 assert_eq!(rejection.code, HashlineRejectionCode::EvictedTag);
1467 assert_eq!(rejection.stage, RejectionStage::Resolution);
1468 }
1469
1470 #[test]
1471 fn equivalent_candidates_resolve_despite_different_provenance() {
1472 let mut store = SnapshotStore::new();
1473 let path = PathBuf::from("/equivalent");
1474 let first = snapshot(b"one\ntwo\n", [1]);
1475 let mut reread = snapshot(b"one\ntwo\n", [1]);
1476 reread.provenance = reread.provenance.with_label("read", "second");
1477 let tag = first.tag.clone();
1478 store.publish(&path, first);
1479 store.publish(&path, reread);
1480 assert!(resolve_snapshot(&mut store, &path, &tag).is_ok());
1481 }
1482
1483 #[test]
1484 fn same_path_sections_are_resolved_in_pre_request_coordinates() {
1485 let mut store = SnapshotStore::new();
1486 let path = PathBuf::from("/pre-request");
1487 let captured = snapshot(b"one\ntwo\nthree\n", [1, 2, 3]);
1488 let tag = captured.tag.clone();
1489 store.publish(&path, captured);
1490 let patch = parse_hashline_patch(&format!(
1491 "[/pre-request#{tag}]\nCUT 1\n[/pre-request#{tag}]\nCUT 3"
1492 ))
1493 .expect("patch");
1494 let sections =
1495 resolve_patch_sections(&mut store, &patch, |requested| Ok(PathBuf::from(requested)))
1496 .expect("both sections resolve before mutation");
1497 assert_eq!(sections.len(), 2);
1498 assert_eq!(
1499 sections[1].operations[0].address,
1500 ResolvedAddress::Span(LineSpan { start: 3, end: 3 })
1501 );
1502 }
1503
1504 #[test]
1505 fn whole_file_operations_require_full_retained_coverage() {
1506 let mut store = SnapshotStore::new();
1507 let path = PathBuf::from("/whole-file");
1508 let partial = snapshot(b"one\ntwo\n", [1]);
1509 let tag = partial.tag.clone();
1510 store.publish(&path, partial);
1511 let patch = parse_hashline_patch(&format!("[/whole-file#{tag}]\nREM")).expect("patch");
1512 let rejection =
1513 resolve_patch_sections(&mut store, &patch, |requested| Ok(PathBuf::from(requested)))
1514 .expect_err("partial read cannot authorize whole-file deletion");
1515 assert_eq!(rejection.code, HashlineRejectionCode::UnseenLine);
1516 assert_eq!(rejection.stage, RejectionStage::Eligibility);
1517 }
1518
1519 #[test]
1520 fn addresses_resolve_against_snapshot_not_live_baseline_length() {
1521 let retained = snapshot(b"one\ntwo\nthree\n", [2, 3]);
1522 let resolved = resolve_address(&parse_address("$-1").unwrap(), &retained).unwrap();
1523 assert_eq!(
1524 resolved,
1525 ResolvedAddress::Span(LineSpan { start: 2, end: 2 })
1526 );
1527
1528 let baseline = Baseline::from_bytes(b"one\ntwo\nthree\nfour\n".to_vec());
1529 assert!(matches!(
1530 verify_exact(&retained, &baseline, resolved),
1531 VerificationOutcome::Exact
1532 ));
1533 }
1534
1535 #[test]
1536 fn gaps_require_their_retained_boundary_rows() {
1537 let retained = snapshot(b"one\ntwo\nthree\n", [2]);
1538 let gap = resolve_address(&parse_address("<2").unwrap(), &retained).unwrap();
1539 let rejection = check_eligibility(&retained, gap).expect_err("line one was unseen");
1540 assert_eq!(rejection.code, HashlineRejectionCode::UnseenLine);
1541 assert_eq!(rejection.stage, RejectionStage::Eligibility);
1542
1543 let eof = resolve_address(&parse_address(">$").unwrap(), &retained).unwrap();
1544 let rejection = check_eligibility(&retained, eof).expect_err("line three was unseen");
1545 assert_eq!(rejection.code, HashlineRejectionCode::UnseenLine);
1546 }
1547
1548 #[test]
1549 fn one_baseline_is_retained_for_every_section_of_a_canonical_path() {
1550 let mut cache = BaselineCache::default();
1551 let path = PathBuf::from("/canonical");
1552 let first = cache
1553 .load_once(path.clone(), b"before\n".to_vec())
1554 .bytes
1555 .clone();
1556 let second = cache
1557 .load_once(path.clone(), b"after\n".to_vec())
1558 .bytes
1559 .clone();
1560 assert_eq!(first, b"before\n");
1561 assert_eq!(second, b"before\n");
1562 assert_eq!(cache.len(), 1);
1563 }
1564
1565 #[test]
1566 fn anchor_mismatch_is_verification_but_span_mismatch_requests_recovery() {
1567 let retained = snapshot(b"one\ntwo\nthree\n", [1, 2, 3]);
1568 let span = resolve_address(&parse_address("2").unwrap(), &retained).unwrap();
1569 let changed_span = Baseline::from_bytes(b"one\nTWO\nthree\n".to_vec());
1570 let VerificationOutcome::RecoveryRequired(plan) =
1571 verify_exact(&retained, &changed_span, span)
1572 else {
1573 panic!("addressed record drift must be planned for recovery");
1574 };
1575 assert_eq!(plan.old_span, LineSpan { start: 2, end: 2 });
1576
1577 let gap = resolve_address(&parse_address("<2").unwrap(), &retained).unwrap();
1578 let changed_anchor = Baseline::from_bytes(b"ONE\ntwo\nthree\n".to_vec());
1579 let VerificationOutcome::Rejected(rejection) =
1580 verify_exact(&retained, &changed_anchor, gap)
1581 else {
1582 panic!("anchor drift must reject before recovery");
1583 };
1584 assert_eq!(rejection.code, HashlineRejectionCode::StaleTag);
1585 assert_eq!(rejection.stage, RejectionStage::Verification);
1586 }
1587
1588 #[test]
1589 fn exact_verification_compares_terminators_and_unnormalized_bytes() {
1590 let retained = snapshot(b"one \r\ntwo\n", [1, 2]);
1591 let span = resolve_address(&parse_address("1").unwrap(), &retained).unwrap();
1592 let trailing_space_drift = Baseline::from_bytes(b"one\r\ntwo\n".to_vec());
1593 assert!(matches!(
1594 verify_exact(&retained, &trailing_space_drift, span),
1595 VerificationOutcome::RecoveryRequired(_)
1596 ));
1597 }
1598}