Skip to main content

oxicode_hashline/
mismatch.rs

1//! Error type raised when a section's snapshot tag does not match the live file
2//! content and recovery is unavailable / has failed.
3//!
4//! Carries enough context to render a useful diagnostic: the anchored lines
5//! plus a couple of lines of surrounding context. [`MismatchError`] formats
6//! this into a message at construction time.
7//!
8//! Ported from omp `packages/hashline/src/mismatch.ts`.
9
10use crate::format::{HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX};
11use crate::messages::format_anchored_context;
12
13/// Example content-hash tag shown in the anchor-requirement diagnostic.
14/// (omp exposes this as `HL_FILE_HASH_EXAMPLES[0]`; the format module here does
15/// not, so the first example is repeated locally.)
16const EXAMPLE_HASH: &str = "1A2B";
17
18// ── Details ──────────────────────────────────────────────────────────────
19
20/// Diagnostic context carried by a [`MismatchError`].
21#[derive(Debug, Clone)]
22pub struct MismatchDetails {
23    /// Canonical path, when known.
24    pub path: Option<String>,
25    /// Hash tag the section was bound to.
26    pub expected_file_hash: String,
27    /// Hash tag the live file actually produced.
28    pub actual_file_hash: String,
29    /// Live file lines, for an anchored-context preview.
30    pub file_lines: Vec<String>,
31    /// 1-indexed lines the edit anchored to.
32    pub anchor_lines: Vec<u32>,
33    /// `true` when the expected hash resolved to a recorded snapshot (file
34    /// content drifted since that snapshot); `false` when no snapshot was ever
35    /// recorded for the hash (likely fabricated or carried over from a prior
36    /// session). Drives a more actionable rejection message.
37    pub hash_recognized: bool,
38}
39
40impl Default for MismatchDetails {
41    fn default() -> Self {
42        // omp defaults `hashRecognized` to `true` for backward compatibility.
43        Self {
44            path: None,
45            expected_file_hash: String::new(),
46            actual_file_hash: String::new(),
47            file_lines: Vec::new(),
48            anchor_lines: Vec::new(),
49            hash_recognized: true,
50        }
51    }
52}
53
54// ── Error ────────────────────────────────────────────────────────────────
55
56/// Raised when a hashline section's snapshot tag doesn't match the live file's
57/// content (and recovery, if configured, declined the merge). Implements
58/// [`std::error::Error`]; its [`Display`](std::fmt::Display) is the formatted
59/// diagnostic produced by [`format_message`].
60#[derive(Debug, Clone, thiserror::Error)]
61#[error("{message}")]
62pub struct MismatchError {
63    /// Pre-formatted diagnostic (header + anchored context).
64    pub message: String,
65    /// Structured context behind the diagnostic.
66    pub details: MismatchDetails,
67}
68
69impl MismatchError {
70    /// Build the error, computing its message from `details`.
71    pub fn new(details: MismatchDetails) -> Self {
72        let message = format_message(&details);
73        Self { message, details }
74    }
75
76    /// The formatted diagnostic (alias for the [`Display`](std::fmt::Display)
77    /// rendering).
78    pub fn display_message(&self) -> &str {
79        &self.message
80    }
81}
82
83// ── Crate umbrella error ─────────────────────────────────────────────────
84
85/// The umbrella error type for the hashline crate.
86///
87/// Structured errors raised across the parser, tokenizer, patcher, and recovery
88/// paths. Defined here (per the omp-adoption design §3.10) so the lower-level
89/// tokenizer/parser can reference it without an extra module.
90#[derive(Debug, thiserror::Error)]
91pub enum HashlineError {
92    /// A patch grammar / structural parse error at a given line.
93    #[error("Parse error at line {line}: {msg}")]
94    Parse {
95        /// 1-indexed source line where the parse error occurred.
96        line: u32,
97        /// Human-readable parse error detail.
98        msg: String,
99    },
100    /// File does not exist.
101    #[error("File not found: {path}. Use the write tool to create new files.")]
102    NotFound {
103        /// Canonical file path that could not be found.
104        path: String,
105    },
106    /// Section omitted the mandatory snapshot tag (omp `missingSnapshotTagMessage`).
107    #[error("{0}")]
108    MissingSnapshotTag(String),
109    /// An anchored edit referenced unseen lines (omp `unseenLinesMessage`).
110    #[error("{0}")]
111    UnseenLines(String),
112    /// Snapshot tag mismatch with live content (omp `MismatchError`).
113    #[error("{detail}")]
114    Mismatch {
115        /// Pre-formatted diagnostic message.
116        detail: String,
117        /// Snapshot tag the section was bound to.
118        expected: String,
119        /// Snapshot tag the live file actually produced.
120        actual: String,
121    },
122    /// Multiple sections resolve to the same canonical path.
123    #[error("Multiple sections resolve to {path}")]
124    DuplicateCanonicalPath {
125        /// Canonical path targeted by more than one section.
126        path: String,
127    },
128    /// Edits resulted in no net change.
129    #[error("Edits to {path} resulted in no changes")]
130    NoOp {
131        /// Path whose applied edits produced no net change.
132        path: String,
133    },
134    /// Anchor line out of bounds for the file.
135    #[error("Line {line} does not exist (file has {total} lines)")]
136    LineOutOfBounds {
137        /// 1-indexed anchor line that exceeds the file's bounds.
138        line: u32,
139        /// Total number of lines in the file.
140        total: usize,
141    },
142    /// Underlying I/O error.
143    #[error("IO error: {0}")]
144    Io(#[from] std::io::Error),
145    /// No block resolver configured (block-ops feature only).
146    #[cfg(feature = "block-ops")]
147    #[error("Block resolver unavailable for {path}")]
148    BlockResolverUnavailable {
149        /// Path of the file whose block operation lacked a resolver.
150        path: String,
151    },
152}
153
154impl HashlineError {
155    /// Construct a [`HashlineError::Parse`] (the form the tokenizer raises).
156    pub fn parse(line: u32, msg: impl Into<String>) -> Self {
157        HashlineError::Parse {
158            line,
159            msg: msg.into(),
160        }
161    }
162}
163
164// ── Message builders ─────────────────────────────────────────────────────
165
166/// The two-line rejection header, branching on [`MismatchDetails::hash_recognized`].
167pub fn rejection_header(details: &MismatchDetails) -> Vec<String> {
168    let path_text = details
169        .path
170        .as_deref()
171        .map(|p| format!(" for {p}"))
172        .unwrap_or_default();
173    if !details.hash_recognized {
174        vec![
175            format!(
176                "Edit rejected{path_text}: hash {sep}{expected} is not from this session.",
177                sep = HL_FILE_HASH_SEP,
178                expected = details.expected_file_hash,
179            ),
180            format!(
181                "The current file hashes to {sep}{actual}. Re-read the file with `read` to copy a \
182                 current {pfx}path{sep}tag{sfx} header — never invent the tag and never reuse one \
183                 from a prior session.",
184                sep = HL_FILE_HASH_SEP,
185                actual = details.actual_file_hash,
186                pfx = HL_FILE_PREFIX,
187                sfx = HL_FILE_SUFFIX,
188            ),
189        ]
190    } else {
191        vec![
192            format!("Edit rejected{path_text}: file changed between read and edit."),
193            format!(
194                "Section is bound to {sep}{expected}, but the current file hashes to \
195                 {sep}{actual}. If a prior edit in this session modified this file, copy the \
196                 {pfx}path{sep}newhash{sfx} header from that edit's response; otherwise re-read \
197                 the file with `read` to refresh the tag before retrying.",
198                sep = HL_FILE_HASH_SEP,
199                expected = details.expected_file_hash,
200                actual = details.actual_file_hash,
201                pfx = HL_FILE_PREFIX,
202                sfx = HL_FILE_SUFFIX,
203            ),
204        ]
205    }
206}
207
208/// Full diagnostic: the rejection header followed by an anchored-context
209/// preview (when anchor lines and file lines are available).
210pub fn format_message(details: &MismatchDetails) -> String {
211    let mut lines = rejection_header(details);
212    let context = format_anchored_context(&details.anchor_lines, &details.file_lines);
213    if context.is_empty() {
214        lines.join("\n")
215    } else {
216        lines.push(String::new());
217        lines.extend(context);
218        lines.join("\n")
219    }
220}
221
222/// Alias of [`format_message`] (omp's `formatDisplayMessage`).
223pub fn format_display_message(details: &MismatchDetails) -> String {
224    format_message(details)
225}
226
227/// Throws (returns `Err`) when the line reference is out of bounds for the
228/// given file.
229pub fn validate_line_ref(line: u32, file_lines: &[String]) -> Result<(), String> {
230    if line < 1 || (line as usize) > file_lines.len() {
231        return Err(format!(
232            "Line {line} does not exist (file has {} lines)",
233            file_lines.len()
234        ));
235    }
236    Ok(())
237}
238
239/// Format the required-shape diagnostic shown when a line reference is malformed.
240pub fn format_full_anchor_requirement(raw: Option<&str>) -> String {
241    let received = match raw {
242        Some(r) => format!(" Received {r:?}."),
243        None => String::new(),
244    };
245    format!(
246        "a bare line number from read/search output plus the section header content-hash tag \
247         (for example {pfx}src/foo.ts{sep}{ex}{sfx} and line \"160\"){received}",
248        pfx = HL_FILE_PREFIX,
249        sep = HL_FILE_HASH_SEP,
250        ex = EXAMPLE_HASH,
251        sfx = HL_FILE_SUFFIX,
252    )
253}
254
255/// Parse a decorated bare line-number anchor like `42`, `*42:foo`, `> 7`.
256///
257/// Equivalent to omp's `parseTag` regex `/^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/`.
258/// Returns the parsed 1-indexed line number, or an error whose message is the
259/// required-shape diagnostic.
260pub fn parse_tag(reference: &str) -> Result<u32, String> {
261    match try_parse_line_ref(reference) {
262        Some(line) if line >= 1 => Ok(line),
263        Some(line) => Err(format!(
264            "Line number must be >= 1, got {line} in {reference:?}."
265        )),
266        None => Err(format!(
267            "Invalid line reference. Expected {}. Expected {}.",
268            reference,
269            format_full_anchor_requirement(Some(reference))
270        )),
271    }
272}
273
274/// Inner worker for [`parse_tag`]: matches omp's `LINE_REF_RE`.
275fn try_parse_line_ref(reference: &str) -> Option<u32> {
276    let s = reference.trim_start();
277    let bytes = s.as_bytes();
278    // Skip optional leading decorators [> + - *].
279    let mut i = 0;
280    while i < bytes.len() && matches!(bytes[i], b'>' | b'+' | b'-' | b'*') {
281        i += 1;
282    }
283    let rest = &s[i..];
284    let rest = rest.trim_start();
285    // A run of ASCII digits is the line number.
286    let digit_len = rest.bytes().take_while(|b| b.is_ascii_digit()).count();
287    if digit_len == 0 {
288        return None;
289    }
290    let line: u32 = rest[..digit_len].parse().ok()?;
291    // After the digits: optional `:…`, then trailing whitespace only.
292    let tail = rest[digit_len..].trim_end();
293    if tail.is_empty() || tail.starts_with(':') {
294        Some(line)
295    } else {
296        None
297    }
298}
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn details(expected: &str, actual: &str, recognized: bool) -> MismatchDetails {
304        MismatchDetails {
305            path: Some("src/foo.rs".to_string()),
306            expected_file_hash: expected.to_string(),
307            actual_file_hash: actual.to_string(),
308            file_lines: vec!["a".into(), "b".into(), "c".into(), "d".into(), "e".into()],
309            anchor_lines: vec![2],
310            hash_recognized: recognized,
311        }
312    }
313
314    #[test]
315    fn recognized_mismatch_renders_header_and_context() {
316        let d = details("AAAA", "BBBB", true);
317        let err = MismatchError::new(d);
318        assert!(err.message.contains("file changed between read and edit"));
319        assert!(err.message.contains("#AAAA"));
320        assert!(err.message.contains("#BBBB"));
321        // Anchored context is separated from the header by a blank line; the
322        // anchor line itself appears within the context window.
323        assert!(err.message.contains("\n\n"));
324        assert!(
325            err.message.contains("*2:b"),
326            "anchored line 2 appears in context"
327        );
328        // Structured fields are preserved.
329        assert_eq!(err.details.expected_file_hash, "AAAA");
330        assert_eq!(err.details.actual_file_hash, "BBBB");
331        assert!(err.details.hash_recognized);
332    }
333
334    #[test]
335    fn unrecognized_mismatch_uses_fabrication_message() {
336        let d = details("AAAA", "BBBB", false);
337        let msg = format_message(&d);
338        assert!(msg.contains("is not from this session"));
339        assert!(msg.contains("never invent the tag"));
340    }
341
342    #[test]
343    fn no_context_when_file_lines_absent() {
344        let d = MismatchDetails {
345            path: None,
346            expected_file_hash: "AAAA".into(),
347            actual_file_hash: "BBBB".into(),
348            file_lines: Vec::new(),
349            anchor_lines: Vec::new(),
350            hash_recognized: true,
351        };
352        let msg = format_message(&d);
353        assert!(!msg.contains("\n\n"));
354        assert!(msg.contains("Edit rejected"));
355    }
356
357    #[test]
358    fn validate_line_ref_bounds() {
359        let file: Vec<String> = vec!["x".into(), "y".into()];
360        assert!(validate_line_ref(1, &file).is_ok());
361        assert!(validate_line_ref(2, &file).is_ok());
362        assert!(validate_line_ref(0, &file).is_err());
363        assert!(validate_line_ref(3, &file).is_err());
364    }
365
366    #[test]
367    fn parse_tag_accepts_decorated_refs() {
368        assert_eq!(parse_tag("42").unwrap(), 42);
369        assert_eq!(parse_tag("  *42:foo").unwrap(), 42);
370        assert_eq!(parse_tag(" > 7").unwrap(), 7);
371        assert_eq!(parse_tag("160:some content").unwrap(), 160);
372    }
373
374    #[test]
375    fn parse_tag_rejects_garbage() {
376        assert!(parse_tag("not a line").is_err());
377        assert!(parse_tag(":42").is_err());
378        assert!(parse_tag("").is_err());
379        // Non-decorator, non-digit content after the number fails.
380        assert!(parse_tag("42 extra").is_err());
381    }
382
383    #[test]
384    fn mismatch_error_implements_std_error() {
385        let err = MismatchError::new(details("AAAA", "BBBB", true));
386        // Ensures the thiserror `Error` + `Display` derive is wired.
387        let _: &dyn std::error::Error = &err;
388        assert_eq!(err.display_message(), format!("{}", err));
389    }
390
391    #[test]
392    fn format_full_anchor_requirement_includes_example() {
393        let req = format_full_anchor_requirement(None);
394        assert!(req.contains("[src/foo.ts#1A2B]"));
395        assert!(req.contains("\"160\""));
396        let req_with = format_full_anchor_requirement(Some("xyz"));
397        assert!(req_with.contains("Received \"xyz\"."));
398    }
399}