1use crate::types::{EditOverwriteOutput, EditReplaceOutput};
6use std::borrow::Cow;
7use std::path::{Path, PathBuf};
8use tempfile::NamedTempFile;
9use thiserror::Error;
10
11#[non_exhaustive]
12#[derive(Debug, Error)]
13pub enum EditError {
14 #[error("I/O error: {0}")]
15 Io(#[from] std::io::Error),
16 #[error("invalid range: start ({start}) > end ({end}); file has {total} lines")]
17 InvalidRange {
18 start: usize,
19 end: usize,
20 total: usize,
21 },
22 #[error("path is a directory, not a file: {0}")]
23 NotAFile(PathBuf),
24 #[error(
25 "old_text not found in {path} — verify the text matches exactly, including whitespace and newlines"
26 )]
27 NotFound {
28 path: String,
29 first_20_lines: String,
30 },
31 #[error(
32 "old_text appears {count} times in {path} — make old_text longer and more specific to uniquely identify the block"
33 )]
34 Ambiguous {
35 count: usize,
36 path: String,
37 match_lines: Vec<usize>,
38 },
39 #[error("edit_replace invalid params: {0}")]
40 InvalidParams(String),
41 #[error(
42 "stale content hash for {path}: expected {expected} but file has {actual} — re-read the file with analyze_file or analyze_module, then retry with the current content hash"
43 )]
44 StaleContentHash {
45 expected: String,
46 actual: String,
47 path: String,
48 },
49}
50
51fn write_file_atomic(path: &Path, content: &str) -> Result<(), EditError> {
52 let parent = path.parent().ok_or_else(|| {
53 EditError::Io(std::io::Error::new(
54 std::io::ErrorKind::InvalidInput,
55 "path has no parent directory",
56 ))
57 })?;
58 let mut temp_file = NamedTempFile::new_in(parent)?;
59 use std::io::Write;
60 temp_file.write_all(content.as_bytes())?;
61 temp_file.persist(path).map_err(|e| e.error)?;
62 Ok(())
63}
64
65fn normalize_for_match(s: &str) -> Cow<'_, str> {
71 if !s.as_bytes().contains(&b'\r') {
72 Cow::Borrowed(s)
73 } else {
74 Cow::Owned(s.replace("\r\n", "\n"))
75 }
76}
77
78fn build_crlf_positions(original: &str) -> Vec<usize> {
84 let bytes = original.as_bytes();
85 let mut positions = Vec::new();
86 let mut norm_pos = 0usize;
87 let mut i = 0usize;
88 while i < bytes.len() {
89 if bytes[i] == b'\r' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
90 positions.push(norm_pos);
91 norm_pos += 1;
92 i += 2;
93 } else {
94 norm_pos += 1;
95 i += 1;
96 }
97 }
98 positions
99}
100
101fn norm_to_original_offset(norm_offset: usize, crlf_positions: &[usize]) -> usize {
108 if crlf_positions.is_empty() {
109 norm_offset
110 } else {
111 norm_offset + crlf_positions.partition_point(|&x| x < norm_offset)
112 }
113}
114
115pub fn edit_overwrite_content(
116 path: &Path,
117 content: &str,
118) -> Result<EditOverwriteOutput, EditError> {
119 if path.is_dir() {
120 return Err(EditError::NotAFile(path.to_path_buf()));
121 }
122 if let Some(parent) = path.parent()
123 && !parent.as_os_str().is_empty()
124 {
125 std::fs::create_dir_all(parent)?;
126 }
127 write_file_atomic(path, content)?;
128 Ok(EditOverwriteOutput {
129 path: path.display().to_string(),
130 bytes_written: content.len(),
131 })
132}
133
134pub fn edit_replace_block(
135 path: &Path,
136 old_text: &str,
137 new_text: &str,
138) -> Result<EditReplaceOutput, EditError> {
139 edit_replace_block_inner(path, old_text, new_text, false, None)
140}
141
142pub fn edit_replace_block_with_options(
151 path: &Path,
152 old_text: &str,
153 new_text: &str,
154 replace_all: bool,
155 expected_content_hash: Option<&str>,
156) -> Result<EditReplaceOutput, EditError> {
157 edit_replace_block_inner(path, old_text, new_text, replace_all, expected_content_hash)
158}
159
160pub(crate) fn edit_replace_block_inner(
161 path: &Path,
162 old_text: &str,
163 new_text: &str,
164 replace_all: bool,
165 expected_content_hash: Option<&str>,
166) -> Result<EditReplaceOutput, EditError> {
167 if path.is_dir() {
168 return Err(EditError::NotAFile(path.to_path_buf()));
169 }
170 let content = std::fs::read_to_string(path)?;
171
172 if let Some(expected_hash) = expected_content_hash {
176 let actual_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
177 if actual_hash != expected_hash {
178 return Err(EditError::StaleContentHash {
179 expected: expected_hash.to_string(),
180 actual: actual_hash,
181 path: path.display().to_string(),
182 });
183 }
184 }
185
186 let norm_content = normalize_for_match(&content);
187 let norm_old = normalize_for_match(old_text);
188 if norm_old.is_empty() {
189 return Err(EditError::InvalidParams(
190 "old_text must not be empty".to_string(),
191 ));
192 }
193 let crlf_positions = build_crlf_positions(&content);
196 let count = norm_content.matches(norm_old.as_ref()).count();
197 match count {
198 0 => {
199 let first_20_lines = content.lines().take(20).collect::<Vec<_>>().join("\n");
200 return Err(EditError::NotFound {
201 path: path.display().to_string(),
202 first_20_lines,
203 });
204 }
205 1 if !replace_all => {}
206 n if !replace_all => {
207 let match_lines: Vec<usize> = norm_content
208 .match_indices(norm_old.as_ref())
209 .map(|(offset, _)| {
210 norm_content[..offset]
211 .bytes()
212 .filter(|&b| b == b'\n')
213 .count()
214 + 1
215 })
216 .collect();
217 return Err(EditError::Ambiguous {
218 count: n,
219 path: path.display().to_string(),
220 match_lines,
221 });
222 }
223 _ => {} }
225 let bytes_before = content.len();
226
227 if replace_all {
228 let mut matches: Vec<(usize, usize)> = Vec::new();
231 for (norm_start, _m) in norm_content.match_indices(norm_old.as_ref()) {
232 let original_start = norm_to_original_offset(norm_start, &crlf_positions);
233 let original_end =
234 norm_to_original_offset(norm_start + norm_old.len(), &crlf_positions);
235 matches.push((original_start, original_end));
236 }
237 let occurrences_replaced = matches.len();
238 let old_span_total: usize = matches.iter().map(|(s, e)| e - s).sum();
239 let mut result = String::with_capacity(
242 bytes_before + new_text.len().saturating_mul(occurrences_replaced) - old_span_total,
243 );
244 let mut last_end = 0usize;
245 for (start, end) in &matches {
246 result.push_str(&content[last_end..*start]);
247 result.push_str(new_text);
248 last_end = *end;
249 }
250 result.push_str(&content[last_end..]);
251 let bytes_after = result.len();
252 write_file_atomic(path, &result)?;
253 Ok(EditReplaceOutput {
254 path: path.display().to_string(),
255 bytes_before,
256 bytes_after,
257 occurrences_replaced,
258 })
259 } else {
260 #[allow(clippy::expect_used)]
264 let norm_match_offset = norm_content
265 .find(norm_old.as_ref())
266 .expect("match was verified above via count check; find must succeed");
267 let original_start = norm_to_original_offset(norm_match_offset, &crlf_positions);
268 let original_end =
269 norm_to_original_offset(norm_match_offset + norm_old.len(), &crlf_positions);
270 let updated = [
271 &content[..original_start],
272 new_text,
273 &content[original_end..],
274 ]
275 .concat();
276 let bytes_after = updated.len();
277 write_file_atomic(path, &updated)?;
278 Ok(EditReplaceOutput {
279 path: path.display().to_string(),
280 bytes_before,
281 bytes_after,
282 occurrences_replaced: 1,
283 })
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn edit_overwrite_content_creates_new_file() {
293 let dir = tempfile::tempdir().unwrap();
294 let path = dir.path().join("new.txt");
295 let result = edit_overwrite_content(&path, "hello world").unwrap();
296 assert_eq!(result.bytes_written, 11);
297 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
298 }
299
300 #[test]
301 fn edit_overwrite_content_overwrites_existing() {
302 let dir = tempfile::tempdir().unwrap();
303 let path = dir.path().join("existing.txt");
304 std::fs::write(&path, "old content").unwrap();
305 let result = edit_overwrite_content(&path, "new content").unwrap();
306 assert_eq!(result.bytes_written, 11);
307 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
308 }
309
310 #[test]
311 fn edit_overwrite_content_creates_parent_dirs() {
312 let dir = tempfile::tempdir().unwrap();
313 let path = dir.path().join("a").join("b").join("c.txt");
314 let result = edit_overwrite_content(&path, "nested").unwrap();
315 assert_eq!(result.bytes_written, 6);
316 assert!(path.exists());
317 }
318
319 #[test]
320 fn edit_overwrite_content_directory_guard() {
321 let dir = tempfile::tempdir().unwrap();
322 let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
323 std::assert_matches!(err, EditError::NotAFile(_));
324 }
325
326 #[test]
327 fn edit_replace_block_happy_path() {
328 let dir = tempfile::tempdir().unwrap();
329 let path = dir.path().join("file.txt");
330 std::fs::write(&path, "foo bar baz").unwrap();
331 let result = edit_replace_block(&path, "bar", "qux").unwrap();
332 assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
333 assert_eq!(result.bytes_before, 11);
334 assert_eq!(result.bytes_after, 11);
335 }
336
337 #[test]
338 fn edit_replace_block_not_found() {
339 let dir = tempfile::tempdir().unwrap();
340 let path = dir.path().join("file.txt");
341 std::fs::write(&path, "foo bar baz").unwrap();
342 let err = edit_replace_block(&path, "missing", "x").unwrap_err();
343 std::assert_matches!(&err, EditError::NotFound { first_20_lines, .. } if !first_20_lines.is_empty());
344 }
345
346 #[test]
347 fn edit_replace_block_ambiguous() {
348 let dir = tempfile::tempdir().unwrap();
349 let path = dir.path().join("file.txt");
350 std::fs::write(&path, "foo foo baz").unwrap();
351 let err = edit_replace_block(&path, "foo", "x").unwrap_err();
352 std::assert_matches!(&err, EditError::Ambiguous { count: 2, match_lines, .. } if match_lines == &[1, 1]);
353 }
354
355 #[test]
356 fn edit_replace_block_directory_guard() {
357 let dir = tempfile::tempdir().unwrap();
358 let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
359 std::assert_matches!(err, EditError::NotAFile(_));
360 }
361
362 #[test]
363 fn edit_replace_block_crlf_file_lf_oldtext() {
364 let dir = tempfile::tempdir().unwrap();
366 let path = dir.path().join("crlf.txt");
367 std::fs::write(&path, b"foo\r\nbar\r\nbaz").unwrap();
369 let result = edit_replace_block(&path, "bar", "qux").unwrap();
370 let output = std::fs::read_to_string(&path).unwrap();
372 assert_eq!(output, "foo\r\nqux\r\nbaz");
373 assert_eq!(result.bytes_before, 13); assert_eq!(result.bytes_after, 13); }
376
377 #[test]
378 fn edit_replace_block_lf_file_crlf_oldtext() {
379 let dir = tempfile::tempdir().unwrap();
381 let path = dir.path().join("lf.txt");
382 std::fs::write(&path, b"foo\nbar\nbaz").unwrap();
383 let result = edit_replace_block(&path, "bar\r\n", "qux\n").unwrap();
384 let output = std::fs::read_to_string(&path).unwrap();
386 assert_eq!(output, "foo\nqux\nbaz");
387 assert_eq!(result.bytes_before, 11); assert_eq!(result.bytes_after, 11); }
390
391 #[test]
392 fn edit_replace_block_crlf_file_crlf_oldtext() {
393 let dir = tempfile::tempdir().unwrap();
395 let path = dir.path().join("bothcrlf.txt");
396 std::fs::write(&path, b"line1\r\nline2\r\nline3").unwrap();
397 let result = edit_replace_block(&path, "line2\r\n", "replaced\n").unwrap();
398 let output = std::fs::read_to_string(&path).unwrap();
399 assert_eq!(output, "line1\r\nreplaced\nline3");
400 assert_eq!(result.bytes_before, 19); }
402
403 #[test]
404 fn edit_replace_block_trailing_spaces_distinct() {
405 let dir = tempfile::tempdir().unwrap();
407 let path = dir.path().join("spaces.txt");
408 std::fs::write(&path, "foo \nbar\nfoo\nbar").unwrap();
409 let result = edit_replace_block(&path, "foo\nbar", "replaced").unwrap();
412 let output = std::fs::read_to_string(&path).unwrap();
413 assert_eq!(output, "foo \nbar\nreplaced");
414 assert_eq!(result.bytes_before, 17); assert_eq!(result.bytes_after, 18); }
417
418 #[test]
419 fn edit_replace_block_replace_all_three_occurrences() {
420 let dir = tempfile::tempdir().unwrap();
421 let path = dir.path().join("all.txt");
422 std::fs::write(&path, "a b a c a d").unwrap();
423 let result = edit_replace_block_with_options(&path, "a", "x", true, None).unwrap();
424 assert_eq!(std::fs::read_to_string(&path).unwrap(), "x b x c x d");
425 assert_eq!(result.bytes_before, 11);
426 assert_eq!(result.bytes_after, 11);
427 assert_eq!(result.occurrences_replaced, 3);
428 }
429
430 #[test]
431 fn edit_replace_block_replace_all_not_found() {
432 let dir = tempfile::tempdir().unwrap();
433 let path = dir.path().join("nf.txt");
434 std::fs::write(&path, "foo bar baz").unwrap();
435 let err = edit_replace_block_with_options(&path, "missing", "x", true, None).unwrap_err();
436 std::assert_matches!(&err, EditError::NotFound { .. });
437 }
438
439 #[test]
440 fn edit_replace_block_replace_all_empty_oldtext() {
441 let dir = tempfile::tempdir().unwrap();
442 let path = dir.path().join("empty.txt");
443 std::fs::write(&path, "foo bar baz").unwrap();
444 let err = edit_replace_block_with_options(&path, "", "x", true, None).unwrap_err();
445 std::assert_matches!(&err, EditError::InvalidParams(_));
446 }
447
448 #[test]
449 fn edit_replace_block_replace_all_preserves_crlf() {
450 let dir = tempfile::tempdir().unwrap();
452 let path = dir.path().join("crlf_all.txt");
453 std::fs::write(&path, b"a\r\nb\r\na\r\nc").unwrap();
454 let result = edit_replace_block_with_options(&path, "a", "x", true, None).unwrap();
455 assert_eq!(std::fs::read_to_string(&path).unwrap(), "x\r\nb\r\nx\r\nc");
456 assert_eq!(result.occurrences_replaced, 2);
457 }
458
459 #[test]
460 fn replace_all_deletes_all_occurrences() {
461 let dir = tempfile::tempdir().unwrap();
462 let path = dir.path().join("delete.txt");
463 std::fs::write(&path, "a b a c a d").unwrap();
464 let result = edit_replace_block_with_options(&path, "a", "", true, None).unwrap();
465 assert_eq!(std::fs::read_to_string(&path).unwrap(), " b c d");
466 assert_eq!(result.bytes_before, 11);
467 assert_eq!(result.bytes_after, 8);
468 assert_eq!(result.occurrences_replaced, 3);
469 }
470
471 #[test]
472 fn replace_all_non_overlap_adjacent() {
473 let dir = tempfile::tempdir().unwrap();
474 let path = dir.path().join("adjacent.txt");
475 std::fs::write(&path, "aaaa").unwrap();
476 let result = edit_replace_block_with_options(&path, "aa", "xx", true, None).unwrap();
477 assert_eq!(std::fs::read_to_string(&path).unwrap(), "xxxx");
478 assert_eq!(result.occurrences_replaced, 2);
479 }
480
481 #[test]
482 fn replace_all_size_changing() {
483 let dir = tempfile::tempdir().unwrap();
484 let path = dir.path().join("size.txt");
485 std::fs::write(&path, "x y x z x").unwrap();
486 let result = edit_replace_block_with_options(&path, "x", "yyy", true, None).unwrap();
487 assert_eq!(std::fs::read_to_string(&path).unwrap(), "yyy y yyy z yyy");
488 assert_eq!(result.bytes_before, 9);
489 assert_eq!(result.bytes_after, 15);
490 assert_eq!(result.occurrences_replaced, 3);
491 }
492
493 #[test]
494 fn replace_all_empty_oldtext_no_replace_all_returns_invalid_params() {
495 let dir = tempfile::tempdir().unwrap();
496 let path = dir.path().join("empty.txt");
497 std::fs::write(&path, "foo bar baz").unwrap();
498 let err = edit_replace_block(&path, "", "x").unwrap_err();
499 std::assert_matches!(&err, EditError::InvalidParams(_));
500 }
501
502 #[test]
503 fn expected_content_hash_mismatch_returns_stale_error() {
504 let dir = tempfile::tempdir().unwrap();
505 let path = dir.path().join("stale.txt");
506 std::fs::write(&path, "hello world").unwrap();
507 let err = edit_replace_block_with_options(
508 &path,
509 "hello",
510 "hi",
511 false,
512 Some("0000000000000000000000000000000000000000000000000000000000000000"),
513 )
514 .unwrap_err();
515 std::assert_matches!(&err, EditError::StaleContentHash { path: p, .. } if p.contains("stale.txt"));
516 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
518 }
519
520 #[test]
521 fn expected_content_hash_match_proceeds_normally() {
522 let dir = tempfile::tempdir().unwrap();
523 let path = dir.path().join("match.txt");
524 std::fs::write(&path, "hello world").unwrap();
525 let raw_bytes = std::fs::read(&path).unwrap();
526 let hash = blake3::hash(&raw_bytes).to_hex().to_string();
527 let result =
528 edit_replace_block_with_options(&path, "hello", "hi", false, Some(&hash)).unwrap();
529 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hi world");
530 assert_eq!(result.occurrences_replaced, 1);
531 }
532
533 #[test]
534 fn expected_content_hash_none_skips_check() {
535 let dir = tempfile::tempdir().unwrap();
536 let path = dir.path().join("nohash.txt");
537 std::fs::write(&path, "foo bar baz").unwrap();
538 let result = edit_replace_block_with_options(&path, "bar", "qux", false, None).unwrap();
539 assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
540 assert_eq!(result.occurrences_replaced, 1);
541 }
542
543 #[test]
544 fn replace_all_crlf_offset_index_byte_identical() {
545 let dir = tempfile::tempdir().unwrap();
548 let path = dir.path().join("mixed_crlf.txt");
549 let original = b"a\r\nb\na\r\nc\na\r\nd";
551 std::fs::write(&path, original).unwrap();
552 let result = edit_replace_block_with_options(&path, "a", "XYZ", true, None).unwrap();
553 assert_eq!(result.occurrences_replaced, 3);
554 let output = std::fs::read(&path).unwrap();
555 assert_eq!(output, b"XYZ\r\nb\nXYZ\r\nc\nXYZ\r\nd");
557 }
558}