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