1use crate::types::{
6 EditInsertOutput, EditOverwriteOutput, EditRenameOutput, EditReplaceOutput, FileRenameError,
7 FileRenameResult, InsertPosition,
8};
9use std::path::{Path, PathBuf};
10use tempfile::NamedTempFile;
11use thiserror::Error;
12
13#[derive(Debug, Error)]
14pub enum EditError {
15 #[error("I/O error: {0}")]
16 Io(#[from] std::io::Error),
17 #[error("invalid range: start ({start}) > end ({end}); file has {total} lines")]
18 InvalidRange {
19 start: usize,
20 end: usize,
21 total: usize,
22 },
23 #[error("path is a directory, not a file: {0}")]
24 NotAFile(PathBuf),
25 #[error(
26 "old_text not found in {path} — verify the text matches exactly, including whitespace and newlines"
27 )]
28 NotFound { path: String },
29 #[error(
30 "old_text appears {count} times in {path} — make old_text longer and more specific to uniquely identify the block"
31 )]
32 Ambiguous { count: usize, path: String },
33 #[error("symbol '{name}' not found in {path}")]
34 SymbolNotFound { name: String, path: String },
35 #[error("symbol '{name}' matches multiple node kinds in {path} — supply kind to disambiguate")]
36 AmbiguousKind {
37 name: String,
38 kinds: Vec<String>,
39 path: String,
40 },
41 #[error("unsupported file extension for AST operations: {0}")]
42 UnsupportedLanguage(String),
43 #[error(
44 "kind filtering is not supported with the current identifier query infrastructure; omit kind to rename all occurrences"
45 )]
46 KindFilterUnsupported,
47}
48
49const IDENTIFIER_QUERY: &str = "(identifier) @name";
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
65pub fn edit_overwrite_content(
66 path: &Path,
67 content: &str,
68) -> Result<EditOverwriteOutput, EditError> {
69 if path.is_dir() {
70 return Err(EditError::NotAFile(path.to_path_buf()));
71 }
72 if let Some(parent) = path.parent()
73 && !parent.as_os_str().is_empty()
74 {
75 std::fs::create_dir_all(parent)?;
76 }
77 write_file_atomic(path, content)?;
78 Ok(EditOverwriteOutput {
79 path: path.display().to_string(),
80 bytes_written: content.len(),
81 })
82}
83
84pub fn edit_replace_block(
85 path: &Path,
86 old_text: &str,
87 new_text: &str,
88) -> Result<EditReplaceOutput, EditError> {
89 if path.is_dir() {
90 return Err(EditError::NotAFile(path.to_path_buf()));
91 }
92 let content = std::fs::read_to_string(path)?;
93 let count = content.matches(old_text).count();
94 match count {
95 0 => {
96 return Err(EditError::NotFound {
97 path: path.display().to_string(),
98 });
99 }
100 1 => {}
101 n => {
102 return Err(EditError::Ambiguous {
103 count: n,
104 path: path.display().to_string(),
105 });
106 }
107 }
108 let bytes_before = content.len();
109 let updated = content.replacen(old_text, new_text, 1);
110 let bytes_after = updated.len();
111 write_file_atomic(path, &updated)?;
112 Ok(EditReplaceOutput {
113 path: path.display().to_string(),
114 bytes_before,
115 bytes_after,
116 })
117}
118
119pub fn edit_rename_in_file(
120 path: &Path,
121 old_name: &str,
122 new_name: &str,
123 kind: Option<&str>,
124) -> Result<EditRenameOutput, EditError> {
125 if kind.is_some() {
126 return Err(EditError::KindFilterUnsupported);
127 }
128
129 if path.is_dir() {
130 return Err(EditError::NotAFile(path.to_path_buf()));
131 }
132
133 let ext = path
134 .extension()
135 .and_then(|e| e.to_str())
136 .ok_or_else(|| EditError::UnsupportedLanguage("no extension".to_string()))?;
137
138 let language = crate::lang::language_for_extension(ext)
139 .ok_or_else(|| EditError::UnsupportedLanguage(ext.to_string()))?;
140
141 let source = std::fs::read_to_string(path)?;
142
143 let captures = crate::execute_query(language, &source, IDENTIFIER_QUERY)
144 .map_err(|_| EditError::UnsupportedLanguage(language.to_string()))?;
145
146 let matching_captures: Vec<_> = captures.iter().filter(|c| c.text == old_name).collect();
147
148 if matching_captures.is_empty() {
149 return Err(EditError::SymbolNotFound {
150 name: old_name.to_string(),
151 path: path.display().to_string(),
152 });
153 }
154
155 let mut bytes: Vec<u8> = source.into_bytes();
156 let mut sorted_captures = matching_captures.clone();
157 sorted_captures.sort_by_key(|b| std::cmp::Reverse(b.start_byte));
158
159 for capture in sorted_captures {
160 let start = capture.start_byte;
161 let end = capture.end_byte;
162 bytes.splice(start..end, new_name.bytes());
163 }
164
165 let updated = String::from_utf8(bytes).map_err(|_| {
166 EditError::Io(std::io::Error::new(
167 std::io::ErrorKind::InvalidData,
168 "invalid UTF-8 after rename",
169 ))
170 })?;
171
172 std::fs::write(path, &updated)?;
173
174 Ok(EditRenameOutput {
175 path: path.display().to_string(),
176 old_name: old_name.to_string(),
177 new_name: new_name.to_string(),
178 occurrences_renamed: matching_captures.len(),
179 files_changed: None,
180 errors: None,
181 })
182}
183
184pub fn edit_rename_directory(
185 root: &Path,
186 old_name: &str,
187 new_name: &str,
188 kind: Option<&str>,
189) -> Result<(Vec<FileRenameResult>, Vec<FileRenameError>), EditError> {
190 if kind.is_some() {
191 return Err(EditError::KindFilterUnsupported);
192 }
193
194 let entries = crate::traversal::walk_directory(root, None).map_err(|e| {
195 EditError::Io(std::io::Error::other(format!(
196 "directory traversal failed: {}",
197 e
198 )))
199 })?;
200
201 let mut results = Vec::new();
202 let mut errors = Vec::new();
203
204 for entry in entries {
205 if entry.is_dir {
206 continue;
207 }
208
209 let ext = match entry.path.extension().and_then(|e| e.to_str()) {
210 Some(e) => e,
211 None => continue,
212 };
213
214 if crate::lang::language_for_extension(ext).is_none() {
215 continue;
216 }
217
218 match edit_rename_in_file(&entry.path, old_name, new_name, None) {
219 Ok(output) => {
220 if output.occurrences_renamed > 0 {
221 results.push(FileRenameResult {
222 path: entry.path.display().to_string(),
223 occurrences_renamed: output.occurrences_renamed,
224 });
225 }
226 }
227 Err(e) => {
228 errors.push(FileRenameError {
229 path: entry.path.display().to_string(),
230 error: e.to_string(),
231 });
232 }
233 }
234 }
235
236 Ok((results, errors))
237}
238
239pub fn edit_insert_at_symbol(
240 path: &Path,
241 symbol_name: &str,
242 position: InsertPosition,
243 content: &str,
244) -> Result<EditInsertOutput, EditError> {
245 if path.is_dir() {
246 return Err(EditError::NotAFile(path.to_path_buf()));
247 }
248
249 let ext = path
250 .extension()
251 .and_then(|e| e.to_str())
252 .ok_or_else(|| EditError::UnsupportedLanguage("no extension".to_string()))?;
253
254 let language = crate::lang::language_for_extension(ext)
255 .ok_or_else(|| EditError::UnsupportedLanguage(ext.to_string()))?;
256
257 let source = std::fs::read_to_string(path)?;
258
259 let captures = crate::execute_query(language, &source, IDENTIFIER_QUERY)
260 .map_err(|_| EditError::UnsupportedLanguage(language.to_string()))?;
261
262 let target = captures
263 .iter()
264 .find(|c| c.text == symbol_name)
265 .ok_or_else(|| EditError::SymbolNotFound {
266 name: symbol_name.to_string(),
267 path: path.display().to_string(),
268 })?;
269
270 let byte_offset = match position {
271 InsertPosition::Before => target.start_byte,
272 InsertPosition::After => target.end_byte,
273 };
274
275 let mut bytes: Vec<u8> = source.into_bytes();
276 bytes.splice(byte_offset..byte_offset, content.bytes());
277
278 let updated = String::from_utf8(bytes).map_err(|_| {
279 EditError::Io(std::io::Error::new(
280 std::io::ErrorKind::InvalidData,
281 "invalid UTF-8 after insert",
282 ))
283 })?;
284
285 std::fs::write(path, &updated)?;
286
287 let position_str = match position {
288 InsertPosition::Before => "before",
289 InsertPosition::After => "after",
290 };
291
292 Ok(EditInsertOutput {
293 path: path.display().to_string(),
294 symbol_name: symbol_name.to_string(),
295 position: position_str.to_string(),
296 byte_offset,
297 })
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use std::io::Write;
304 use tempfile::NamedTempFile;
305
306 fn make_temp_file(content: &str) -> NamedTempFile {
307 let mut f = NamedTempFile::new().unwrap();
308 write!(f, "{}", content).unwrap();
309 f
310 }
311
312 #[test]
313 fn edit_overwrite_content_creates_new_file() {
314 let dir = tempfile::tempdir().unwrap();
315 let path = dir.path().join("new.txt");
316 let result = edit_overwrite_content(&path, "hello world").unwrap();
317 assert_eq!(result.bytes_written, 11);
318 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
319 }
320
321 #[test]
322 fn edit_overwrite_content_overwrites_existing() {
323 let dir = tempfile::tempdir().unwrap();
324 let path = dir.path().join("existing.txt");
325 std::fs::write(&path, "old content").unwrap();
326 let result = edit_overwrite_content(&path, "new content").unwrap();
327 assert_eq!(result.bytes_written, 11);
328 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
329 }
330
331 #[test]
332 fn edit_overwrite_content_creates_parent_dirs() {
333 let dir = tempfile::tempdir().unwrap();
334 let path = dir.path().join("a").join("b").join("c.txt");
335 let result = edit_overwrite_content(&path, "nested").unwrap();
336 assert_eq!(result.bytes_written, 6);
337 assert!(path.exists());
338 }
339
340 #[test]
341 fn edit_overwrite_content_directory_guard() {
342 let dir = tempfile::tempdir().unwrap();
343 let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
344 assert!(matches!(err, EditError::NotAFile(_)));
345 }
346
347 #[test]
348 fn edit_replace_block_happy_path() {
349 let dir = tempfile::tempdir().unwrap();
350 let path = dir.path().join("file.txt");
351 std::fs::write(&path, "foo bar baz").unwrap();
352 let result = edit_replace_block(&path, "bar", "qux").unwrap();
353 assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
354 assert_eq!(result.bytes_before, 11);
355 assert_eq!(result.bytes_after, 11);
356 }
357
358 #[test]
359 fn edit_replace_block_not_found() {
360 let dir = tempfile::tempdir().unwrap();
361 let path = dir.path().join("file.txt");
362 std::fs::write(&path, "foo bar baz").unwrap();
363 let err = edit_replace_block(&path, "missing", "x").unwrap_err();
364 assert!(matches!(err, EditError::NotFound { .. }));
365 }
366
367 #[test]
368 fn edit_replace_block_ambiguous() {
369 let dir = tempfile::tempdir().unwrap();
370 let path = dir.path().join("file.txt");
371 std::fs::write(&path, "foo foo baz").unwrap();
372 let err = edit_replace_block(&path, "foo", "x").unwrap_err();
373 assert!(matches!(err, EditError::Ambiguous { count: 2, .. }));
374 }
375
376 #[test]
377 fn edit_replace_block_directory_guard() {
378 let dir = tempfile::tempdir().unwrap();
379 let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
380 assert!(matches!(err, EditError::NotAFile(_)));
381 }
382
383 fn write_temp(content: &str, ext: &str) -> tempfile::NamedTempFile {
384 let mut f = tempfile::Builder::new().suffix(ext).tempfile().unwrap();
385 f.write_all(content.as_bytes()).unwrap();
386 f.flush().unwrap();
387 f
388 }
389
390 #[test]
391 fn edit_rename_in_file_renames_identifier_not_comment() {
392 let src = "fn foo() {}\n// foo is a function\n";
393 let f = write_temp(src, ".rs");
394 let out = edit_rename_in_file(f.path(), "foo", "bar", None).unwrap();
395 assert_eq!(out.occurrences_renamed, 1);
396 let updated = std::fs::read_to_string(f.path()).unwrap();
397 assert!(updated.contains("fn bar()"));
398 assert!(updated.contains("// foo is a function"));
399 }
400
401 #[test]
402 fn edit_rename_in_file_not_found_error() {
403 let f = write_temp("fn foo() {}\n", ".rs");
404 let err = edit_rename_in_file(f.path(), "missing", "bar", None).unwrap_err();
405 assert!(matches!(err, EditError::SymbolNotFound { .. }));
406 }
407
408 #[test]
409 fn edit_rename_in_file_kind_returns_kind_filter_unsupported() {
410 let f = write_temp("fn foo() {}\n", ".rs");
411 let err = edit_rename_in_file(f.path(), "foo", "bar", Some("function")).unwrap_err();
412 assert!(matches!(err, EditError::KindFilterUnsupported));
413 }
414
415 #[test]
416 fn edit_rename_in_file_unsupported_extension() {
417 let f = write_temp("foo bar\n", ".txt");
418 let err = edit_rename_in_file(f.path(), "foo", "bar", None).unwrap_err();
419 assert!(matches!(err, EditError::UnsupportedLanguage(_)));
420 }
421
422 #[test]
423 fn edit_insert_at_symbol_before() {
424 let src = "fn foo() {}\n";
425 let f = write_temp(src, ".rs");
426 let out = edit_insert_at_symbol(f.path(), "foo", InsertPosition::Before, "bar_").unwrap();
427 let updated = std::fs::read_to_string(f.path()).unwrap();
428 assert!(updated.contains("fn bar_foo()"));
429 assert_eq!(out.position, "before");
430 }
431
432 #[test]
433 fn edit_insert_at_symbol_after() {
434 let src = "fn foo() {}\n";
435 let f = write_temp(src, ".rs");
436 let out =
437 edit_insert_at_symbol(f.path(), "foo", InsertPosition::After, "_renamed").unwrap();
438 let updated = std::fs::read_to_string(f.path()).unwrap();
439 assert!(updated.contains("fn foo_renamed()"));
440 assert_eq!(out.position, "after");
441 }
442
443 #[test]
444 fn edit_insert_at_symbol_not_found_error() {
445 let f = write_temp("fn foo() {}\n", ".rs");
446 let err =
447 edit_insert_at_symbol(f.path(), "missing", InsertPosition::Before, "x").unwrap_err();
448 assert!(matches!(err, EditError::SymbolNotFound { .. }));
449 }
450
451 #[test]
452 fn edit_insert_at_symbol_unsupported_extension() {
453 let f = write_temp("foo bar\n", ".txt");
454 let err = edit_insert_at_symbol(f.path(), "foo", InsertPosition::Before, "x").unwrap_err();
455 assert!(matches!(err, EditError::UnsupportedLanguage(_)));
456 }
457
458 #[test]
459 fn edit_rename_directory_multi_file() {
460 let dir = tempfile::tempdir().unwrap();
461 let file1 = dir.path().join("file1.rs");
462 let file2 = dir.path().join("file2.rs");
463 std::fs::write(&file1, "fn foo() {}\n").unwrap();
464 std::fs::write(&file2, "fn foo() { foo(); }\n").unwrap();
465
466 let (results, errors) = edit_rename_directory(dir.path(), "foo", "bar", None).unwrap();
467
468 assert_eq!(errors.len(), 0);
469 assert_eq!(results.len(), 2);
470 assert_eq!(results[0].occurrences_renamed, 1);
471 assert_eq!(results[1].occurrences_renamed, 2);
472
473 let content1 = std::fs::read_to_string(&file1).unwrap();
474 let content2 = std::fs::read_to_string(&file2).unwrap();
475 assert!(content1.contains("fn bar()"));
476 assert!(content2.contains("fn bar() { bar(); }"));
477 }
478
479 #[test]
480 fn edit_rename_directory_partial_failure() {
481 let dir = tempfile::tempdir().unwrap();
482 let file1 = dir.path().join("file1.rs");
483 let file2 = dir.path().join("file2.rs");
484 std::fs::write(&file1, "fn foo() {}\n").unwrap();
485 std::fs::write(&file2, "fn foo() {}\n").unwrap();
486
487 let (results, errors) = edit_rename_directory(dir.path(), "foo", "bar", None).unwrap();
488
489 assert_eq!(errors.len(), 0);
490 assert_eq!(results.len(), 2);
491
492 let content1 = std::fs::read_to_string(&file1).unwrap();
493 let content2 = std::fs::read_to_string(&file2).unwrap();
494 assert!(content1.contains("fn bar()"));
495 assert!(content2.contains("fn bar()"));
496 }
497}