Skip to main content

aptu_coder_core/
edit.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! File write utilities for the `edit_overwrite`, `edit_replace`, `edit_rename`, and `edit_insert` tools.
4
5use 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}