1use crate::format::{HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX};
11use crate::messages::format_anchored_context;
12
13const EXAMPLE_HASH: &str = "1A2B";
17
18#[derive(Debug, Clone)]
22pub struct MismatchDetails {
23 pub path: Option<String>,
25 pub expected_file_hash: String,
27 pub actual_file_hash: String,
29 pub file_lines: Vec<String>,
31 pub anchor_lines: Vec<u32>,
33 pub hash_recognized: bool,
38}
39
40impl Default for MismatchDetails {
41 fn default() -> Self {
42 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#[derive(Debug, Clone, thiserror::Error)]
61#[error("{message}")]
62pub struct MismatchError {
63 pub message: String,
65 pub details: MismatchDetails,
67}
68
69impl MismatchError {
70 pub fn new(details: MismatchDetails) -> Self {
72 let message = format_message(&details);
73 Self { message, details }
74 }
75
76 pub fn display_message(&self) -> &str {
79 &self.message
80 }
81}
82
83#[derive(Debug, thiserror::Error)]
91pub enum HashlineError {
92 #[error("Parse error at line {line}: {msg}")]
94 Parse {
95 line: u32,
97 msg: String,
99 },
100 #[error("File not found: {path}. Use the write tool to create new files.")]
102 NotFound {
103 path: String,
105 },
106 #[error("{0}")]
108 MissingSnapshotTag(String),
109 #[error("{0}")]
111 UnseenLines(String),
112 #[error("{detail}")]
114 Mismatch {
115 detail: String,
117 expected: String,
119 actual: String,
121 },
122 #[error("Multiple sections resolve to {path}")]
124 DuplicateCanonicalPath {
125 path: String,
127 },
128 #[error("Edits to {path} resulted in no changes")]
130 NoOp {
131 path: String,
133 },
134 #[error("Line {line} does not exist (file has {total} lines)")]
136 LineOutOfBounds {
137 line: u32,
139 total: usize,
141 },
142 #[error("IO error: {0}")]
144 Io(#[from] std::io::Error),
145 #[cfg(feature = "block-ops")]
147 #[error("Block resolver unavailable for {path}")]
148 BlockResolverUnavailable {
149 path: String,
151 },
152}
153
154impl HashlineError {
155 pub fn parse(line: u32, msg: impl Into<String>) -> Self {
157 HashlineError::Parse {
158 line,
159 msg: msg.into(),
160 }
161 }
162}
163
164pub 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
208pub 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
222pub fn format_display_message(details: &MismatchDetails) -> String {
224 format_message(details)
225}
226
227pub 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
239pub 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
255pub 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
274fn try_parse_line_ref(reference: &str) -> Option<u32> {
276 let s = reference.trim_start();
277 let bytes = s.as_bytes();
278 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 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 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 assert!(err.message.contains("\n\n"));
324 assert!(
325 err.message.contains("*2:b"),
326 "anchored line 2 appears in context"
327 );
328 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 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 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}