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, 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}