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