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 let mut lines = patch.split('\n').enumerate().peekable();
467 while let Some((index, raw_line)) = lines.next() {
468 let line_number = index + 1;
469 let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
470 let trimmed = line.trim();
471
472 if raw_line.is_empty() && lines.peek().is_none() {
475 continue;
476 }
477
478 if trimmed == "*** Begin Patch" {
479 if envelope_started || !sections.is_empty() {
480 return Err(HashlineRejection::parse(format!(
481 "unexpected patch envelope start at line {line_number}"
482 )));
483 }
484 envelope_started = true;
485 continue;
486 }
487 if trimmed == "*** End Patch" {
488 if !envelope_started || envelope_ended {
489 return Err(HashlineRejection::parse(format!(
490 "unexpected patch envelope end at line {line_number}"
491 )));
492 }
493 envelope_ended = true;
494 continue;
495 }
496 if envelope_ended {
497 if !trimmed.is_empty() {
498 return Err(HashlineRejection::parse(format!(
499 "content follows the patch envelope at line {line_number}"
500 )));
501 }
502 continue;
503 }
504
505 if is_header_line(trimmed) {
506 let header = parse_section_header(trimmed)?;
507 sections.push(PatchSection {
508 header,
509 operations: Vec::new(),
510 line: line_number,
511 });
512 continue;
513 }
514
515 if is_directive(line) {
516 let section = sections.last_mut().ok_or_else(|| {
517 HashlineRejection::parse(format!(
518 "operation at line {line_number} appears before a tagged section header"
519 ))
520 })?;
521 section.operations.push(parse_operation(line, line_number)?);
522 continue;
523 }
524
525 if trimmed.is_empty() && sections.is_empty() {
526 continue;
527 }
528
529 let Some(section) = sections.last_mut() else {
530 return Err(HashlineRejection::parse(format!(
531 "expected a tagged section header at line {line_number}"
532 )));
533 };
534 let Some(operation) = section.operations.last_mut() else {
535 return Err(HashlineRejection::parse(format!(
536 "expected an operation after section header at line {}",
537 section.line
538 )));
539 };
540 if !operation.append_body_line(line) {
541 let message = match operation {
542 Operation::Put(PutOperation {
543 source: PutSource::Register(_),
544 line: operation_line,
545 ..
546 }) => format!(
547 "PUT at line {operation_line} copies a register and accepts no body; \
548 use `PUT <address>:` followed by `+` rows for text (unexpected content at line {line_number})"
549 ),
550 Operation::Put(PutOperation {
551 source: PutSource::Text(_),
552 ..
553 }) => format!(
554 "text PUT body rows must begin with `+` (`+` alone is a blank row); \
555 unexpected content at line {line_number}"
556 ),
557 _ => format!(
558 "only `PUT <address>:` accepts body rows beginning with `+`; \
559 unexpected content at line {line_number}"
560 ),
561 };
562 return Err(HashlineRejection::parse(message));
563 }
564 }
565
566 if envelope_started && !envelope_ended {
567 return Err(HashlineRejection::parse(
568 "patch envelope is missing *** End Patch",
569 ));
570 }
571 if sections.is_empty() {
572 return Err(HashlineRejection::parse(
573 "patch must contain at least one tagged section header",
574 ));
575 }
576 if let Some(section) = sections
577 .iter()
578 .find(|section| section.operations.is_empty())
579 {
580 return Err(HashlineRejection::parse(format!(
581 "section at line {} contains no operation",
582 section.line
583 )));
584 }
585 for section in §ions {
586 validate_section_composition(section)?;
587 for operation in §ion.operations {
588 if let Operation::Put(PutOperation {
589 source: PutSource::Text(lines),
590 line,
591 ..
592 }) = operation
593 {
594 if lines.is_empty() {
595 return Err(HashlineRejection::parse(format!(
596 "PUT at line {line} requires one or more + body rows"
597 )));
598 }
599 }
600 }
601 }
602 Ok(Patch { sections })
603}
604
605fn validate_section_composition(section: &PatchSection) -> Result<(), HashlineRejection> {
606 let rem_count = section
607 .operations
608 .iter()
609 .filter(|operation| matches!(operation, Operation::Rem(_)))
610 .count();
611 if rem_count > 0 && section.operations.len() != 1 {
612 return Err(HashlineRejection::parse(format!(
613 "REM at section line {} cannot be combined with line operations",
614 section.line
615 )));
616 }
617 let mv_positions: Vec<usize> = section
618 .operations
619 .iter()
620 .enumerate()
621 .filter_map(|(index, operation)| matches!(operation, Operation::Mv(_)).then_some(index))
622 .collect();
623 if mv_positions.len() > 1
624 || mv_positions
625 .first()
626 .is_some_and(|index| *index + 1 != section.operations.len())
627 {
628 return Err(HashlineRejection::parse(format!(
629 "MV at section line {} must occur once and after all line operations",
630 section.line
631 )));
632 }
633 Ok(())
634}
635
636pub fn parse_section_header(line: &str) -> Result<SectionHeader, HashlineRejection> {
638 let trimmed = line.trim();
639 if !is_header_line(trimmed) {
640 return Err(HashlineRejection::parse(
641 "section headers must use [requested-path#TAG]",
642 ));
643 }
644 let inner = &trimmed[1..trimmed.len() - 1];
645 let Some((path, tag)) = inner.split_once('#') else {
646 return Err(HashlineRejection::missing_tag(
647 "section header is missing its #TAG handle",
648 ));
649 };
650 if path.is_empty() {
651 return Err(HashlineRejection::missing_tag(
652 "section header must name a path before #TAG",
653 ));
654 }
655 if path.contains('#') || path.contains(['\r', '\n']) {
656 return Err(HashlineRejection::parse(
657 "section paths cannot contain #, carriage return, or newline",
658 ));
659 }
660 SectionHeader::new(path, tag)
661}
662
663fn is_header_line(line: &str) -> bool {
664 line.starts_with('[') && line.ends_with(']') && line.len() >= 2
665}
666
667fn is_directive(line: &str) -> bool {
668 ["PUT", "CUT", "REM", "MV"].into_iter().any(|keyword| {
669 line == keyword
670 || line
671 .strip_prefix(keyword)
672 .is_some_and(|remainder| remainder.starts_with(char::is_whitespace))
673 })
674}
675
676fn parse_operation(line: &str, line_number: usize) -> Result<Operation, HashlineRejection> {
677 let trimmed = line.trim();
678 if trimmed == "REM" {
679 return parse_rem("", line_number);
680 }
681 let (keyword, remainder) = trimmed.split_once(char::is_whitespace).ok_or_else(|| {
682 HashlineRejection::parse(format!(
683 "operation at line {line_number} lacks required input"
684 ))
685 })?;
686 let remainder = remainder.trim();
687 match keyword {
688 "PUT" => parse_put(remainder, line_number),
689 "CUT" => parse_cut(remainder, line_number),
690 "REM" => parse_rem(remainder, line_number),
691 "MV" => parse_mv(remainder, line_number),
692 _ => Err(HashlineRejection::parse(format!(
693 "unknown operation {keyword:?} at line {line_number}"
694 ))),
695 }
696}
697
698fn parse_put(remainder: &str, line: usize) -> Result<Operation, HashlineRejection> {
699 let Some(prefix) = remainder.strip_suffix(':') else {
700 let mut parts = remainder.split_whitespace();
701 let address = parts.next().ok_or_else(|| {
702 HashlineRejection::parse(format!("PUT at line {line} lacks an address"))
703 })?;
704 let register = match parts.next() {
705 Some(register) => parse_register(register)?,
706 None => RegisterRef::Anonymous,
709 };
710 if parts.next().is_some() {
711 return Err(HashlineRejection::parse(format!(
712 "PUT at line {line} accepts at most one register source"
713 )));
714 }
715 return Ok(Operation::Put(PutOperation {
716 address: parse_address(address)?,
717 source: PutSource::Register(register),
718 line,
719 }));
720 };
721 let address = prefix.trim();
722 if address.is_empty() {
723 return Err(HashlineRejection::parse(format!(
724 "PUT at line {line} lacks an address before :"
725 )));
726 }
727 Ok(Operation::Put(PutOperation {
728 address: parse_address(address)?,
729 source: PutSource::Text(Vec::new()),
730 line,
731 }))
732}
733
734fn parse_cut(remainder: &str, line: usize) -> Result<Operation, HashlineRejection> {
735 let mut parts = remainder.split_whitespace();
736 let address = parts
737 .next()
738 .ok_or_else(|| HashlineRejection::parse(format!("CUT at line {line} lacks an address")))?;
739 let register = match parts.next() {
740 Some(register) => Some(parse_register(register)?),
741 None => None,
742 };
743 if parts.next().is_some() {
744 return Err(HashlineRejection::parse(format!(
745 "CUT at line {line} has unexpected trailing input"
746 )));
747 }
748 Ok(Operation::Cut(CutOperation {
749 address: parse_address(address)?,
750 register,
751 line,
752 }))
753}
754
755fn parse_rem(remainder: &str, line: usize) -> Result<Operation, HashlineRejection> {
756 if !remainder.trim().is_empty() {
757 return Err(HashlineRejection::parse(format!(
758 "REM at line {line} removes the whole section file and accepts no address"
759 )));
760 }
761 Ok(Operation::Rem(RemOperation { line }))
762}
763
764fn parse_mv(remainder: &str, line: usize) -> Result<Operation, HashlineRejection> {
765 let destination = remainder.trim();
766 if destination.is_empty() || destination.split_whitespace().count() != 1 {
767 return Err(HashlineRejection::parse(format!(
768 "MV at line {line} requires exactly one destination path"
769 )));
770 }
771 Ok(Operation::Mv(MvOperation {
772 destination: unquote(destination).to_string(),
773 line,
774 }))
775}
776
777fn parse_register(input: &str) -> Result<RegisterRef, HashlineRejection> {
778 let Some(name) = input.strip_prefix('@') else {
779 return Err(HashlineRejection::parse("registers must begin with @"));
780 };
781 if name.is_empty()
782 || !name.chars().all(|character| {
783 character.is_ascii_alphanumeric() || character == '_' || character == '-'
784 })
785 {
786 return Err(HashlineRejection::parse(
787 "register names may contain only ASCII letters, digits, _, and -",
788 ));
789 }
790 Ok(RegisterRef::Named(name.to_string()))
791}
792
793pub fn parse_address(input: &str) -> Result<Address, HashlineRejection> {
796 let input = input.trim();
797 if input == "0" {
798 return Ok(Address::Bof);
799 }
800 if let Some((side, line)) = input
801 .strip_prefix('<')
802 .map(|line| (GapSide::Before, line))
803 .or_else(|| input.strip_prefix('>').map(|line| (GapSide::After, line)))
804 {
805 if let Some(block_line) = line.strip_suffix('*') {
806 return Ok(Address::BlockGap {
807 side,
808 line: parse_line_reference(block_line)?,
809 });
810 }
811 return Ok(Address::Gap {
812 side,
813 line: parse_line_reference(line)?,
814 });
815 }
816 if let Some(line) = input.strip_suffix('*') {
817 return Ok(Address::Block(parse_line_reference(line)?));
818 }
819 for separator in ["..=", ".=", ".."] {
822 if let Some((start, end)) = input.split_once(separator) {
823 return Ok(Address::Range {
824 start: parse_line_reference(start)?,
825 end: parse_line_reference(end)?,
826 });
827 }
828 }
829 Ok(Address::Line(parse_line_reference(input)?))
830}
831
832fn parse_line_reference(input: &str) -> Result<LineReference, HashlineRejection> {
833 let input = input.trim();
834 if input == "$" {
835 return Ok(LineReference::EofRelative(0));
836 }
837 if let Some(offset) = input.strip_prefix("$-") {
838 return Ok(LineReference::EofRelative(parse_positive_usize(offset)?));
839 }
840 let line = parse_positive_usize(input)?;
841 Ok(LineReference::Absolute(line))
842}
843
844fn parse_positive_usize(input: &str) -> Result<usize, HashlineRejection> {
845 let value = input.parse::<usize>().map_err(|_| {
846 HashlineRejection::parse(format!("{input:?} is not a valid positive line number"))
847 })?;
848 if value == 0 {
849 return Err(HashlineRejection::parse(
850 "line number zero is only valid as the standalone BOF address",
851 ));
852 }
853 Ok(value)
854}
855
856fn unquote(value: &str) -> &str {
857 value
858 .strip_prefix('"')
859 .and_then(|value| value.strip_suffix('"'))
860 .or_else(|| {
861 value
862 .strip_prefix('\'')
863 .and_then(|value| value.strip_suffix('\''))
864 })
865 .unwrap_or(value)
866}
867
868fn normalize_tag(tag: &str) -> Result<String, HashlineRejection> {
869 if tag.len() != 4 || !tag.bytes().all(|byte| byte.is_ascii_hexdigit()) {
870 return Err(HashlineRejection::malformed_tag(
871 "section tags must be exactly four hexadecimal digits",
872 ));
873 }
874 Ok(tag.to_ascii_uppercase())
875}
876
877#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
879pub struct LineSpan {
880 pub start: usize,
881 pub end: usize,
882}
883
884impl LineSpan {
885 pub fn new(start: usize, end: usize) -> Option<Self> {
886 (start > 0 && start <= end).then_some(Self { start, end })
887 }
888
889 pub fn lines(self) -> impl Iterator<Item = usize> {
890 self.start..=self.end
891 }
892}
893
894#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
897pub struct ResolvedGap {
898 pub before: Option<usize>,
899 pub after: Option<usize>,
900}
901
902#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
904pub enum ResolvedAddress {
905 WholeFile,
907 Span(LineSpan),
908 Gap(ResolvedGap),
909 BlockAnchor(usize),
912 BlockGapAnchor {
915 side: GapSide,
916 anchor: usize,
917 },
918}
919
920impl ResolvedAddress {
921 pub fn addressed_span(self) -> Option<LineSpan> {
922 match self {
923 Self::Span(span) => Some(span),
924 Self::WholeFile | Self::Gap(_) | Self::BlockAnchor(_) | Self::BlockGapAnchor { .. } => {
925 None
926 }
927 }
928 }
929
930 pub fn required_anchors(self) -> Vec<usize> {
931 match self {
932 Self::Gap(gap) => [gap.before, gap.after].into_iter().flatten().collect(),
933 Self::WholeFile
934 | Self::Span(_)
935 | Self::BlockAnchor(_)
936 | Self::BlockGapAnchor { .. } => Vec::new(),
937 }
938 }
939}
940
941pub fn resolve_address(
944 address: &Address,
945 snapshot: &Snapshot,
946) -> Result<ResolvedAddress, HashlineRejection> {
947 let total_lines = snapshot.total_lines;
948 match address {
949 Address::Bof => Ok(ResolvedAddress::Gap(ResolvedGap {
950 before: None,
951 after: (total_lines > 0).then_some(1),
952 })),
953 Address::Line(reference) => {
954 let line = resolve_line_reference(*reference, total_lines)?;
955 Ok(ResolvedAddress::Span(LineSpan {
956 start: line,
957 end: line,
958 }))
959 }
960 Address::Range { start, end } => {
961 let start = resolve_line_reference(*start, total_lines)?;
962 let end = resolve_line_reference(*end, total_lines)?;
963 let Some(span) = LineSpan::new(start, end) else {
964 return Err(HashlineRejection::eligibility(
965 HashlineRejectionCode::BoundaryIneligible,
966 "range start occurs after range end in the retained snapshot",
967 ));
968 };
969 Ok(ResolvedAddress::Span(span))
970 }
971 Address::Gap { side, line } => {
972 let line = resolve_line_reference(*line, total_lines)?;
973 let gap = match side {
974 GapSide::Before => ResolvedGap {
975 before: line.checked_sub(1),
976 after: Some(line),
977 },
978 GapSide::After => ResolvedGap {
979 before: Some(line),
980 after: (line < total_lines).then_some(line + 1),
981 },
982 };
983 Ok(ResolvedAddress::Gap(gap))
984 }
985 Address::Block(reference) => Ok(ResolvedAddress::BlockAnchor(resolve_line_reference(
986 *reference,
987 total_lines,
988 )?)),
989 Address::BlockGap { side, line } => Ok(ResolvedAddress::BlockGapAnchor {
990 side: *side,
991 anchor: resolve_line_reference(*line, total_lines)?,
992 }),
993 }
994}
995
996fn resolve_line_reference(
997 reference: LineReference,
998 total_lines: usize,
999) -> Result<usize, HashlineRejection> {
1000 let line = match reference {
1001 LineReference::Absolute(line) if line <= total_lines => Some(line),
1002 LineReference::EofRelative(offset) if offset < total_lines => Some(total_lines - offset),
1003 LineReference::Absolute(_) | LineReference::EofRelative(_) => None,
1004 };
1005 line.ok_or_else(|| {
1006 HashlineRejection::eligibility(
1007 HashlineRejectionCode::BoundaryIneligible,
1008 "address falls outside the retained snapshot boundary",
1009 )
1010 })
1011}
1012
1013pub fn expand_block(
1016 resolved: ResolvedAddress,
1017 span: LineSpan,
1018 snapshot: &Snapshot,
1019) -> Result<ResolvedAddress, HashlineRejection> {
1020 let (side, anchor) = match resolved {
1021 ResolvedAddress::BlockAnchor(anchor) => (None, anchor),
1022 ResolvedAddress::BlockGapAnchor { side, anchor } => (Some(side), anchor),
1023 ResolvedAddress::WholeFile | ResolvedAddress::Span(_) | ResolvedAddress::Gap(_) => {
1024 return Err(HashlineRejection::parse(
1025 "only a parsed block address can be expanded as a block",
1026 ));
1027 }
1028 };
1029 if !span.lines().any(|line| line == anchor) || span.end > snapshot.total_lines {
1030 return Err(HashlineRejection::eligibility(
1031 HashlineRejectionCode::BoundaryIneligible,
1032 "block resolver returned a span outside the retained snapshot",
1033 ));
1034 }
1035 match side {
1036 None => Ok(ResolvedAddress::Span(span)),
1037 Some(GapSide::Before) => Ok(ResolvedAddress::Gap(ResolvedGap {
1038 before: span.start.checked_sub(1),
1039 after: Some(span.start),
1040 })),
1041 Some(GapSide::After) => Ok(ResolvedAddress::Gap(ResolvedGap {
1042 before: Some(span.end),
1043 after: (span.end < snapshot.total_lines).then_some(span.end + 1),
1044 })),
1045 }
1046}
1047
1048pub fn check_eligibility(
1052 snapshot: &Snapshot,
1053 address: ResolvedAddress,
1054) -> Result<(), HashlineRejection> {
1055 match address {
1056 ResolvedAddress::WholeFile => {
1057 if snapshot.records.len() != snapshot.total_lines
1058 || (1..=snapshot.total_lines).any(|line| !snapshot.is_seen(line))
1059 {
1060 return Err(HashlineRejection::eligibility(
1061 HashlineRejectionCode::UnseenLine,
1062 "REM and MV require a tagged read that retained the whole source file",
1063 ));
1064 }
1065 }
1066 ResolvedAddress::Span(span) => {
1067 for line in span.lines() {
1068 if !snapshot.is_seen(line) {
1069 return Err(HashlineRejection::eligibility(
1070 HashlineRejectionCode::UnseenLine,
1071 format!("line {line} was not retained by the tagged read"),
1072 ));
1073 }
1074 }
1075 }
1076 ResolvedAddress::BlockAnchor(line)
1077 | ResolvedAddress::BlockGapAnchor { anchor: line, .. } => {
1078 if !snapshot.is_seen(line) {
1079 return Err(HashlineRejection::eligibility(
1080 HashlineRejectionCode::UnseenLine,
1081 format!("block anchor line {line} was not retained by the tagged read"),
1082 ));
1083 }
1084 }
1085 ResolvedAddress::Gap(gap) => {
1086 if gap.before.is_none() && gap.after.is_none() {
1087 if !snapshot.boundary.empty_file {
1088 return Err(HashlineRejection::eligibility(
1089 HashlineRejectionCode::BoundaryIneligible,
1090 "empty-file gap has no retained empty-file boundary evidence",
1091 ));
1092 }
1093 return Ok(());
1094 }
1095 for line in [gap.before, gap.after].into_iter().flatten() {
1096 if !snapshot.is_seen(line) {
1097 return Err(HashlineRejection::eligibility(
1098 HashlineRejectionCode::UnseenLine,
1099 format!("gap boundary line {line} was not retained by the tagged read"),
1100 ));
1101 }
1102 }
1103 if gap.before.is_none() && !snapshot.boundary.bof_observed {
1104 return Err(HashlineRejection::eligibility(
1105 HashlineRejectionCode::BoundaryIneligible,
1106 "BOF boundary evidence was not retained",
1107 ));
1108 }
1109 if gap.after.is_none() && !snapshot.boundary.eof_observed {
1110 return Err(HashlineRejection::eligibility(
1111 HashlineRejectionCode::BoundaryIneligible,
1112 "EOF boundary evidence was not retained",
1113 ));
1114 }
1115 }
1116 }
1117 Ok(())
1118}
1119
1120#[derive(Clone, Debug)]
1124pub struct ResolvedPatchSection {
1125 pub section_index: usize,
1126 pub canonical_path: PathBuf,
1127 pub snapshot: Snapshot,
1128 pub operations: Vec<ResolvedOperation>,
1129}
1130
1131#[derive(Clone, Debug, Eq, PartialEq)]
1132pub struct ResolvedOperation {
1133 pub operation_index: usize,
1134 pub address: ResolvedAddress,
1136}
1137
1138pub fn resolve_patch_sections<F>(
1139 store: &mut SnapshotStore,
1140 patch: &Patch,
1141 mut canonicalize: F,
1142) -> Result<Vec<ResolvedPatchSection>, HashlineRejection>
1143where
1144 F: FnMut(&str) -> Result<PathBuf, HashlineRejection>,
1145{
1146 let mut views: BTreeMap<PathBuf, Snapshot> = BTreeMap::new();
1147 let mut resolved = Vec::with_capacity(patch.sections.len());
1148 for (section_index, section) in patch.sections.iter().enumerate() {
1149 let canonical_path = canonicalize(§ion.header.requested_path)?;
1150 let snapshot = resolve_snapshot(store, &canonical_path, §ion.header.tag)?;
1151 if let Some(existing) = views.get(&canonical_path) {
1152 if !equivalent_snapshots(existing, &snapshot) {
1153 return Err(HashlineRejection::resolution(
1154 HashlineRejectionCode::AmbiguousTag,
1155 "sections for one canonical path selected different retained verification evidence",
1156 ));
1157 }
1158 } else {
1159 views.insert(canonical_path.clone(), snapshot.clone());
1160 }
1161
1162 let mut operations = Vec::with_capacity(section.operations.len());
1163 for (operation_index, operation) in section.operations.iter().enumerate() {
1164 let address = match operation.address() {
1165 Some(address) => resolve_address(address, &snapshot)?,
1166 None => ResolvedAddress::WholeFile,
1167 };
1168 check_eligibility(&snapshot, address)?;
1169 operations.push(ResolvedOperation {
1170 operation_index,
1171 address,
1172 });
1173 }
1174 resolved.push(ResolvedPatchSection {
1175 section_index,
1176 canonical_path,
1177 snapshot,
1178 operations,
1179 });
1180 }
1181 Ok(resolved)
1182}
1183
1184pub fn resolve_snapshot(
1187 store: &mut SnapshotStore,
1188 canonical_path: impl AsRef<Path>,
1189 tag: &str,
1190) -> Result<Snapshot, HashlineRejection> {
1191 let tag = normalize_tag(tag)?;
1192 store
1193 .lookup(canonical_path, &tag)
1194 .map_err(|error| match error {
1195 SnapshotLookupError::UnknownTag => HashlineRejection::resolution(
1196 HashlineRejectionCode::UnknownTag,
1197 "the tagged snapshot is not resident for this path",
1198 ),
1199 SnapshotLookupError::EvictedTag => HashlineRejection::resolution(
1200 HashlineRejectionCode::EvictedTag,
1201 "the tagged snapshot was evicted from this session",
1202 ),
1203 SnapshotLookupError::AmbiguousTag => HashlineRejection::resolution(
1204 HashlineRejectionCode::AmbiguousTag,
1205 "the four-hex tag collides across different normalized file content",
1206 ),
1207 })
1208}
1209
1210#[derive(Clone, Debug, Eq, PartialEq)]
1212pub struct Baseline {
1213 pub bytes: Vec<u8>,
1214 pub snapshot: Snapshot,
1215}
1216
1217impl Baseline {
1218 pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
1219 let bytes = bytes.into();
1220 Self {
1221 snapshot: scan_bytes(&bytes),
1222 bytes,
1223 }
1224 }
1225
1226 pub fn raw_record(&self, line: usize) -> Option<&RawLineRecord> {
1227 self.snapshot.raw_record(line)
1228 }
1229}
1230
1231#[derive(Clone, Debug, Default)]
1235pub struct BaselineCache {
1236 baselines: BTreeMap<PathBuf, Baseline>,
1237}
1238
1239impl BaselineCache {
1240 pub fn load_once(
1241 &mut self,
1242 canonical_path: impl Into<PathBuf>,
1243 bytes: impl Into<Vec<u8>>,
1244 ) -> &Baseline {
1245 let path = canonical_path.into();
1246 self.baselines
1247 .entry(path)
1248 .or_insert_with(|| Baseline::from_bytes(bytes))
1249 }
1250
1251 pub fn get(&self, canonical_path: impl AsRef<Path>) -> Option<&Baseline> {
1252 self.baselines.get(canonical_path.as_ref())
1253 }
1254
1255 pub fn len(&self) -> usize {
1256 self.baselines.len()
1257 }
1258
1259 pub fn is_empty(&self) -> bool {
1260 self.baselines.is_empty()
1261 }
1262}
1263
1264#[derive(Clone, Debug, Eq, PartialEq)]
1269pub enum VerificationOutcome {
1270 Exact,
1271 RecoveryRequired(RecoveryPlan),
1272 Rejected(HashlineRejection),
1273 BlockNeedsResolution { anchor: usize },
1274}
1275
1276#[derive(Clone, Debug, Eq, PartialEq)]
1279pub struct RecoveryPlan {
1280 pub old_span: LineSpan,
1281 pub expected_records: Vec<RawLineRecord>,
1282}
1283
1284pub fn verify_exact(
1287 snapshot: &Snapshot,
1288 baseline: &Baseline,
1289 address: ResolvedAddress,
1290) -> VerificationOutcome {
1291 if let Err(rejection) = check_eligibility(snapshot, address) {
1292 return VerificationOutcome::Rejected(rejection);
1293 }
1294 match address {
1295 ResolvedAddress::WholeFile => {
1296 if snapshot.total_lines != baseline.snapshot.total_lines
1297 || snapshot.records != baseline.snapshot.records
1298 {
1299 VerificationOutcome::Rejected(HashlineRejection::stale_verification(
1300 "the whole-file source changed since the tagged read",
1301 ))
1302 } else {
1303 VerificationOutcome::Exact
1304 }
1305 }
1306 ResolvedAddress::BlockAnchor(anchor) | ResolvedAddress::BlockGapAnchor { anchor, .. } => {
1307 VerificationOutcome::BlockNeedsResolution { anchor }
1308 }
1309 ResolvedAddress::Gap(gap) => {
1310 for line in [gap.before, gap.after].into_iter().flatten() {
1311 if snapshot.raw_record(line) != baseline.raw_record(line) {
1312 return VerificationOutcome::Rejected(HashlineRejection::stale_verification(
1313 format!("required gap anchor line {line} changed since the tagged read"),
1314 ));
1315 }
1316 }
1317 VerificationOutcome::Exact
1318 }
1319 ResolvedAddress::Span(span) => {
1320 let expected_records: Vec<RawLineRecord> = span
1321 .lines()
1322 .filter_map(|line| snapshot.raw_record(line).cloned())
1323 .collect();
1324 let changed = span
1325 .lines()
1326 .any(|line| snapshot.raw_record(line) != baseline.raw_record(line));
1327 if changed {
1328 VerificationOutcome::RecoveryRequired(RecoveryPlan {
1329 old_span: span,
1330 expected_records,
1331 })
1332 } else {
1333 VerificationOutcome::Exact
1334 }
1335 }
1336 }
1337}
1338
1339#[cfg(test)]
1340mod tests {
1341 use super::*;
1342 use crate::hashline::scan::{scan_bytes_with_request, CoverageInput, ScanRequest};
1343 use crate::hashline::snapshot::MAX_SNAPSHOT_PATHS;
1344
1345 fn snapshot(bytes: &[u8], lines: impl IntoIterator<Item = usize>) -> Snapshot {
1346 scan_bytes_with_request(bytes, ScanRequest::new(CoverageInput::lines(lines)))
1347 .snapshot
1348 .expect("in-memory snapshots reach EOF")
1349 }
1350
1351 #[test]
1352 fn raw_arguments_allow_only_a_nonempty_patch_string() {
1353 let request = validate_raw_arguments(&serde_json::json!({
1354 "patch": "[src/lib.rs#cafe]\nREM"
1355 }))
1356 .expect("hashline request");
1357 assert!(request.patch.contains("REM"));
1358
1359 for value in [
1360 serde_json::json!({"patch": "", "path": "src/lib.rs"}),
1361 serde_json::json!({"patch": 3}),
1362 serde_json::json!({"path": "src/lib.rs", "edits": []}),
1363 ] {
1364 let rejection = validate_raw_arguments(&value).expect_err("legacy shape is rejected");
1365 assert_eq!(rejection.code, HashlineRejectionCode::ParseError);
1366 assert_eq!(rejection.stage, RejectionStage::Parse);
1367 }
1368 }
1369
1370 #[test]
1371 fn headers_distinguish_missing_and_malformed_tags() {
1372 let missing = parse_section_header("[src/lib.rs]").expect_err("missing tag");
1373 assert_eq!(missing.code, HashlineRejectionCode::MissingTag);
1374 assert_eq!(missing.stage, RejectionStage::Header);
1375
1376 for header in ["[src/lib.rs#]", "[src/lib.rs#abc]", "[src/lib.rs#ABCDE]"] {
1377 let malformed = parse_section_header(header).expect_err("malformed tag");
1378 assert_eq!(malformed.code, HashlineRejectionCode::MalformedTag);
1379 assert_eq!(malformed.stage, RejectionStage::Header);
1380 }
1381 assert_eq!(
1382 parse_section_header("[src/lib.rs#cAfE]")
1383 .expect("valid header")
1384 .tag,
1385 "CAFE"
1386 );
1387 }
1388
1389 #[test]
1390 fn parser_retains_multisection_operations_and_put_body() {
1391 let patch = parse_hashline_patch(
1392 "*** 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",
1393 )
1394 .expect("patch parses");
1395 assert_eq!(patch.sections.len(), 2);
1396 assert_eq!(patch.sections[0].operations.len(), 2);
1397 let Operation::Put(put) = &patch.sections[0].operations[0] else {
1398 panic!("first operation is PUT");
1399 };
1400 assert_eq!(
1401 put.source,
1402 PutSource::Text(vec!["first".into(), "second".into()])
1403 );
1404 assert!(matches!(
1405 patch.sections[1].operations[0],
1406 Operation::Put(PutOperation {
1407 source: PutSource::Register(RegisterRef::Named(_)),
1408 ..
1409 })
1410 ));
1411 }
1412
1413 #[test]
1414 fn parser_accepts_all_pre_request_address_forms() {
1415 assert_eq!(parse_address("0").unwrap(), Address::Bof);
1416 assert!(matches!(
1417 parse_address("2.=4").unwrap(),
1418 Address::Range { .. }
1419 ));
1420 assert!(matches!(
1421 parse_address("2..=4").unwrap(),
1422 Address::Range { .. }
1423 ));
1424 assert!(matches!(parse_address("<1").unwrap(), Address::Gap { .. }));
1425 assert!(matches!(
1426 parse_address(">$-1").unwrap(),
1427 Address::Gap { .. }
1428 ));
1429 assert!(matches!(
1430 parse_address(">8*").unwrap(),
1431 Address::BlockGap {
1432 side: GapSide::After,
1433 ..
1434 }
1435 ));
1436 assert!(matches!(parse_address("$*").unwrap(), Address::Block(_)));
1437 }
1438
1439 #[test]
1440 fn parser_guides_text_put_bodies_and_accepts_a_terminal_newline() {
1441 let register_put = parse_hashline_patch("[a.rs#CAFE]\nPUT 1\n+replacement")
1442 .expect_err("register PUT cannot accept a text body");
1443 assert_eq!(register_put.stage, RejectionStage::Parse);
1444 assert!(register_put.message.contains("PUT <address>:"));
1445 assert!(register_put.message.contains("accepts no body"));
1446
1447 let body = parse_hashline_patch("[a.rs#CAFE]\nPUT 1:\nreplacement")
1448 .expect_err("body rows require the + marker");
1449 assert!(body.message.contains("`+`"));
1450 assert!(body.message.contains("blank row"));
1451
1452 assert!(parse_hashline_patch("[a.rs#CAFE]\nPUT 1:\n+replacement\n").is_ok());
1453 }
1454
1455 #[test]
1456 fn parser_rejects_noncanonical_file_operations() {
1457 assert!(parse_hashline_patch("[a.rs#CAFE]\nREM 1").is_err());
1458 assert!(parse_hashline_patch("[a.rs#CAFE]\nMV 1 -> b.rs").is_err());
1459 assert!(parse_hashline_patch("[a.rs#CAFE]\nREM\nPUT 1:\n+x").is_err());
1460 assert!(parse_hashline_patch("[a.rs#CAFE]\nMV b.rs\nCUT 1").is_err());
1461 }
1462
1463 #[test]
1464 fn snapshot_lookup_maps_all_store_outcomes_to_resolution_stage() {
1465 let mut store = SnapshotStore::new();
1466 let unknown = resolve_snapshot(&mut store, "/not-known", "CAFE").expect_err("unknown");
1467 assert_eq!(unknown.code, HashlineRejectionCode::UnknownTag);
1468 assert_eq!(unknown.stage, RejectionStage::Resolution);
1469
1470 let path = PathBuf::from("/same-tag");
1471 let first = snapshot(b"shared\none\n", [1]);
1472 let mut collision = snapshot(b"shared\nother\n", [1]);
1473 let tag = first.tag.clone();
1474 collision.tag = tag.clone();
1475 store.publish(&path, first);
1476 store.publish(&path, collision);
1477 let ambiguous = resolve_snapshot(&mut store, &path, &tag).expect_err("collision");
1478 assert_eq!(store.snapshot_count(), 2);
1479 assert_eq!(
1480 store.lookup_state(&path, &tag),
1481 crate::hashline::snapshot::SnapshotLookup::Ambiguous
1482 );
1483 assert_eq!(ambiguous.code, HashlineRejectionCode::AmbiguousTag);
1484 assert_eq!(ambiguous.stage, RejectionStage::Resolution);
1485 assert_eq!(
1486 ambiguous.steering,
1487 "use apply_patch or another available non-hashline edit surface; re-reading preserves this colliding four-hex tag"
1488 );
1489 }
1490
1491 #[test]
1492 fn evicted_handles_keep_their_distinct_resolution_code() {
1493 let mut store = SnapshotStore::new();
1494 let first_path = PathBuf::from("/evicted-0");
1495 let first = snapshot(b"row\n", [1]);
1496 let tag = first.tag.clone();
1497 store.publish(&first_path, first);
1498 for index in 1..=MAX_SNAPSHOT_PATHS {
1499 store.publish(
1500 PathBuf::from(format!("/evicted-{index}")),
1501 snapshot(b"row\n", [1]),
1502 );
1503 }
1504 let rejection = resolve_snapshot(&mut store, &first_path, &tag).expect_err("evicted");
1505 assert_eq!(rejection.code, HashlineRejectionCode::EvictedTag);
1506 assert_eq!(rejection.stage, RejectionStage::Resolution);
1507 }
1508
1509 #[test]
1510 fn equivalent_candidates_resolve_despite_different_provenance() {
1511 let mut store = SnapshotStore::new();
1512 let path = PathBuf::from("/equivalent");
1513 let first = snapshot(b"one\ntwo\n", [1]);
1514 let mut reread = snapshot(b"one\ntwo\n", [1]);
1515 reread.provenance = reread.provenance.with_label("read", "second");
1516 let tag = first.tag.clone();
1517 store.publish(&path, first);
1518 store.publish(&path, reread);
1519 assert!(resolve_snapshot(&mut store, &path, &tag).is_ok());
1520 }
1521
1522 #[test]
1523 fn same_path_sections_are_resolved_in_pre_request_coordinates() {
1524 let mut store = SnapshotStore::new();
1525 let path = PathBuf::from("/pre-request");
1526 let captured = snapshot(b"one\ntwo\nthree\n", [1, 2, 3]);
1527 let tag = captured.tag.clone();
1528 store.publish(&path, captured);
1529 let patch = parse_hashline_patch(&format!(
1530 "[/pre-request#{tag}]\nCUT 1\n[/pre-request#{tag}]\nCUT 3"
1531 ))
1532 .expect("patch");
1533 let sections =
1534 resolve_patch_sections(&mut store, &patch, |requested| Ok(PathBuf::from(requested)))
1535 .expect("both sections resolve before mutation");
1536 assert_eq!(sections.len(), 2);
1537 assert_eq!(
1538 sections[1].operations[0].address,
1539 ResolvedAddress::Span(LineSpan { start: 3, end: 3 })
1540 );
1541 }
1542
1543 #[test]
1544 fn whole_file_operations_require_full_retained_coverage() {
1545 let mut store = SnapshotStore::new();
1546 let path = PathBuf::from("/whole-file");
1547 let partial = snapshot(b"one\ntwo\n", [1]);
1548 let tag = partial.tag.clone();
1549 store.publish(&path, partial);
1550 let patch = parse_hashline_patch(&format!("[/whole-file#{tag}]\nREM")).expect("patch");
1551 let rejection =
1552 resolve_patch_sections(&mut store, &patch, |requested| Ok(PathBuf::from(requested)))
1553 .expect_err("partial read cannot authorize whole-file deletion");
1554 assert_eq!(rejection.code, HashlineRejectionCode::UnseenLine);
1555 assert_eq!(rejection.stage, RejectionStage::Eligibility);
1556 }
1557
1558 #[test]
1559 fn addresses_resolve_against_snapshot_not_live_baseline_length() {
1560 let retained = snapshot(b"one\ntwo\nthree\n", [2, 3]);
1561 let resolved = resolve_address(&parse_address("$-1").unwrap(), &retained).unwrap();
1562 assert_eq!(
1563 resolved,
1564 ResolvedAddress::Span(LineSpan { start: 2, end: 2 })
1565 );
1566
1567 let baseline = Baseline::from_bytes(b"one\ntwo\nthree\nfour\n".to_vec());
1568 assert!(matches!(
1569 verify_exact(&retained, &baseline, resolved),
1570 VerificationOutcome::Exact
1571 ));
1572 }
1573
1574 #[test]
1575 fn gaps_require_their_retained_boundary_rows() {
1576 let retained = snapshot(b"one\ntwo\nthree\n", [2]);
1577 let gap = resolve_address(&parse_address("<2").unwrap(), &retained).unwrap();
1578 let rejection = check_eligibility(&retained, gap).expect_err("line one was unseen");
1579 assert_eq!(rejection.code, HashlineRejectionCode::UnseenLine);
1580 assert_eq!(rejection.stage, RejectionStage::Eligibility);
1581
1582 let eof = resolve_address(&parse_address(">$").unwrap(), &retained).unwrap();
1583 let rejection = check_eligibility(&retained, eof).expect_err("line three was unseen");
1584 assert_eq!(rejection.code, HashlineRejectionCode::UnseenLine);
1585 }
1586
1587 #[test]
1588 fn one_baseline_is_retained_for_every_section_of_a_canonical_path() {
1589 let mut cache = BaselineCache::default();
1590 let path = PathBuf::from("/canonical");
1591 let first = cache
1592 .load_once(path.clone(), b"before\n".to_vec())
1593 .bytes
1594 .clone();
1595 let second = cache
1596 .load_once(path.clone(), b"after\n".to_vec())
1597 .bytes
1598 .clone();
1599 assert_eq!(first, b"before\n");
1600 assert_eq!(second, b"before\n");
1601 assert_eq!(cache.len(), 1);
1602 }
1603
1604 #[test]
1605 fn anchor_mismatch_is_verification_but_span_mismatch_requests_recovery() {
1606 let retained = snapshot(b"one\ntwo\nthree\n", [1, 2, 3]);
1607 let span = resolve_address(&parse_address("2").unwrap(), &retained).unwrap();
1608 let changed_span = Baseline::from_bytes(b"one\nTWO\nthree\n".to_vec());
1609 let VerificationOutcome::RecoveryRequired(plan) =
1610 verify_exact(&retained, &changed_span, span)
1611 else {
1612 panic!("addressed record drift must be planned for recovery");
1613 };
1614 assert_eq!(plan.old_span, LineSpan { start: 2, end: 2 });
1615
1616 let gap = resolve_address(&parse_address("<2").unwrap(), &retained).unwrap();
1617 let changed_anchor = Baseline::from_bytes(b"ONE\ntwo\nthree\n".to_vec());
1618 let VerificationOutcome::Rejected(rejection) =
1619 verify_exact(&retained, &changed_anchor, gap)
1620 else {
1621 panic!("anchor drift must reject before recovery");
1622 };
1623 assert_eq!(rejection.code, HashlineRejectionCode::StaleTag);
1624 assert_eq!(rejection.stage, RejectionStage::Verification);
1625 }
1626
1627 #[test]
1628 fn exact_verification_compares_terminators_and_unnormalized_bytes() {
1629 let retained = snapshot(b"one \r\ntwo\n", [1, 2]);
1630 let span = resolve_address(&parse_address("1").unwrap(), &retained).unwrap();
1631 let trailing_space_drift = Baseline::from_bytes(b"one\r\ntwo\n".to_vec());
1632 assert!(matches!(
1633 verify_exact(&retained, &trailing_space_drift, span),
1634 VerificationOutcome::RecoveryRequired(_)
1635 ));
1636 }
1637}