1use crate::types::{EditOverwriteOutput, EditReplaceOutput};
6use std::path::{Path, PathBuf};
7use tempfile::NamedTempFile;
8use thiserror::Error;
9
10#[non_exhaustive]
11#[derive(Debug, Error)]
12pub enum EditError {
13 #[error("I/O error: {0}")]
14 Io(#[from] std::io::Error),
15 #[error("invalid range: start ({start}) > end ({end}); file has {total} lines")]
16 InvalidRange {
17 start: usize,
18 end: usize,
19 total: usize,
20 },
21 #[error("path is a directory, not a file: {0}")]
22 NotAFile(PathBuf),
23 #[error(
24 "old_text not found in {path} — verify the text matches exactly, including whitespace and newlines"
25 )]
26 NotFound {
27 path: String,
28 first_20_lines: String,
29 },
30 #[error(
31 "old_text appears {count} times in {path} — make old_text longer and more specific to uniquely identify the block"
32 )]
33 Ambiguous {
34 count: usize,
35 path: String,
36 match_lines: Vec<usize>,
37 },
38 #[error("edit_replace invalid params: {0}")]
39 InvalidParams(String),
40}
41
42fn write_file_atomic(path: &Path, content: &str) -> Result<(), EditError> {
43 let parent = path.parent().ok_or_else(|| {
44 EditError::Io(std::io::Error::new(
45 std::io::ErrorKind::InvalidInput,
46 "path has no parent directory",
47 ))
48 })?;
49 let mut temp_file = NamedTempFile::new_in(parent)?;
50 use std::io::Write;
51 temp_file.write_all(content.as_bytes())?;
52 temp_file.persist(path).map_err(|e| e.error)?;
53 Ok(())
54}
55
56fn normalize_for_match(s: &str) -> String {
59 s.replace("\r\n", "\n")
60}
61
62fn norm_offset_to_original_from(
65 original: &str,
66 norm_offset: usize,
67 original_start: usize,
68) -> usize {
69 let bytes = original.as_bytes();
73 let mut norm_pos = 0usize;
74 let mut i = original_start;
75 while i < bytes.len() && norm_pos < norm_offset {
76 if bytes[i] == b'\r' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
77 norm_pos += 1;
78 i += 2;
79 } else {
80 norm_pos += 1;
81 i += 1;
82 }
83 }
84 i
85}
86
87fn norm_offset_to_original(original: &str, norm_offset: usize) -> usize {
90 norm_offset_to_original_from(original, norm_offset, 0)
91}
92
93pub fn edit_overwrite_content(
94 path: &Path,
95 content: &str,
96) -> Result<EditOverwriteOutput, EditError> {
97 if path.is_dir() {
98 return Err(EditError::NotAFile(path.to_path_buf()));
99 }
100 if let Some(parent) = path.parent()
101 && !parent.as_os_str().is_empty()
102 {
103 std::fs::create_dir_all(parent)?;
104 }
105 write_file_atomic(path, content)?;
106 Ok(EditOverwriteOutput {
107 path: path.display().to_string(),
108 bytes_written: content.len(),
109 })
110}
111
112pub fn edit_replace_block(
113 path: &Path,
114 old_text: &str,
115 new_text: &str,
116) -> Result<EditReplaceOutput, EditError> {
117 edit_replace_block_inner(path, old_text, new_text, false)
118}
119
120pub fn edit_replace_block_with_options(
124 path: &Path,
125 old_text: &str,
126 new_text: &str,
127 replace_all: bool,
128) -> Result<EditReplaceOutput, EditError> {
129 edit_replace_block_inner(path, old_text, new_text, replace_all)
130}
131
132pub(crate) fn edit_replace_block_inner(
133 path: &Path,
134 old_text: &str,
135 new_text: &str,
136 replace_all: bool,
137) -> Result<EditReplaceOutput, EditError> {
138 if path.is_dir() {
139 return Err(EditError::NotAFile(path.to_path_buf()));
140 }
141 let content = std::fs::read_to_string(path)?;
142 let norm_content = normalize_for_match(&content);
143 let norm_old = normalize_for_match(old_text);
144 if norm_old.is_empty() {
145 return Err(EditError::InvalidParams(
146 "old_text must not be empty".to_string(),
147 ));
148 }
149 let count = norm_content.matches(&norm_old).count();
150 match count {
151 0 => {
152 let first_20_lines = content.lines().take(20).collect::<Vec<_>>().join("\n");
153 return Err(EditError::NotFound {
154 path: path.display().to_string(),
155 first_20_lines,
156 });
157 }
158 1 if !replace_all => {}
159 n if !replace_all => {
160 let match_lines: Vec<usize> = norm_content
161 .match_indices(&norm_old)
162 .map(|(offset, _)| {
163 norm_content[..offset]
164 .bytes()
165 .filter(|&b| b == b'\n')
166 .count()
167 + 1
168 })
169 .collect();
170 return Err(EditError::Ambiguous {
171 count: n,
172 path: path.display().to_string(),
173 match_lines,
174 });
175 }
176 _ => {} }
178 let bytes_before = content.len();
179
180 if replace_all {
181 let mut matches: Vec<(usize, usize)> = Vec::new();
184 let mut search_from_original = 0usize;
185 let mut norm_consumed = 0usize;
186 for (norm_start, _m) in norm_content.match_indices(&norm_old) {
187 let original_start = norm_offset_to_original_from(
188 &content,
189 norm_start - norm_consumed,
190 search_from_original,
191 );
192 let original_end =
193 norm_offset_to_original_from(&content, norm_old.len(), original_start);
194 matches.push((original_start, original_end));
195 search_from_original = original_end;
196 norm_consumed = norm_start + norm_old.len();
197 }
198 let occurrences_replaced = matches.len();
199 let old_span_total: usize = matches.iter().map(|(s, e)| e - s).sum();
200 let mut result = String::with_capacity(
203 bytes_before + new_text.len().saturating_mul(occurrences_replaced) - old_span_total,
204 );
205 let mut last_end = 0usize;
206 for (start, end) in &matches {
207 result.push_str(&content[last_end..*start]);
208 result.push_str(new_text);
209 last_end = *end;
210 }
211 result.push_str(&content[last_end..]);
212 let bytes_after = result.len();
213 write_file_atomic(path, &result)?;
214 Ok(EditReplaceOutput {
215 path: path.display().to_string(),
216 bytes_before,
217 bytes_after,
218 occurrences_replaced,
219 })
220 } else {
221 #[allow(clippy::expect_used)]
225 let norm_match_offset = norm_content
226 .find(&norm_old)
227 .expect("match was verified above via count check; find must succeed");
228 let original_start = norm_offset_to_original(&content, norm_match_offset);
229 let original_end = norm_offset_to_original_from(&content, norm_old.len(), original_start);
230 let updated = [
231 &content[..original_start],
232 new_text,
233 &content[original_end..],
234 ]
235 .concat();
236 let bytes_after = updated.len();
237 write_file_atomic(path, &updated)?;
238 Ok(EditReplaceOutput {
239 path: path.display().to_string(),
240 bytes_before,
241 bytes_after,
242 occurrences_replaced: 1,
243 })
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn edit_overwrite_content_creates_new_file() {
253 let dir = tempfile::tempdir().unwrap();
254 let path = dir.path().join("new.txt");
255 let result = edit_overwrite_content(&path, "hello world").unwrap();
256 assert_eq!(result.bytes_written, 11);
257 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
258 }
259
260 #[test]
261 fn edit_overwrite_content_overwrites_existing() {
262 let dir = tempfile::tempdir().unwrap();
263 let path = dir.path().join("existing.txt");
264 std::fs::write(&path, "old content").unwrap();
265 let result = edit_overwrite_content(&path, "new content").unwrap();
266 assert_eq!(result.bytes_written, 11);
267 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
268 }
269
270 #[test]
271 fn edit_overwrite_content_creates_parent_dirs() {
272 let dir = tempfile::tempdir().unwrap();
273 let path = dir.path().join("a").join("b").join("c.txt");
274 let result = edit_overwrite_content(&path, "nested").unwrap();
275 assert_eq!(result.bytes_written, 6);
276 assert!(path.exists());
277 }
278
279 #[test]
280 fn edit_overwrite_content_directory_guard() {
281 let dir = tempfile::tempdir().unwrap();
282 let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
283 std::assert_matches!(err, EditError::NotAFile(_));
284 }
285
286 #[test]
287 fn edit_replace_block_happy_path() {
288 let dir = tempfile::tempdir().unwrap();
289 let path = dir.path().join("file.txt");
290 std::fs::write(&path, "foo bar baz").unwrap();
291 let result = edit_replace_block(&path, "bar", "qux").unwrap();
292 assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
293 assert_eq!(result.bytes_before, 11);
294 assert_eq!(result.bytes_after, 11);
295 }
296
297 #[test]
298 fn edit_replace_block_not_found() {
299 let dir = tempfile::tempdir().unwrap();
300 let path = dir.path().join("file.txt");
301 std::fs::write(&path, "foo bar baz").unwrap();
302 let err = edit_replace_block(&path, "missing", "x").unwrap_err();
303 std::assert_matches!(&err, EditError::NotFound { first_20_lines, .. } if !first_20_lines.is_empty());
304 }
305
306 #[test]
307 fn edit_replace_block_ambiguous() {
308 let dir = tempfile::tempdir().unwrap();
309 let path = dir.path().join("file.txt");
310 std::fs::write(&path, "foo foo baz").unwrap();
311 let err = edit_replace_block(&path, "foo", "x").unwrap_err();
312 std::assert_matches!(&err, EditError::Ambiguous { count: 2, match_lines, .. } if match_lines == &[1, 1]);
313 }
314
315 #[test]
316 fn edit_replace_block_directory_guard() {
317 let dir = tempfile::tempdir().unwrap();
318 let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
319 std::assert_matches!(err, EditError::NotAFile(_));
320 }
321
322 #[test]
323 fn edit_replace_block_crlf_file_lf_oldtext() {
324 let dir = tempfile::tempdir().unwrap();
326 let path = dir.path().join("crlf.txt");
327 std::fs::write(&path, b"foo\r\nbar\r\nbaz").unwrap();
329 let result = edit_replace_block(&path, "bar", "qux").unwrap();
330 let output = std::fs::read_to_string(&path).unwrap();
332 assert_eq!(output, "foo\r\nqux\r\nbaz");
333 assert_eq!(result.bytes_before, 13); assert_eq!(result.bytes_after, 13); }
336
337 #[test]
338 fn edit_replace_block_lf_file_crlf_oldtext() {
339 let dir = tempfile::tempdir().unwrap();
341 let path = dir.path().join("lf.txt");
342 std::fs::write(&path, b"foo\nbar\nbaz").unwrap();
343 let result = edit_replace_block(&path, "bar\r\n", "qux\n").unwrap();
344 let output = std::fs::read_to_string(&path).unwrap();
346 assert_eq!(output, "foo\nqux\nbaz");
347 assert_eq!(result.bytes_before, 11); assert_eq!(result.bytes_after, 11); }
350
351 #[test]
352 fn edit_replace_block_crlf_file_crlf_oldtext() {
353 let dir = tempfile::tempdir().unwrap();
355 let path = dir.path().join("bothcrlf.txt");
356 std::fs::write(&path, b"line1\r\nline2\r\nline3").unwrap();
357 let result = edit_replace_block(&path, "line2\r\n", "replaced\n").unwrap();
358 let output = std::fs::read_to_string(&path).unwrap();
359 assert_eq!(output, "line1\r\nreplaced\nline3");
360 assert_eq!(result.bytes_before, 19); }
362
363 #[test]
364 fn edit_replace_block_trailing_spaces_distinct() {
365 let dir = tempfile::tempdir().unwrap();
367 let path = dir.path().join("spaces.txt");
368 std::fs::write(&path, "foo \nbar\nfoo\nbar").unwrap();
369 let result = edit_replace_block(&path, "foo\nbar", "replaced").unwrap();
372 let output = std::fs::read_to_string(&path).unwrap();
373 assert_eq!(output, "foo \nbar\nreplaced");
374 assert_eq!(result.bytes_before, 17); assert_eq!(result.bytes_after, 18); }
377
378 #[test]
379 fn edit_replace_block_replace_all_three_occurrences() {
380 let dir = tempfile::tempdir().unwrap();
381 let path = dir.path().join("all.txt");
382 std::fs::write(&path, "a b a c a d").unwrap();
383 let result = edit_replace_block_with_options(&path, "a", "x", true).unwrap();
384 assert_eq!(std::fs::read_to_string(&path).unwrap(), "x b x c x d");
385 assert_eq!(result.bytes_before, 11);
386 assert_eq!(result.bytes_after, 11);
387 assert_eq!(result.occurrences_replaced, 3);
388 }
389
390 #[test]
391 fn edit_replace_block_replace_all_not_found() {
392 let dir = tempfile::tempdir().unwrap();
393 let path = dir.path().join("nf.txt");
394 std::fs::write(&path, "foo bar baz").unwrap();
395 let err = edit_replace_block_with_options(&path, "missing", "x", true).unwrap_err();
396 std::assert_matches!(&err, EditError::NotFound { .. });
397 }
398
399 #[test]
400 fn edit_replace_block_replace_all_empty_oldtext() {
401 let dir = tempfile::tempdir().unwrap();
402 let path = dir.path().join("empty.txt");
403 std::fs::write(&path, "foo bar baz").unwrap();
404 let err = edit_replace_block_with_options(&path, "", "x", true).unwrap_err();
405 std::assert_matches!(&err, EditError::InvalidParams(_));
406 }
407
408 #[test]
409 fn edit_replace_block_replace_all_preserves_crlf() {
410 let dir = tempfile::tempdir().unwrap();
412 let path = dir.path().join("crlf_all.txt");
413 std::fs::write(&path, b"a\r\nb\r\na\r\nc").unwrap();
414 let result = edit_replace_block_with_options(&path, "a", "x", true).unwrap();
415 assert_eq!(std::fs::read_to_string(&path).unwrap(), "x\r\nb\r\nx\r\nc");
416 assert_eq!(result.occurrences_replaced, 2);
417 }
418
419 #[test]
420 fn replace_all_deletes_all_occurrences() {
421 let dir = tempfile::tempdir().unwrap();
422 let path = dir.path().join("delete.txt");
423 std::fs::write(&path, "a b a c a d").unwrap();
424 let result = edit_replace_block_with_options(&path, "a", "", true).unwrap();
425 assert_eq!(std::fs::read_to_string(&path).unwrap(), " b c d");
426 assert_eq!(result.bytes_before, 11);
427 assert_eq!(result.bytes_after, 8);
428 assert_eq!(result.occurrences_replaced, 3);
429 }
430
431 #[test]
432 fn replace_all_non_overlap_adjacent() {
433 let dir = tempfile::tempdir().unwrap();
434 let path = dir.path().join("adjacent.txt");
435 std::fs::write(&path, "aaaa").unwrap();
436 let result = edit_replace_block_with_options(&path, "aa", "xx", true).unwrap();
437 assert_eq!(std::fs::read_to_string(&path).unwrap(), "xxxx");
438 assert_eq!(result.occurrences_replaced, 2);
439 }
440
441 #[test]
442 fn replace_all_size_changing() {
443 let dir = tempfile::tempdir().unwrap();
444 let path = dir.path().join("size.txt");
445 std::fs::write(&path, "x y x z x").unwrap();
446 let result = edit_replace_block_with_options(&path, "x", "yyy", true).unwrap();
447 assert_eq!(std::fs::read_to_string(&path).unwrap(), "yyy y yyy z yyy");
448 assert_eq!(result.bytes_before, 9);
449 assert_eq!(result.bytes_after, 15);
450 assert_eq!(result.occurrences_replaced, 3);
451 }
452
453 #[test]
454 fn replace_all_empty_oldtext_no_replace_all_returns_invalid_params() {
455 let dir = tempfile::tempdir().unwrap();
456 let path = dir.path().join("empty.txt");
457 std::fs::write(&path, "foo bar baz").unwrap();
458 let err = edit_replace_block(&path, "", "x").unwrap_err();
459 std::assert_matches!(&err, EditError::InvalidParams(_));
460 }
461}