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 { path: String },
149}
150
151impl HashlineError {
152    /// Construct a [`HashlineError::Parse`] (the form the tokenizer raises).
153    pub fn parse(line: u32, msg: impl Into<String>) -> Self {
154        HashlineError::Parse {
155            line,
156            msg: msg.into(),
157        }
158    }
159}
160
161// ── Message builders ─────────────────────────────────────────────────────
162
163/// The two-line rejection header, branching on [`MismatchDetails::hash_recognized`].
164pub fn rejection_header(details: &MismatchDetails) -> Vec<String> {
165    let path_text = details
166        .path
167        .as_deref()
168        .map(|p| format!(" for {p}"))
169        .unwrap_or_default();
170    if !details.hash_recognized {
171        vec![
172            format!(
173                "Edit rejected{path_text}: hash {sep}{expected} is not from this session.",
174                sep = HL_FILE_HASH_SEP,
175                expected = details.expected_file_hash,
176            ),
177            format!(
178                "The current file hashes to {sep}{actual}. Re-read the file with `read` to copy a \
179                 current {pfx}path{sep}tag{sfx} header — never invent the tag and never reuse one \
180                 from a prior session.",
181                sep = HL_FILE_HASH_SEP,
182                actual = details.actual_file_hash,
183                pfx = HL_FILE_PREFIX,
184                sfx = HL_FILE_SUFFIX,
185            ),
186        ]
187    } else {
188        vec![
189            format!("Edit rejected{path_text}: file changed between read and edit."),
190            format!(
191                "Section is bound to {sep}{expected}, but the current file hashes to \
192                 {sep}{actual}. If a prior edit in this session modified this file, copy the \
193                 {pfx}path{sep}newhash{sfx} header from that edit's response; otherwise re-read \
194                 the file with `read` to refresh the tag before retrying.",
195                sep = HL_FILE_HASH_SEP,
196                expected = details.expected_file_hash,
197                actual = details.actual_file_hash,
198                pfx = HL_FILE_PREFIX,
199                sfx = HL_FILE_SUFFIX,
200            ),
201        ]
202    }
203}
204
205/// Full diagnostic: the rejection header followed by an anchored-context
206/// preview (when anchor lines and file lines are available).
207pub fn format_message(details: &MismatchDetails) -> String {
208    let mut lines = rejection_header(details);
209    let context = format_anchored_context(&details.anchor_lines, &details.file_lines);
210    if context.is_empty() {
211        lines.join("\n")
212    } else {
213        lines.push(String::new());
214        lines.extend(context);
215        lines.join("\n")
216    }
217}
218
219/// Alias of [`format_message`] (omp's `formatDisplayMessage`).
220pub fn format_display_message(details: &MismatchDetails) -> String {
221    format_message(details)
222}
223
224/// Throws (returns `Err`) when the line reference is out of bounds for the
225/// given file.
226pub fn validate_line_ref(line: u32, file_lines: &[String]) -> Result<(), String> {
227    if line < 1 || (line as usize) > file_lines.len() {
228        return Err(format!(
229            "Line {line} does not exist (file has {} lines)",
230            file_lines.len()
231        ));
232    }
233    Ok(())
234}
235
236/// Format the required-shape diagnostic shown when a line reference is malformed.
237pub fn format_full_anchor_requirement(raw: Option<&str>) -> String {
238    let received = match raw {
239        Some(r) => format!(" Received {r:?}."),
240        None => String::new(),
241    };
242    format!(
243        "a bare line number from read/search output plus the section header content-hash tag \
244         (for example {pfx}src/foo.ts{sep}{ex}{sfx} and line \"160\"){received}",
245        pfx = HL_FILE_PREFIX,
246        sep = HL_FILE_HASH_SEP,
247        ex = EXAMPLE_HASH,
248        sfx = HL_FILE_SUFFIX,
249    )
250}
251
252/// Parse a decorated bare line-number anchor like `42`, `*42:foo`, `> 7`.
253///
254/// Equivalent to omp's `parseTag` regex `/^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/`.
255/// Returns the parsed 1-indexed line number, or an error whose message is the
256/// required-shape diagnostic.
257pub fn parse_tag(reference: &str) -> Result<u32, String> {
258    match try_parse_line_ref(reference) {
259        Some(line) if line >= 1 => Ok(line),
260        Some(line) => Err(format!(
261            "Line number must be >= 1, got {line} in {reference:?}."
262        )),
263        None => Err(format!(
264            "Invalid line reference. Expected {}. Expected {}.",
265            reference,
266            format_full_anchor_requirement(Some(reference))
267        )),
268    }
269}
270
271/// Inner worker for [`parse_tag`]: matches omp's `LINE_REF_RE`.
272fn try_parse_line_ref(reference: &str) -> Option<u32> {
273    let s = reference.trim_start();
274    let bytes = s.as_bytes();
275    // Skip optional leading decorators [> + - *].
276    let mut i = 0;
277    while i < bytes.len() && matches!(bytes[i], b'>' | b'+' | b'-' | b'*') {
278        i += 1;
279    }
280    let rest = &s[i..];
281    let rest = rest.trim_start();
282    // A run of ASCII digits is the line number.
283    let digit_len = rest.bytes().take_while(|b| b.is_ascii_digit()).count();
284    if digit_len == 0 {
285        return None;
286    }
287    let line: u32 = rest[..digit_len].parse().ok()?;
288    // After the digits: optional `:…`, then trailing whitespace only.
289    let tail = rest[digit_len..].trim_end();
290    if tail.is_empty() || tail.starts_with(':') {
291        Some(line)
292    } else {
293        None
294    }
295}
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    fn details(expected: &str, actual: &str, recognized: bool) -> MismatchDetails {
301        MismatchDetails {
302            path: Some("src/foo.rs".to_string()),
303            expected_file_hash: expected.to_string(),
304            actual_file_hash: actual.to_string(),
305            file_lines: vec!["a".into(), "b".into(), "c".into(), "d".into(), "e".into()],
306            anchor_lines: vec![2],
307            hash_recognized: recognized,
308        }
309    }
310
311    #[test]
312    fn recognized_mismatch_renders_header_and_context() {
313        let d = details("AAAA", "BBBB", true);
314        let err = MismatchError::new(d);
315        assert!(err.message.contains("file changed between read and edit"));
316        assert!(err.message.contains("#AAAA"));
317        assert!(err.message.contains("#BBBB"));
318        // Anchored context is separated from the header by a blank line; the
319        // anchor line itself appears within the context window.
320        assert!(err.message.contains("\n\n"));
321        assert!(
322            err.message.contains("*2:b"),
323            "anchored line 2 appears in context"
324        );
325        // Structured fields are preserved.
326        assert_eq!(err.details.expected_file_hash, "AAAA");
327        assert_eq!(err.details.actual_file_hash, "BBBB");
328        assert!(err.details.hash_recognized);
329    }
330
331    #[test]
332    fn unrecognized_mismatch_uses_fabrication_message() {
333        let d = details("AAAA", "BBBB", false);
334        let msg = format_message(&d);
335        assert!(msg.contains("is not from this session"));
336        assert!(msg.contains("never invent the tag"));
337    }
338
339    #[test]
340    fn no_context_when_file_lines_absent() {
341        let d = MismatchDetails {
342            path: None,
343            expected_file_hash: "AAAA".into(),
344            actual_file_hash: "BBBB".into(),
345            file_lines: Vec::new(),
346            anchor_lines: Vec::new(),
347            hash_recognized: true,
348        };
349        let msg = format_message(&d);
350        assert!(!msg.contains("\n\n"));
351        assert!(msg.contains("Edit rejected"));
352    }
353
354    #[test]
355    fn validate_line_ref_bounds() {
356        let file: Vec<String> = vec!["x".into(), "y".into()];
357        assert!(validate_line_ref(1, &file).is_ok());
358        assert!(validate_line_ref(2, &file).is_ok());
359        assert!(validate_line_ref(0, &file).is_err());
360        assert!(validate_line_ref(3, &file).is_err());
361    }
362
363    #[test]
364    fn parse_tag_accepts_decorated_refs() {
365        assert_eq!(parse_tag("42").unwrap(), 42);
366        assert_eq!(parse_tag("  *42:foo").unwrap(), 42);
367        assert_eq!(parse_tag(" > 7").unwrap(), 7);
368        assert_eq!(parse_tag("160:some content").unwrap(), 160);
369    }
370
371    #[test]
372    fn parse_tag_rejects_garbage() {
373        assert!(parse_tag("not a line").is_err());
374        assert!(parse_tag(":42").is_err());
375        assert!(parse_tag("").is_err());
376        // Non-decorator, non-digit content after the number fails.
377        assert!(parse_tag("42 extra").is_err());
378    }
379
380    #[test]
381    fn mismatch_error_implements_std_error() {
382        let err = MismatchError::new(details("AAAA", "BBBB", true));
383        // Ensures the thiserror `Error` + `Display` derive is wired.
384        let _: &dyn std::error::Error = &err;
385        assert_eq!(err.display_message(), format!("{}", err));
386    }
387
388    #[test]
389    fn format_full_anchor_requirement_includes_example() {
390        let req = format_full_anchor_requirement(None);
391        assert!(req.contains("[src/foo.ts#1A2B]"));
392        assert!(req.contains("\"160\""));
393        let req_with = format_full_anchor_requirement(Some("xyz"));
394        assert!(req_with.contains("Received \"xyz\"."));
395    }
396}