Skip to main content

oxicode_hashline/
messages.rs

1//! Centralized error/warning text for the hashline parser, applier, and patcher.
2//!
3//! Ported from omp `packages/hashline/src/messages.ts`. Message text is kept
4//! close to omp's so behaviour and diagnostics line up.
5
6use std::collections::{BTreeSet, HashSet};
7
8use crate::format::{
9    HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX, HL_RANGE_SEP, format_numbered_line,
10};
11
12/// Lines of context shown either side of a hash mismatch.
13pub const MISMATCH_CONTEXT: usize = 2;
14
15// ── Optional patch envelope markers ──────────────────────────────────────
16
17/// Optional patch envelope start marker; silently consumed.
18pub const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
19
20/// Optional patch envelope end marker; terminates parsing.
21pub const END_PATCH_MARKER: &str = "*** End Patch";
22
23/// Truncation sentinel emitted by an agent loop mid-call. Ends parsing like
24/// [`END_PATCH_MARKER`], without a warning.
25pub const ABORT_MARKER: &str = "*** Abort";
26
27// ── Warning messages ─────────────────────────────────────────────────────
28
29/// Two consecutive hunks targeted the exact same concrete range.
30pub const REPLACE_PAIR_COALESCED_WARNING: &str = "Two hunks targeted the same range; kept only the second. One `SWAP N.=M:` hunk per range — the body is the final content, never old+new.";
31
32/// Bare bodyless hunk followed by an overlapping concrete hunk.
33pub const BARE_BODY_OVERLAPPED_WARNING: &str = "Dropped a bare hunk overlapped by the concrete hunk after it. One `SWAP N.=M:` hunk per range — the body is the final content, never old+new.";
34
35/// Bare body rows auto-converted to literal `+` rows.
36pub const BARE_BODY_AUTO_PIPED_WARNING: &str =
37    "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
38
39/// Unified-diff-style `-` row in a hunk body.
40pub const MINUS_ROW_REJECTED: &str = "`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-…`.";
41
42/// Block-anchored edit reached a path with no block resolver wired in.
43pub const BLOCK_RESOLVER_UNAVAILABLE: &str = "`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
44
45/// Internal invariant: an unresolved `SWAP.BLK` edit reached the applier.
46pub const UNRESOLVED_BLOCK_INTERNAL: &str = "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
47
48/// `Recovery`: an external write matched a cached snapshot.
49pub const RECOVERY_EXTERNAL_WARNING: &str = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
50
51/// `Recovery`: a prior in-session edit advanced the hash.
52pub const RECOVERY_SESSION_CHAIN_WARNING: &str = "Recovered from a stale file hash using an earlier in-session snapshot (a prior edit in this session advanced the hash).";
53
54/// `Recovery`: session-chain replay fast-path (verify hedge).
55pub const RECOVERY_SESSION_REPLAY_WARNING: &str = "Recovered by replaying your edits onto the current file content (a prior in-session edit changed the lines you re-targeted with a stale hash). Verify the diff matches your intent.";
56
57/// `INS.HEAD:`/`INS.TAIL:` applied despite a stale snapshot tag.
58pub const HEADTAIL_DRIFT_WARNING: &str = "Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected.";
59
60// ── Error messages ───────────────────────────────────────────────────────
61
62/// Replace hunk with no body.
63pub const EMPTY_REPLACE: &str =
64    "`SWAP N.=M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N.=M`.";
65
66/// `SWAP.BLK N:` hunk with no body.
67pub const EMPTY_BLOCK: &str =
68    "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
69
70/// Delete hunk received a body row.
71pub const DELETE_TAKES_NO_BODY: &str =
72    "`DEL N.=M` does not take body rows. Remove the body, or use `SWAP N.=M:`.";
73
74/// `DEL.BLK N` hunk received a body row.
75pub const DELETE_BLOCK_TAKES_NO_BODY: &str =
76    "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
77
78/// Insert hunk with no body.
79pub const EMPTY_INSERT: &str = "`INS` needs at least one `+TEXT` body row.";
80
81// ── Op kinds for block messages ──────────────────────────────────────────
82
83/// Op kind of a deferred block edit, for [`block_unresolved_message`] and
84/// [`block_single_line_message`].
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum BlockOpKind {
87    /// Block replacement (`SWAP.BLK`).
88    Replace,
89    /// Block deletion (`DEL.BLK`).
90    Delete,
91    /// Insert immediately after a block (`INS.BLK.POST`).
92    InsertAfter,
93}
94
95// ── Message-builder functions ────────────────────────────────────────────
96
97/// Numbered `LINE:TEXT` rows around `anchor_lines` (± [`MISMATCH_CONTEXT`]),
98/// `*`-marking anchors, `...` between non-adjacent runs. Out-of-range anchors
99/// contribute no rows.
100pub fn format_anchored_context(anchor_lines: &[u32], file_lines: &[String]) -> Vec<String> {
101    let len = file_lines.len() as u32;
102    let mut display: BTreeSet<u32> = BTreeSet::new();
103    for &line in anchor_lines {
104        if line < 1 || line > len {
105            continue;
106        }
107        let lo = (line as usize).saturating_sub(MISMATCH_CONTEXT).max(1) as u32;
108        let hi = line + MISMATCH_CONTEXT as u32;
109        let hi = hi.min(len);
110        for n in lo..=hi {
111            display.insert(n);
112        }
113    }
114
115    let anchor_set: HashSet<u32> = anchor_lines.iter().copied().collect();
116    let mut rows: Vec<String> = Vec::new();
117    let mut previous: i64 = -1;
118    for &line_num in display.iter() {
119        // BTreeSet iterates ascending; emit a gap when runs are non-adjacent.
120        if previous != -1 && (line_num as i64) > previous + 1 {
121            rows.push("...".to_string());
122        }
123        previous = line_num as i64;
124        let marker = if anchor_set.contains(&line_num) {
125            "*"
126        } else {
127            " "
128        };
129        let text = file_lines
130            .get((line_num - 1) as usize)
131            .map(String::as_str)
132            .unwrap_or("");
133        rows.push(format!("{marker}{}", format_numbered_line(line_num, text)));
134    }
135    rows
136}
137
138/// `SWAP.BLK`/`DEL.BLK` could not resolve to a syntactic block. Appends a
139/// [`format_anchored_context`] preview when `file_lines` is given.
140pub fn block_unresolved_message(
141    line: u32,
142    op: BlockOpKind,
143    file_lines: Option<&[String]>,
144) -> String {
145    let is_delete = op == BlockOpKind::Delete;
146    let phrase = if is_delete {
147        format!("DEL.BLK {line}")
148    } else {
149        format!("SWAP.BLK {line}:")
150    };
151    let fallback = if is_delete {
152        format!("DEL {line}{HL_RANGE_SEP}M")
153    } else {
154        format!("SWAP {line}{HL_RANGE_SEP}M:")
155    };
156    let mut message = format!(
157        "`{phrase}` could not resolve a syntactic block beginning on line {line} \
158         (unsupported language, blank/closer line, or parse error). Use `{fallback}` with explicit lines."
159    );
160    if let Some(lines) = file_lines {
161        let context = format_anchored_context(&[line], lines);
162        if !context.is_empty() {
163            message.push_str("\n\n");
164            message.push_str(&context.join("\n"));
165        }
166    }
167    message
168}
169
170/// `INS.BLK.POST N:` anchored on a closing-delimiter line, lowered to plain
171/// `INS.POST N:`.
172pub fn insert_after_block_closer_lowered_warning(line: u32) -> String {
173    format!(
174        "`INS.BLK.POST {line}:` anchors on a closing delimiter, so it was applied as plain \
175         `INS.POST {line}:`. Anchor on the line that OPENS the construct."
176    )
177}
178
179/// `INS.BLK.POST N:` anchor unresolvable, lowered to plain `INS.POST N:`.
180pub fn insert_after_block_unresolved_lowered_warning(line: u32) -> String {
181    format!(
182        "`INS.BLK.POST {line}:` could not resolve a syntactic block on line {line}, so it was \
183         applied as plain `INS.POST {line}:`. Verify the landing line; anchor on a line that \
184         OPENS a construct."
185    )
186}
187
188/// `INS.POST N:` body indented shallower than the anchor: the landing slid
189/// forward past trailing closer lines.
190pub fn after_insert_landing_shift_warning(
191    anchor_line: u32,
192    landing_line: u32,
193    crossed: u32,
194) -> String {
195    let plural = if crossed == 1 { "" } else { "s" };
196    format!(
197        "INS.POST {anchor_line}: body indented shallower than the anchor, so the landing moved \
198         past {crossed} closing line{plural} to after line {landing_line}. For the deeper position \
199         inside the block, re-issue with the body indented to match."
200    )
201}
202
203/// `INS.BLK.POST N:` body indented deeper than the block's closer: the landing
204/// was pulled inside the block.
205pub fn block_insert_landing_shift_warning(
206    block_start: u32,
207    closer_line: u32,
208    landing_line: u32,
209) -> String {
210    format!(
211        "INS.BLK.POST {block_start}: body indented deeper than closing line {closer_line}, so it \
212         was placed inside the block, after line {landing_line}. `INS.BLK.POST` lands AFTER the \
213         block at sibling depth — if inside was intended, use plain `INS.POST {closer_line}:`."
214    )
215}
216
217/// Section omitted the mandatory snapshot tag.
218pub fn missing_snapshot_tag_message(section_path: &str) -> String {
219    format!(
220        "Missing hashline snapshot tag for {section_path}; use \
221         `{pfx}{section_path}{sep}tag{sfx}` from your latest read/search output. To create a new \
222         file, use the write tool.",
223        pfx = HL_FILE_PREFIX,
224        sep = HL_FILE_HASH_SEP,
225        sfx = HL_FILE_SUFFIX,
226    )
227}
228
229/// An anchored edit referenced lines the read that minted `tag` never displayed.
230pub fn unseen_lines_message(section_path: &str, unseen_lines: &[u32], tag: &str) -> String {
231    let ranges = format_line_ranges(unseen_lines);
232    let selector = ranges.replace(", ", ",");
233    format!(
234        "This edit anchors to lines {ranges} of {section_path} that \
235         {pfx}{section_path}{sep}{tag}{sfx} never displayed (it showed a partial range, a search \
236         hit, or a folded summary). Re-read them in full first with a ranged read like \
237         `{section_path}:{selector}` — it skips summarization and mints a fresh tag (a plain \
238         re-read just re-folds them) — then re-issue the edit.",
239        pfx = HL_FILE_PREFIX,
240        sep = HL_FILE_HASH_SEP,
241        sfx = HL_FILE_SUFFIX,
242    )
243}
244
245/// A block-anchored op resolved to a single line — the plain op is unambiguous
246/// for one line.
247pub fn block_single_line_message(line: u32, op: BlockOpKind) -> String {
248    let block_form = match op {
249        BlockOpKind::InsertAfter => "INS.BLK.POST",
250        BlockOpKind::Delete => "DEL.BLK",
251        BlockOpKind::Replace => "SWAP.BLK",
252    };
253    let plain_form = match op {
254        BlockOpKind::InsertAfter => format!("INS.POST {line}:"),
255        BlockOpKind::Delete => format!("DEL {line}"),
256        BlockOpKind::Replace => format!("SWAP {line}{HL_RANGE_SEP}{line}:"),
257    };
258    format!(
259        "`{block_form} {line}` resolved a single-line block — line {line} is a bare statement, \
260         not the opening line of a multi-line construct. For that one line use `{plain_form}`; \
261         to act on an enclosing construct, anchor {block_form} on the line that OPENS it \
262         (e.g. its `function`/`if`/`case` header), never a statement inside it."
263    )
264}
265
266/// Format a comma-separated list of example anchors with an optional
267/// line-number prefix, quoted for inclusion in error messages:
268/// `"160", "42", "7"` (no prefix) or `"119", "112", "7"` (prefix `"119"`).
269///
270/// Ported from omp `format.ts:describeAnchorExamples`.
271pub fn describe_anchor_examples(line_prefix: &str) -> String {
272    let examples: Vec<String> = if line_prefix.is_empty() {
273        ["160", "42", "7"]
274            .iter()
275            .map(|s| (*s).to_string())
276            .collect()
277    } else {
278        let stem = &line_prefix[..line_prefix.len().saturating_sub(1)];
279        let stem = if stem.is_empty() { "4" } else { stem };
280        vec![line_prefix.to_string(), format!("{stem}2"), "7".to_string()]
281    };
282    examples
283        .iter()
284        .map(|e| format!("\"{e}\""))
285        .collect::<Vec<_>>()
286        .join(", ")
287}
288
289/// Compress a line list into a sorted `1-4, 7, 10-12` range string.
290fn format_line_ranges(lines: &[u32]) -> String {
291    let mut sorted: Vec<u32> = lines.to_vec();
292    sorted.sort_unstable();
293    sorted.dedup();
294    if sorted.is_empty() {
295        return String::new();
296    }
297    let mut parts: Vec<String> = Vec::new();
298    let mut start = sorted[0];
299    let mut prev = sorted[0];
300    for &current in &sorted[1..] {
301        if current == prev + 1 {
302            prev = current;
303            continue;
304        }
305        parts.push(run_range(start, prev));
306        start = current;
307        prev = current;
308    }
309    parts.push(run_range(start, prev));
310    parts.join(", ")
311}
312
313/// Format a single run `[start..=prev]` as `start` or `start-prev`.
314fn run_range(start: u32, prev: u32) -> String {
315    if start == prev {
316        start.to_string()
317    } else {
318        format!("{start}-{prev}")
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn anchored_context_marks_and_gaps() {
328        let file: Vec<String> = (1..=10).map(|n| format!("L{n}")).collect();
329        // Anchor at 3 and 8: windows [1..5] and [6..10], contiguous (5,6) so no gap.
330        let rows = format_anchored_context(&[3, 8], &file);
331        assert!(rows.iter().any(|r| r.starts_with("*3:L3")));
332        assert!(rows.iter().any(|r| r.starts_with("*8:L8")));
333        assert!(
334            !rows.iter().any(|r| r == "..."),
335            "adjacent windows should not produce a gap"
336        );
337
338        // Non-adjacent anchors produce a gap row.
339        let rows = format_anchored_context(&[1, 9], &file);
340        assert!(rows.iter().any(|r| r == "..."), "non-adjacent windows gap");
341    }
342
343    #[test]
344    fn anchored_context_skips_out_of_range() {
345        let file: Vec<String> = vec!["only".to_string()];
346        let rows = format_anchored_context(&[0, 1, 99], &file);
347        // Only line 1 (in range) contributes.
348        assert_eq!(rows.len(), 1);
349        assert!(rows[0].starts_with("*1:only"));
350    }
351
352    #[test]
353    fn format_line_ranges_compresses() {
354        assert_eq!(format_line_ranges(&[]), "");
355        assert_eq!(format_line_ranges(&[1, 2, 3, 4]), "1-4");
356        assert_eq!(format_line_ranges(&[1, 3, 5]), "1, 3, 5");
357        assert_eq!(format_line_ranges(&[10, 11, 12, 7]), "7, 10-12");
358        // Duplicates collapse and order is normalized.
359        assert_eq!(format_line_ranges(&[3, 3, 1, 2]), "1-3");
360    }
361
362    #[test]
363    fn unseen_lines_message_renders_ranges() {
364        let msg = unseen_lines_message("src/a.rs", &[1, 2, 3, 7], "ABCD");
365        assert!(msg.contains("lines 1-3, 7 of src/a.rs"));
366        assert!(msg.contains("[src/a.rs#ABCD]"));
367        assert!(msg.contains("`src/a.rs:1-3,7`"));
368    }
369
370    #[test]
371    fn missing_tag_message_renders_header_hint() {
372        let msg = missing_snapshot_tag_message("src/a.rs");
373        assert!(msg.contains("Missing hashline snapshot tag for src/a.rs"));
374        assert!(msg.contains("`[src/a.rs#tag]`"));
375    }
376
377    #[test]
378    fn block_unresolved_appends_context() {
379        let file: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
380        let msg = block_unresolved_message(2, BlockOpKind::Replace, Some(&file));
381        assert!(msg.contains("`SWAP.BLK 2:`"));
382        assert!(msg.contains("Use `SWAP 2.=M:`"));
383        assert!(msg.contains("\n\n"));
384    }
385
386    #[test]
387    fn block_unresolved_delete_form() {
388        let msg = block_unresolved_message(5, BlockOpKind::Delete, None);
389        assert!(msg.contains("`DEL.BLK 5`"));
390        assert!(msg.contains("Use `DEL 5.=M`"));
391    }
392
393    #[test]
394    fn after_insert_landing_pluralizes() {
395        assert!(
396            !after_insert_landing_shift_warning(1, 3, 1).contains("closing lines"),
397            "singular crossing"
398        );
399        assert!(
400            after_insert_landing_shift_warning(1, 3, 2).contains("closing lines"),
401            "plural crossing"
402        );
403    }
404
405    #[test]
406    fn block_single_line_message_forms() {
407        let m = block_single_line_message(4, BlockOpKind::Replace);
408        assert!(m.contains("`SWAP.BLK 4`"));
409        assert!(m.contains("use `SWAP 4.=4:`"));
410        let m = block_single_line_message(4, BlockOpKind::Delete);
411        assert!(m.contains("use `DEL 4`"));
412        let m = block_single_line_message(4, BlockOpKind::InsertAfter);
413        assert!(m.contains("use `INS.POST 4:`"));
414    }
415
416    #[test]
417    fn marker_constants_are_stable() {
418        assert_eq!(BEGIN_PATCH_MARKER, "*** Begin Patch");
419        assert_eq!(END_PATCH_MARKER, "*** End Patch");
420        assert_eq!(ABORT_MARKER, "*** Abort");
421        assert_eq!(MISMATCH_CONTEXT, 2);
422    }
423}