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 { path: String },
149}
150
151impl HashlineError {
152 pub fn parse(line: u32, msg: impl Into<String>) -> Self {
154 HashlineError::Parse {
155 line,
156 msg: msg.into(),
157 }
158 }
159}
160
161pub 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
205pub 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
219pub fn format_display_message(details: &MismatchDetails) -> String {
221 format_message(details)
222}
223
224pub 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
236pub 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
252pub 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
271fn try_parse_line_ref(reference: &str) -> Option<u32> {
273 let s = reference.trim_start();
274 let bytes = s.as_bytes();
275 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 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 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 assert!(err.message.contains("\n\n"));
321 assert!(
322 err.message.contains("*2:b"),
323 "anchored line 2 appears in context"
324 );
325 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 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 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}