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}
39
40fn write_file_atomic(path: &Path, content: &str) -> Result<(), EditError> {
41 let parent = path.parent().ok_or_else(|| {
42 EditError::Io(std::io::Error::new(
43 std::io::ErrorKind::InvalidInput,
44 "path has no parent directory",
45 ))
46 })?;
47 let mut temp_file = NamedTempFile::new_in(parent)?;
48 use std::io::Write;
49 temp_file.write_all(content.as_bytes())?;
50 temp_file.persist(path).map_err(|e| e.error)?;
51 Ok(())
52}
53
54fn normalize_for_match(s: &str) -> String {
57 s.replace("\r\n", "\n")
58}
59
60fn norm_offset_to_original_from(
63 original: &str,
64 norm_offset: usize,
65 original_start: usize,
66) -> usize {
67 let bytes = original.as_bytes();
71 let mut norm_pos = 0usize;
72 let mut i = original_start;
73 while i < bytes.len() && norm_pos < norm_offset {
74 if bytes[i] == b'\r' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
75 norm_pos += 1;
76 i += 2;
77 } else {
78 norm_pos += 1;
79 i += 1;
80 }
81 }
82 i
83}
84
85fn norm_offset_to_original(original: &str, norm_offset: usize) -> usize {
88 norm_offset_to_original_from(original, norm_offset, 0)
89}
90
91pub fn edit_overwrite_content(
92 path: &Path,
93 content: &str,
94) -> Result<EditOverwriteOutput, EditError> {
95 if path.is_dir() {
96 return Err(EditError::NotAFile(path.to_path_buf()));
97 }
98 if let Some(parent) = path.parent()
99 && !parent.as_os_str().is_empty()
100 {
101 std::fs::create_dir_all(parent)?;
102 }
103 write_file_atomic(path, content)?;
104 Ok(EditOverwriteOutput {
105 path: path.display().to_string(),
106 bytes_written: content.len(),
107 })
108}
109
110pub fn edit_replace_block(
111 path: &Path,
112 old_text: &str,
113 new_text: &str,
114) -> Result<EditReplaceOutput, EditError> {
115 if path.is_dir() {
116 return Err(EditError::NotAFile(path.to_path_buf()));
117 }
118 let content = std::fs::read_to_string(path)?;
119 let norm_content = normalize_for_match(&content);
120 let norm_old = normalize_for_match(old_text);
121 let count = norm_content.matches(&norm_old).count();
122 match count {
123 0 => {
124 let first_20_lines = content.lines().take(20).collect::<Vec<_>>().join("\n");
125 return Err(EditError::NotFound {
126 path: path.display().to_string(),
127 first_20_lines,
128 });
129 }
130 1 => {}
131 n => {
132 let match_lines: Vec<usize> = norm_content
133 .match_indices(&norm_old)
134 .map(|(offset, _)| {
135 norm_content[..offset]
136 .bytes()
137 .filter(|&b| b == b'\n')
138 .count()
139 + 1
140 })
141 .collect();
142 return Err(EditError::Ambiguous {
143 count: n,
144 path: path.display().to_string(),
145 match_lines,
146 });
147 }
148 }
149 let bytes_before = content.len();
150 #[allow(clippy::expect_used)]
154 let norm_match_offset = norm_content
155 .find(&norm_old)
156 .expect("match was verified above via count check; find must succeed");
157 let original_start = norm_offset_to_original(&content, norm_match_offset);
158 let original_end = norm_offset_to_original_from(&content, norm_old.len(), original_start);
159 let updated = [
160 &content[..original_start],
161 new_text,
162 &content[original_end..],
163 ]
164 .concat();
165 let bytes_after = updated.len();
166 write_file_atomic(path, &updated)?;
167 Ok(EditReplaceOutput {
168 path: path.display().to_string(),
169 bytes_before,
170 bytes_after,
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn edit_overwrite_content_creates_new_file() {
180 let dir = tempfile::tempdir().unwrap();
181 let path = dir.path().join("new.txt");
182 let result = edit_overwrite_content(&path, "hello world").unwrap();
183 assert_eq!(result.bytes_written, 11);
184 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
185 }
186
187 #[test]
188 fn edit_overwrite_content_overwrites_existing() {
189 let dir = tempfile::tempdir().unwrap();
190 let path = dir.path().join("existing.txt");
191 std::fs::write(&path, "old content").unwrap();
192 let result = edit_overwrite_content(&path, "new content").unwrap();
193 assert_eq!(result.bytes_written, 11);
194 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
195 }
196
197 #[test]
198 fn edit_overwrite_content_creates_parent_dirs() {
199 let dir = tempfile::tempdir().unwrap();
200 let path = dir.path().join("a").join("b").join("c.txt");
201 let result = edit_overwrite_content(&path, "nested").unwrap();
202 assert_eq!(result.bytes_written, 6);
203 assert!(path.exists());
204 }
205
206 #[test]
207 fn edit_overwrite_content_directory_guard() {
208 let dir = tempfile::tempdir().unwrap();
209 let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
210 std::assert_matches!(err, EditError::NotAFile(_));
211 }
212
213 #[test]
214 fn edit_replace_block_happy_path() {
215 let dir = tempfile::tempdir().unwrap();
216 let path = dir.path().join("file.txt");
217 std::fs::write(&path, "foo bar baz").unwrap();
218 let result = edit_replace_block(&path, "bar", "qux").unwrap();
219 assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
220 assert_eq!(result.bytes_before, 11);
221 assert_eq!(result.bytes_after, 11);
222 }
223
224 #[test]
225 fn edit_replace_block_not_found() {
226 let dir = tempfile::tempdir().unwrap();
227 let path = dir.path().join("file.txt");
228 std::fs::write(&path, "foo bar baz").unwrap();
229 let err = edit_replace_block(&path, "missing", "x").unwrap_err();
230 std::assert_matches!(&err, EditError::NotFound { first_20_lines, .. } if !first_20_lines.is_empty());
231 }
232
233 #[test]
234 fn edit_replace_block_ambiguous() {
235 let dir = tempfile::tempdir().unwrap();
236 let path = dir.path().join("file.txt");
237 std::fs::write(&path, "foo foo baz").unwrap();
238 let err = edit_replace_block(&path, "foo", "x").unwrap_err();
239 std::assert_matches!(&err, EditError::Ambiguous { count: 2, match_lines, .. } if match_lines == &[1, 1]);
240 }
241
242 #[test]
243 fn edit_replace_block_directory_guard() {
244 let dir = tempfile::tempdir().unwrap();
245 let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
246 std::assert_matches!(err, EditError::NotAFile(_));
247 }
248
249 #[test]
250 fn edit_replace_block_crlf_file_lf_oldtext() {
251 let dir = tempfile::tempdir().unwrap();
253 let path = dir.path().join("crlf.txt");
254 std::fs::write(&path, b"foo\r\nbar\r\nbaz").unwrap();
256 let result = edit_replace_block(&path, "bar", "qux").unwrap();
257 let output = std::fs::read_to_string(&path).unwrap();
259 assert_eq!(output, "foo\r\nqux\r\nbaz");
260 assert_eq!(result.bytes_before, 13); assert_eq!(result.bytes_after, 13); }
263
264 #[test]
265 fn edit_replace_block_lf_file_crlf_oldtext() {
266 let dir = tempfile::tempdir().unwrap();
268 let path = dir.path().join("lf.txt");
269 std::fs::write(&path, b"foo\nbar\nbaz").unwrap();
270 let result = edit_replace_block(&path, "bar\r\n", "qux\n").unwrap();
271 let output = std::fs::read_to_string(&path).unwrap();
273 assert_eq!(output, "foo\nqux\nbaz");
274 assert_eq!(result.bytes_before, 11); assert_eq!(result.bytes_after, 11); }
277
278 #[test]
279 fn edit_replace_block_crlf_file_crlf_oldtext() {
280 let dir = tempfile::tempdir().unwrap();
282 let path = dir.path().join("bothcrlf.txt");
283 std::fs::write(&path, b"line1\r\nline2\r\nline3").unwrap();
284 let result = edit_replace_block(&path, "line2\r\n", "replaced\n").unwrap();
285 let output = std::fs::read_to_string(&path).unwrap();
286 assert_eq!(output, "line1\r\nreplaced\nline3");
287 assert_eq!(result.bytes_before, 19); }
289
290 #[test]
291 fn edit_replace_block_trailing_spaces_distinct() {
292 let dir = tempfile::tempdir().unwrap();
294 let path = dir.path().join("spaces.txt");
295 std::fs::write(&path, "foo \nbar\nfoo\nbar").unwrap();
296 let result = edit_replace_block(&path, "foo\nbar", "replaced").unwrap();
299 let output = std::fs::read_to_string(&path).unwrap();
300 assert_eq!(output, "foo \nbar\nreplaced");
301 assert_eq!(result.bytes_before, 17); assert_eq!(result.bytes_after, 18); }
304}