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