1use 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
12pub const MISMATCH_CONTEXT: usize = 2;
14
15pub const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
19
20pub const END_PATCH_MARKER: &str = "*** End Patch";
22
23pub const ABORT_MARKER: &str = "*** Abort";
26
27pub 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
32pub 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
35pub const BARE_BODY_AUTO_PIPED_WARNING: &str =
37 "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
38
39pub const MINUS_ROW_REJECTED: &str = "`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-…`.";
41
42pub 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
45pub const UNRESOLVED_BLOCK_INTERNAL: &str = "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
47
48pub const RECOVERY_EXTERNAL_WARNING: &str = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
50
51pub 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
54pub 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
57pub 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
60pub const EMPTY_REPLACE: &str =
64 "`SWAP N.=M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N.=M`.";
65
66pub const EMPTY_BLOCK: &str =
68 "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
69
70pub const DELETE_TAKES_NO_BODY: &str =
72 "`DEL N.=M` does not take body rows. Remove the body, or use `SWAP N.=M:`.";
73
74pub 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
78pub const EMPTY_INSERT: &str = "`INS` needs at least one `+TEXT` body row.";
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum BlockOpKind {
87 Replace,
89 Delete,
91 InsertAfter,
93}
94
95pub 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 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
138pub 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
170pub 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
179pub 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
188pub 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
203pub 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
217pub 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
229pub 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
245pub 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
266pub 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
289fn 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 ¤t 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
313fn 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 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 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 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 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}