1use crate::types::{
6 EditInsertOutput, EditOverwriteOutput, EditRenameOutput, EditReplaceOutput, InsertPosition,
7};
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10
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 { path: String },
27 #[error(
28 "old_text appears {count} times in {path} — make old_text longer and more specific to uniquely identify the block"
29 )]
30 Ambiguous { count: usize, path: String },
31 #[error("symbol '{name}' not found in {path}")]
32 SymbolNotFound { name: String, path: String },
33 #[error("symbol '{name}' matches multiple node kinds in {path} — supply kind to disambiguate")]
34 AmbiguousKind {
35 name: String,
36 kinds: Vec<String>,
37 path: String,
38 },
39 #[error("unsupported file extension for AST operations: {0}")]
40 UnsupportedLanguage(String),
41 #[error(
42 "kind filtering is not supported with the current identifier query infrastructure; omit kind to rename all occurrences"
43 )]
44 KindFilterUnsupported,
45}
46
47const IDENTIFIER_QUERY: &str = "(identifier) @name";
48
49pub fn edit_overwrite_content(
50 path: &Path,
51 content: &str,
52) -> Result<EditOverwriteOutput, EditError> {
53 if path.is_dir() {
54 return Err(EditError::NotAFile(path.to_path_buf()));
55 }
56 if let Some(parent) = path.parent()
57 && !parent.as_os_str().is_empty()
58 {
59 std::fs::create_dir_all(parent)?;
60 }
61 std::fs::write(path, content)?;
62 Ok(EditOverwriteOutput {
63 path: path.display().to_string(),
64 bytes_written: content.len(),
65 })
66}
67
68pub fn edit_replace_block(
69 path: &Path,
70 old_text: &str,
71 new_text: &str,
72) -> Result<EditReplaceOutput, EditError> {
73 if path.is_dir() {
74 return Err(EditError::NotAFile(path.to_path_buf()));
75 }
76 let content = std::fs::read_to_string(path)?;
77 let count = content.matches(old_text).count();
78 match count {
79 0 => {
80 return Err(EditError::NotFound {
81 path: path.display().to_string(),
82 });
83 }
84 1 => {}
85 n => {
86 return Err(EditError::Ambiguous {
87 count: n,
88 path: path.display().to_string(),
89 });
90 }
91 }
92 let bytes_before = content.len();
93 let updated = content.replacen(old_text, new_text, 1);
94 let bytes_after = updated.len();
95 std::fs::write(path, &updated)?;
96 Ok(EditReplaceOutput {
97 path: path.display().to_string(),
98 bytes_before,
99 bytes_after,
100 })
101}
102
103pub fn edit_rename_in_file(
104 path: &Path,
105 old_name: &str,
106 new_name: &str,
107 kind: Option<&str>,
108) -> Result<EditRenameOutput, EditError> {
109 if kind.is_some() {
110 return Err(EditError::KindFilterUnsupported);
111 }
112
113 if path.is_dir() {
114 return Err(EditError::NotAFile(path.to_path_buf()));
115 }
116
117 let ext = path
118 .extension()
119 .and_then(|e| e.to_str())
120 .ok_or_else(|| EditError::UnsupportedLanguage("no extension".to_string()))?;
121
122 let language = crate::lang::language_for_extension(ext)
123 .ok_or_else(|| EditError::UnsupportedLanguage(ext.to_string()))?;
124
125 let source = std::fs::read_to_string(path)?;
126
127 let captures = crate::execute_query(language, &source, IDENTIFIER_QUERY)
128 .map_err(|_| EditError::UnsupportedLanguage(language.to_string()))?;
129
130 let matching_captures: Vec<_> = captures.iter().filter(|c| c.text == old_name).collect();
131
132 if matching_captures.is_empty() {
133 return Err(EditError::SymbolNotFound {
134 name: old_name.to_string(),
135 path: path.display().to_string(),
136 });
137 }
138
139 let mut bytes: Vec<u8> = source.into_bytes();
140 let mut sorted_captures = matching_captures.clone();
141 sorted_captures.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
142
143 for capture in sorted_captures {
144 let start = capture.start_byte;
145 let end = capture.end_byte;
146 bytes.splice(start..end, new_name.bytes());
147 }
148
149 let updated = String::from_utf8(bytes).map_err(|_| {
150 EditError::Io(std::io::Error::new(
151 std::io::ErrorKind::InvalidData,
152 "invalid UTF-8 after rename",
153 ))
154 })?;
155
156 std::fs::write(path, &updated)?;
157
158 Ok(EditRenameOutput {
159 path: path.display().to_string(),
160 old_name: old_name.to_string(),
161 new_name: new_name.to_string(),
162 occurrences_renamed: matching_captures.len(),
163 })
164}
165
166pub fn edit_insert_at_symbol(
167 path: &Path,
168 symbol_name: &str,
169 position: InsertPosition,
170 content: &str,
171) -> Result<EditInsertOutput, EditError> {
172 if path.is_dir() {
173 return Err(EditError::NotAFile(path.to_path_buf()));
174 }
175
176 let ext = path
177 .extension()
178 .and_then(|e| e.to_str())
179 .ok_or_else(|| EditError::UnsupportedLanguage("no extension".to_string()))?;
180
181 let language = crate::lang::language_for_extension(ext)
182 .ok_or_else(|| EditError::UnsupportedLanguage(ext.to_string()))?;
183
184 let source = std::fs::read_to_string(path)?;
185
186 let captures = crate::execute_query(language, &source, IDENTIFIER_QUERY)
187 .map_err(|_| EditError::UnsupportedLanguage(language.to_string()))?;
188
189 let target = captures
190 .iter()
191 .find(|c| c.text == symbol_name)
192 .ok_or_else(|| EditError::SymbolNotFound {
193 name: symbol_name.to_string(),
194 path: path.display().to_string(),
195 })?;
196
197 let byte_offset = match position {
198 InsertPosition::Before => target.start_byte,
199 InsertPosition::After => target.end_byte,
200 };
201
202 let mut bytes: Vec<u8> = source.into_bytes();
203 bytes.splice(byte_offset..byte_offset, content.bytes());
204
205 let updated = String::from_utf8(bytes).map_err(|_| {
206 EditError::Io(std::io::Error::new(
207 std::io::ErrorKind::InvalidData,
208 "invalid UTF-8 after insert",
209 ))
210 })?;
211
212 std::fs::write(path, &updated)?;
213
214 let position_str = match position {
215 InsertPosition::Before => "before",
216 InsertPosition::After => "after",
217 };
218
219 Ok(EditInsertOutput {
220 path: path.display().to_string(),
221 symbol_name: symbol_name.to_string(),
222 position: position_str.to_string(),
223 byte_offset,
224 })
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use std::io::Write;
231 use tempfile::NamedTempFile;
232
233 fn make_temp_file(content: &str) -> NamedTempFile {
234 let mut f = NamedTempFile::new().unwrap();
235 write!(f, "{}", content).unwrap();
236 f
237 }
238
239 #[test]
240 fn edit_overwrite_content_creates_new_file() {
241 let dir = tempfile::tempdir().unwrap();
242 let path = dir.path().join("new.txt");
243 let result = edit_overwrite_content(&path, "hello world").unwrap();
244 assert_eq!(result.bytes_written, 11);
245 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
246 }
247
248 #[test]
249 fn edit_overwrite_content_overwrites_existing() {
250 let dir = tempfile::tempdir().unwrap();
251 let path = dir.path().join("existing.txt");
252 std::fs::write(&path, "old content").unwrap();
253 let result = edit_overwrite_content(&path, "new content").unwrap();
254 assert_eq!(result.bytes_written, 11);
255 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
256 }
257
258 #[test]
259 fn edit_overwrite_content_creates_parent_dirs() {
260 let dir = tempfile::tempdir().unwrap();
261 let path = dir.path().join("a").join("b").join("c.txt");
262 let result = edit_overwrite_content(&path, "nested").unwrap();
263 assert_eq!(result.bytes_written, 6);
264 assert!(path.exists());
265 }
266
267 #[test]
268 fn edit_overwrite_content_directory_guard() {
269 let dir = tempfile::tempdir().unwrap();
270 let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
271 assert!(matches!(err, EditError::NotAFile(_)));
272 }
273
274 #[test]
275 fn edit_replace_block_happy_path() {
276 let dir = tempfile::tempdir().unwrap();
277 let path = dir.path().join("file.txt");
278 std::fs::write(&path, "foo bar baz").unwrap();
279 let result = edit_replace_block(&path, "bar", "qux").unwrap();
280 assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
281 assert_eq!(result.bytes_before, 11);
282 assert_eq!(result.bytes_after, 11);
283 }
284
285 #[test]
286 fn edit_replace_block_not_found() {
287 let dir = tempfile::tempdir().unwrap();
288 let path = dir.path().join("file.txt");
289 std::fs::write(&path, "foo bar baz").unwrap();
290 let err = edit_replace_block(&path, "missing", "x").unwrap_err();
291 assert!(matches!(err, EditError::NotFound { .. }));
292 }
293
294 #[test]
295 fn edit_replace_block_ambiguous() {
296 let dir = tempfile::tempdir().unwrap();
297 let path = dir.path().join("file.txt");
298 std::fs::write(&path, "foo foo baz").unwrap();
299 let err = edit_replace_block(&path, "foo", "x").unwrap_err();
300 assert!(matches!(err, EditError::Ambiguous { count: 2, .. }));
301 }
302
303 #[test]
304 fn edit_replace_block_directory_guard() {
305 let dir = tempfile::tempdir().unwrap();
306 let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
307 assert!(matches!(err, EditError::NotAFile(_)));
308 }
309
310 fn write_temp(content: &str, ext: &str) -> tempfile::NamedTempFile {
311 let mut f = tempfile::Builder::new().suffix(ext).tempfile().unwrap();
312 f.write_all(content.as_bytes()).unwrap();
313 f.flush().unwrap();
314 f
315 }
316
317 #[test]
318 fn edit_rename_in_file_renames_identifier_not_comment() {
319 let src = "fn foo() {}\n// foo is a function\n";
320 let f = write_temp(src, ".rs");
321 let out = edit_rename_in_file(f.path(), "foo", "bar", None).unwrap();
322 assert_eq!(out.occurrences_renamed, 1);
323 let updated = std::fs::read_to_string(f.path()).unwrap();
324 assert!(updated.contains("fn bar()"));
325 assert!(updated.contains("// foo is a function"));
326 }
327
328 #[test]
329 fn edit_rename_in_file_not_found_error() {
330 let f = write_temp("fn foo() {}\n", ".rs");
331 let err = edit_rename_in_file(f.path(), "missing", "bar", None).unwrap_err();
332 assert!(matches!(err, EditError::SymbolNotFound { .. }));
333 }
334
335 #[test]
336 fn edit_rename_in_file_kind_returns_kind_filter_unsupported() {
337 let f = write_temp("fn foo() {}\n", ".rs");
338 let err = edit_rename_in_file(f.path(), "foo", "bar", Some("function")).unwrap_err();
339 assert!(matches!(err, EditError::KindFilterUnsupported));
340 }
341
342 #[test]
343 fn edit_rename_in_file_unsupported_extension() {
344 let f = write_temp("foo bar\n", ".txt");
345 let err = edit_rename_in_file(f.path(), "foo", "bar", None).unwrap_err();
346 assert!(matches!(err, EditError::UnsupportedLanguage(_)));
347 }
348
349 #[test]
350 fn edit_insert_at_symbol_before() {
351 let src = "fn foo() {}\n";
352 let f = write_temp(src, ".rs");
353 let out = edit_insert_at_symbol(f.path(), "foo", InsertPosition::Before, "bar_").unwrap();
354 let updated = std::fs::read_to_string(f.path()).unwrap();
355 assert!(updated.contains("fn bar_foo()"));
356 assert_eq!(out.position, "before");
357 }
358
359 #[test]
360 fn edit_insert_at_symbol_after() {
361 let src = "fn foo() {}\n";
362 let f = write_temp(src, ".rs");
363 let out =
364 edit_insert_at_symbol(f.path(), "foo", InsertPosition::After, "_renamed").unwrap();
365 let updated = std::fs::read_to_string(f.path()).unwrap();
366 assert!(updated.contains("fn foo_renamed()"));
367 assert_eq!(out.position, "after");
368 }
369
370 #[test]
371 fn edit_insert_at_symbol_not_found_error() {
372 let f = write_temp("fn foo() {}\n", ".rs");
373 let err =
374 edit_insert_at_symbol(f.path(), "missing", InsertPosition::Before, "x").unwrap_err();
375 assert!(matches!(err, EditError::SymbolNotFound { .. }));
376 }
377
378 #[test]
379 fn edit_insert_at_symbol_unsupported_extension() {
380 let f = write_temp("foo bar\n", ".txt");
381 let err = edit_insert_at_symbol(f.path(), "foo", InsertPosition::Before, "x").unwrap_err();
382 assert!(matches!(err, EditError::UnsupportedLanguage(_)));
383 }
384}