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