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#[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}