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 let norm_match_offset = norm_content
152 .find(&norm_old)
153 .expect("match was verified above via count check; find must succeed");
154 let original_start = norm_offset_to_original(&content, norm_match_offset);
155 let original_end = norm_offset_to_original_from(&content, norm_old.len(), original_start);
156 let updated = [
157 &content[..original_start],
158 new_text,
159 &content[original_end..],
160 ]
161 .concat();
162 let bytes_after = updated.len();
163 write_file_atomic(path, &updated)?;
164 Ok(EditReplaceOutput {
165 path: path.display().to_string(),
166 bytes_before,
167 bytes_after,
168 })
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn edit_overwrite_content_creates_new_file() {
177 let dir = tempfile::tempdir().unwrap();
178 let path = dir.path().join("new.txt");
179 let result = edit_overwrite_content(&path, "hello world").unwrap();
180 assert_eq!(result.bytes_written, 11);
181 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
182 }
183
184 #[test]
185 fn edit_overwrite_content_overwrites_existing() {
186 let dir = tempfile::tempdir().unwrap();
187 let path = dir.path().join("existing.txt");
188 std::fs::write(&path, "old content").unwrap();
189 let result = edit_overwrite_content(&path, "new content").unwrap();
190 assert_eq!(result.bytes_written, 11);
191 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
192 }
193
194 #[test]
195 fn edit_overwrite_content_creates_parent_dirs() {
196 let dir = tempfile::tempdir().unwrap();
197 let path = dir.path().join("a").join("b").join("c.txt");
198 let result = edit_overwrite_content(&path, "nested").unwrap();
199 assert_eq!(result.bytes_written, 6);
200 assert!(path.exists());
201 }
202
203 #[test]
204 fn edit_overwrite_content_directory_guard() {
205 let dir = tempfile::tempdir().unwrap();
206 let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
207 std::assert_matches!(err, EditError::NotAFile(_));
208 }
209
210 #[test]
211 fn edit_replace_block_happy_path() {
212 let dir = tempfile::tempdir().unwrap();
213 let path = dir.path().join("file.txt");
214 std::fs::write(&path, "foo bar baz").unwrap();
215 let result = edit_replace_block(&path, "bar", "qux").unwrap();
216 assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
217 assert_eq!(result.bytes_before, 11);
218 assert_eq!(result.bytes_after, 11);
219 }
220
221 #[test]
222 fn edit_replace_block_not_found() {
223 let dir = tempfile::tempdir().unwrap();
224 let path = dir.path().join("file.txt");
225 std::fs::write(&path, "foo bar baz").unwrap();
226 let err = edit_replace_block(&path, "missing", "x").unwrap_err();
227 std::assert_matches!(&err, EditError::NotFound { first_20_lines, .. } if !first_20_lines.is_empty());
228 }
229
230 #[test]
231 fn edit_replace_block_ambiguous() {
232 let dir = tempfile::tempdir().unwrap();
233 let path = dir.path().join("file.txt");
234 std::fs::write(&path, "foo foo baz").unwrap();
235 let err = edit_replace_block(&path, "foo", "x").unwrap_err();
236 std::assert_matches!(&err, EditError::Ambiguous { count: 2, match_lines, .. } if match_lines == &[1, 1]);
237 }
238
239 #[test]
240 fn edit_replace_block_directory_guard() {
241 let dir = tempfile::tempdir().unwrap();
242 let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
243 std::assert_matches!(err, EditError::NotAFile(_));
244 }
245
246 #[test]
247 fn edit_replace_block_crlf_file_lf_oldtext() {
248 let dir = tempfile::tempdir().unwrap();
250 let path = dir.path().join("crlf.txt");
251 std::fs::write(&path, b"foo\r\nbar\r\nbaz").unwrap();
253 let result = edit_replace_block(&path, "bar", "qux").unwrap();
254 let output = std::fs::read_to_string(&path).unwrap();
256 assert_eq!(output, "foo\r\nqux\r\nbaz");
257 assert_eq!(result.bytes_before, 13); assert_eq!(result.bytes_after, 13); }
260
261 #[test]
262 fn edit_replace_block_lf_file_crlf_oldtext() {
263 let dir = tempfile::tempdir().unwrap();
265 let path = dir.path().join("lf.txt");
266 std::fs::write(&path, b"foo\nbar\nbaz").unwrap();
267 let result = edit_replace_block(&path, "bar\r\n", "qux\n").unwrap();
268 let output = std::fs::read_to_string(&path).unwrap();
270 assert_eq!(output, "foo\nqux\nbaz");
271 assert_eq!(result.bytes_before, 11); assert_eq!(result.bytes_after, 11); }
274
275 #[test]
276 fn edit_replace_block_crlf_file_crlf_oldtext() {
277 let dir = tempfile::tempdir().unwrap();
279 let path = dir.path().join("bothcrlf.txt");
280 std::fs::write(&path, b"line1\r\nline2\r\nline3").unwrap();
281 let result = edit_replace_block(&path, "line2\r\n", "replaced\n").unwrap();
282 let output = std::fs::read_to_string(&path).unwrap();
283 assert_eq!(output, "line1\r\nreplaced\nline3");
284 assert_eq!(result.bytes_before, 19); }
286
287 #[test]
288 fn edit_replace_block_trailing_spaces_distinct() {
289 let dir = tempfile::tempdir().unwrap();
291 let path = dir.path().join("spaces.txt");
292 std::fs::write(&path, "foo \nbar\nfoo\nbar").unwrap();
293 let result = edit_replace_block(&path, "foo\nbar", "replaced").unwrap();
296 let output = std::fs::read_to_string(&path).unwrap();
297 assert_eq!(output, "foo \nbar\nreplaced");
298 assert_eq!(result.bytes_before, 17); assert_eq!(result.bytes_after, 18); }
301}