Skip to main content

aft/hashline/syntax/
mod.rs

1//! Parsing and Phase-1 safety checks for the hashline patch language.
2//!
3//! This module deliberately separates a patch's *requested* coordinates from
4//! execution. Every address is resolved against the retained snapshot before a
5//! caller plans a mutation, and exact verification compares that snapshot with
6//! one caller-owned baseline. That keeps later operations in the same patch
7//! from silently renumbering the coordinates of earlier input.
8
9use 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/// The hashline edit entry point accepts exactly one argument field, `patch`,
19/// while hashline validation is enabled.
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct HashlineRequest {
22    pub patch: String,
23}
24
25/// Validate raw tool arguments before any legacy edit normalization can run.
26///
27/// The gate-on surface owns exactly one field. Rejecting unknown fields is
28/// intentional: accepting a legacy `path`, `edits`, or `oldString` field would
29/// make an agent believe that hashline verified input which the native engine
30/// never parsed.
31pub 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/// A stable pre-execution rejection code.
54#[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/// The one canonical Phase-1 adjudication stage enum.
96#[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/// Transport-neutral details for a Phase-1 rejection.
132#[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/// A parsed patch. Section order is patch order and must be retained by later
298/// planning and execution stages.
299#[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/// A per-file patch unit headed by the requested path and its snapshot tag.
311#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct PatchSection {
313    pub header: SectionHeader,
314    pub operations: Vec<Operation>,
315    pub line: usize,
316}
317
318/// The lossless path spelling paired with a normalized tag.
319#[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/// One hashline operation. Body-bearing PUTs retain logical body lines so the
345/// apply layer can select the target file's terminator policy after verification.
346#[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/// Named registers are durable only for the life of the session; the anonymous
412/// register is represented explicitly so it cannot collide with an empty name.
413#[derive(Clone, Debug, Eq, Hash, PartialEq)]
414pub enum RegisterRef {
415    Anonymous,
416    Named(String),
417}
418
419/// A reference whose value is resolved solely from `Snapshot::total_lines`.
420#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
421pub enum LineReference {
422    Absolute(usize),
423    /// `$` is offset zero; `$-1` is the penultimate snapshot row.
424    EofRelative(usize),
425}
426
427/// One parsed pre-request address.
428#[derive(Clone, Debug, Eq, PartialEq)]
429pub enum Address {
430    /// `0` and `<1` are both BOF insertion forms.
431    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    /// A gap adjacent to a syntactic block, such as `>8*`.
443    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
455/// Parse a complete patch after raw tool arguments selected the hashline path.
456/// Accept an optional `*** Begin Patch` / `*** End Patch` envelope, but reject
457/// any other preamble or trailing content instead of ignoring it.
458pub 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        // `split('\n')` yields one final empty item for a terminal newline. It
473        // is not a text-body row, while a deliberate blank body row remains `+`.
474        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 &sections {
587        validate_section_composition(section)?;
588        for operation in &section.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
638/// Parse one recognizable header and distinguish absent and malformed tags.
639pub 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            // When no register is specified, use the anonymous register. This
709            // supports `PUT >$` immediately after a bare CUT.
710            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
795/// Parse line, range, gap, block, and EOF-relative forms without consulting a
796/// live file. Resolution happens later against a snapshot's recorded line count.
797pub 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    // The canonical range separator is `.=`, but these alternate forms retain
822    // compatibility with existing hashline input.
823    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/// A fully resolved inclusive span in pre-request coordinates.
880#[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/// A gap resolves to adjacent snapshot rows rather than a current live-file
897/// offset. `None` anchors name BOF/EOF facts retained in the snapshot.
898#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
899pub struct ResolvedGap {
900    pub before: Option<usize>,
901    pub after: Option<usize>,
902}
903
904/// The result of resolving one parsed address against its retained snapshot.
905#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
906pub enum ResolvedAddress {
907    /// REM and MV address every record in their source section file.
908    WholeFile,
909    Span(LineSpan),
910    Gap(ResolvedGap),
911    /// Block parsing is owned here; a language-aware block resolver expands the
912    /// anchor to a span before verification and execution.
913    BlockAnchor(usize),
914    /// A language-aware resolver must expand this block before the gap can be
915    /// verified against its predecessor and successor anchors.
916    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
943/// Resolve an address against the snapshot that minted its handle. This function
944/// never reads filesystem state, including for `$` and EOF-relative forms.
945pub 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
1015/// Substitute an authoritative language-aware block span for a parsed block
1016/// anchor. The supplied span is still constrained to pre-request coordinates.
1017pub 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
1050/// Prove that the retained snapshot authorizes every record and boundary fact
1051/// an address will use. This is deliberately separate from exact verification:
1052/// an unread row is never eligible merely because current bytes happen to match.
1053pub 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/// Resolve all sections against their snapshot handles before any later apply
1123/// stage can mutate a path. A canonical path may appear in several sections,
1124/// but all of those sections must use equivalent verification evidence.
1125#[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    /// REM and MV use `WholeFile`; line operations retain their parsed address.
1137    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(&section.header.requested_path)?;
1152        let snapshot = resolve_snapshot(store, &canonical_path, &section.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
1186/// Normalize and resolve one case-insensitive four-hex snapshot tag using the
1187/// snapshot store's recorded result for the requested canonical path.
1188pub 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/// A complete Phase-1 baseline owned by one patch and one canonical path.
1213#[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/// A per-patch cache that makes the one-baseline-per-canonical-path contract
1234/// explicit. Calling `load_once` again returns the original byte buffer rather
1235/// than silently replacing the evidence used by another section.
1236#[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/// The result of exact verification. An addressed-span mismatch is deliberately
1267/// not a rejection: the recovery planner receives its exact old records. An
1268/// anchor mismatch remains a loud verification-stage rejection because moving a
1269/// gap or boundary insertion would change its meaning.
1270#[derive(Clone, Debug, Eq, PartialEq)]
1271pub enum VerificationOutcome {
1272    Exact,
1273    RecoveryRequired(RecoveryPlan),
1274    Rejected(HashlineRejection),
1275    BlockNeedsResolution { anchor: usize },
1276}
1277
1278/// The exact records from the old addressed span that a recovery plan can use
1279/// to remap the operation.
1280#[derive(Clone, Debug, Eq, PartialEq)]
1281pub struct RecoveryPlan {
1282    pub old_span: LineSpan,
1283    pub expected_records: Vec<RawLineRecord>,
1284}
1285
1286/// Verify an already-resolved address against one common baseline. Anchor rows
1287/// are checked before addressed rows so a stale gap cannot be auto-remapped.
1288pub 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}