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` and `edit_replace` tools.
4
5use crate::types::{EditOverwriteOutput, EditReplaceOutput};
6use std::path::{Path, PathBuf};
7use tempfile::NamedTempFile;
8use thiserror::Error;
9
10#[non_exhaustive]
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 {
27        path: String,
28        first_20_lines: String,
29    },
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 {
34        count: usize,
35        path: String,
36        match_lines: Vec<usize>,
37    },
38}
39
40fn write_file_atomic(path: &Path, content: &str) -> Result<(), EditError> {
41    let parent = path.parent().ok_or_else(|| {
42        EditError::Io(std::io::Error::new(
43            std::io::ErrorKind::InvalidInput,
44            "path has no parent directory",
45        ))
46    })?;
47    let mut temp_file = NamedTempFile::new_in(parent)?;
48    use std::io::Write;
49    temp_file.write_all(content.as_bytes())?;
50    temp_file.persist(path).map_err(|e| e.error)?;
51    Ok(())
52}
53
54/// Normalize content for matching: replace `\r\n` with `\n`.
55/// Single `\r` bytes are left unchanged.
56fn normalize_for_match(s: &str) -> String {
57    s.replace("\r\n", "\n")
58}
59
60/// Map a byte offset in normalized content (CRLF -> LF) back to the corresponding
61/// byte offset in the original content, starting from `original_start`.
62fn norm_offset_to_original_from(
63    original: &str,
64    norm_offset: usize,
65    original_start: usize,
66) -> usize {
67    // Performance: O(n) byte walk is acceptable for the file sizes MCP tools operate on
68    // (source files, typically <1 MB). If very large file support becomes a requirement,
69    // a pre-built CRLF offset index could reduce this to O(log n) per lookup.
70    let bytes = original.as_bytes();
71    let mut norm_pos = 0usize;
72    let mut i = original_start;
73    while i < bytes.len() && norm_pos < norm_offset {
74        if bytes[i] == b'\r' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
75            norm_pos += 1;
76            i += 2;
77        } else {
78            norm_pos += 1;
79            i += 1;
80        }
81    }
82    i
83}
84
85/// Map a byte offset in normalized content back to the corresponding byte offset
86/// in the original content.
87fn norm_offset_to_original(original: &str, norm_offset: usize) -> usize {
88    norm_offset_to_original_from(original, norm_offset, 0)
89}
90
91pub fn edit_overwrite_content(
92    path: &Path,
93    content: &str,
94) -> Result<EditOverwriteOutput, EditError> {
95    if path.is_dir() {
96        return Err(EditError::NotAFile(path.to_path_buf()));
97    }
98    if let Some(parent) = path.parent()
99        && !parent.as_os_str().is_empty()
100    {
101        std::fs::create_dir_all(parent)?;
102    }
103    write_file_atomic(path, content)?;
104    Ok(EditOverwriteOutput {
105        path: path.display().to_string(),
106        bytes_written: content.len(),
107    })
108}
109
110pub fn edit_replace_block(
111    path: &Path,
112    old_text: &str,
113    new_text: &str,
114) -> Result<EditReplaceOutput, EditError> {
115    if path.is_dir() {
116        return Err(EditError::NotAFile(path.to_path_buf()));
117    }
118    let content = std::fs::read_to_string(path)?;
119    let norm_content = normalize_for_match(&content);
120    let norm_old = normalize_for_match(old_text);
121    let count = norm_content.matches(&norm_old).count();
122    match count {
123        0 => {
124            let first_20_lines = content.lines().take(20).collect::<Vec<_>>().join("\n");
125            return Err(EditError::NotFound {
126                path: path.display().to_string(),
127                first_20_lines,
128            });
129        }
130        1 => {}
131        n => {
132            let match_lines: Vec<usize> = norm_content
133                .match_indices(&norm_old)
134                .map(|(offset, _)| {
135                    norm_content[..offset]
136                        .bytes()
137                        .filter(|&b| b == b'\n')
138                        .count()
139                        + 1
140                })
141                .collect();
142            return Err(EditError::Ambiguous {
143                count: n,
144                path: path.display().to_string(),
145                match_lines,
146            });
147        }
148    }
149    let bytes_before = content.len();
150    // Find match offset in normalized space, then map back to original byte range
151    let norm_match_offset = norm_content
152        .find(&norm_old)
153        .expect("match was verified above via count check; find must succeed");
154    let original_start = norm_offset_to_original(&content, norm_match_offset);
155    let original_end = norm_offset_to_original_from(&content, norm_old.len(), original_start);
156    let updated = [
157        &content[..original_start],
158        new_text,
159        &content[original_end..],
160    ]
161    .concat();
162    let bytes_after = updated.len();
163    write_file_atomic(path, &updated)?;
164    Ok(EditReplaceOutput {
165        path: path.display().to_string(),
166        bytes_before,
167        bytes_after,
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn edit_overwrite_content_creates_new_file() {
177        let dir = tempfile::tempdir().unwrap();
178        let path = dir.path().join("new.txt");
179        let result = edit_overwrite_content(&path, "hello world").unwrap();
180        assert_eq!(result.bytes_written, 11);
181        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
182    }
183
184    #[test]
185    fn edit_overwrite_content_overwrites_existing() {
186        let dir = tempfile::tempdir().unwrap();
187        let path = dir.path().join("existing.txt");
188        std::fs::write(&path, "old content").unwrap();
189        let result = edit_overwrite_content(&path, "new content").unwrap();
190        assert_eq!(result.bytes_written, 11);
191        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
192    }
193
194    #[test]
195    fn edit_overwrite_content_creates_parent_dirs() {
196        let dir = tempfile::tempdir().unwrap();
197        let path = dir.path().join("a").join("b").join("c.txt");
198        let result = edit_overwrite_content(&path, "nested").unwrap();
199        assert_eq!(result.bytes_written, 6);
200        assert!(path.exists());
201    }
202
203    #[test]
204    fn edit_overwrite_content_directory_guard() {
205        let dir = tempfile::tempdir().unwrap();
206        let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
207        std::assert_matches!(err, EditError::NotAFile(_));
208    }
209
210    #[test]
211    fn edit_replace_block_happy_path() {
212        let dir = tempfile::tempdir().unwrap();
213        let path = dir.path().join("file.txt");
214        std::fs::write(&path, "foo bar baz").unwrap();
215        let result = edit_replace_block(&path, "bar", "qux").unwrap();
216        assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
217        assert_eq!(result.bytes_before, 11);
218        assert_eq!(result.bytes_after, 11);
219    }
220
221    #[test]
222    fn edit_replace_block_not_found() {
223        let dir = tempfile::tempdir().unwrap();
224        let path = dir.path().join("file.txt");
225        std::fs::write(&path, "foo bar baz").unwrap();
226        let err = edit_replace_block(&path, "missing", "x").unwrap_err();
227        std::assert_matches!(&err, EditError::NotFound { first_20_lines, .. } if !first_20_lines.is_empty());
228    }
229
230    #[test]
231    fn edit_replace_block_ambiguous() {
232        let dir = tempfile::tempdir().unwrap();
233        let path = dir.path().join("file.txt");
234        std::fs::write(&path, "foo foo baz").unwrap();
235        let err = edit_replace_block(&path, "foo", "x").unwrap_err();
236        std::assert_matches!(&err, EditError::Ambiguous { count: 2, match_lines, .. } if match_lines == &[1, 1]);
237    }
238
239    #[test]
240    fn edit_replace_block_directory_guard() {
241        let dir = tempfile::tempdir().unwrap();
242        let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
243        std::assert_matches!(err, EditError::NotAFile(_));
244    }
245
246    #[test]
247    fn edit_replace_block_crlf_file_lf_oldtext() {
248        // CRLF file + LF old_text => match succeeds and non-replaced lines retain CRLF bytes
249        let dir = tempfile::tempdir().unwrap();
250        let path = dir.path().join("crlf.txt");
251        // Write raw CRLF bytes: "foo\r\nbar\r\nbaz"
252        std::fs::write(&path, b"foo\r\nbar\r\nbaz").unwrap();
253        let result = edit_replace_block(&path, "bar", "qux").unwrap();
254        // The result should contain "foo\r\nqux\r\nbaz" (non-replaced lines retain CRLF)
255        let output = std::fs::read_to_string(&path).unwrap();
256        assert_eq!(output, "foo\r\nqux\r\nbaz");
257        assert_eq!(result.bytes_before, 13); // "foo\r\nbar\r\nbaz" = 13 bytes
258        assert_eq!(result.bytes_after, 13); // "foo\r\nqux\r\nbaz" = 13 bytes
259    }
260
261    #[test]
262    fn edit_replace_block_lf_file_crlf_oldtext() {
263        // LF file + CRLF old_text => match succeeds
264        let dir = tempfile::tempdir().unwrap();
265        let path = dir.path().join("lf.txt");
266        std::fs::write(&path, b"foo\nbar\nbaz").unwrap();
267        let result = edit_replace_block(&path, "bar\r\n", "qux\n").unwrap();
268        // old_text "bar\r\n" is normalized to "bar\n", matches "bar\n" in file
269        let output = std::fs::read_to_string(&path).unwrap();
270        assert_eq!(output, "foo\nqux\nbaz");
271        assert_eq!(result.bytes_before, 11); // "foo\nbar\nbaz" = 11 bytes
272        assert_eq!(result.bytes_after, 11); // "foo\nqux\nbaz" = 11 bytes
273    }
274
275    #[test]
276    fn edit_replace_block_crlf_file_crlf_oldtext() {
277        // CRLF file + CRLF old_text => both normalized, match succeeds
278        let dir = tempfile::tempdir().unwrap();
279        let path = dir.path().join("bothcrlf.txt");
280        std::fs::write(&path, b"line1\r\nline2\r\nline3").unwrap();
281        let result = edit_replace_block(&path, "line2\r\n", "replaced\n").unwrap();
282        let output = std::fs::read_to_string(&path).unwrap();
283        assert_eq!(output, "line1\r\nreplaced\nline3");
284        assert_eq!(result.bytes_before, 19); // "line1\r\nline2\r\nline3" = 19 bytes
285    }
286
287    #[test]
288    fn edit_replace_block_trailing_spaces_distinct() {
289        // Two blocks differing only by trailing spaces remain distinct after normalization
290        let dir = tempfile::tempdir().unwrap();
291        let path = dir.path().join("spaces.txt");
292        std::fs::write(&path, "foo  \nbar\nfoo\nbar").unwrap();
293        // old_text "foo\nbar" should match the SECOND occurrence ("foo\nbar"),
294        // not the first ("foo  \nbar"), because trailing spaces are not stripped
295        let result = edit_replace_block(&path, "foo\nbar", "replaced").unwrap();
296        let output = std::fs::read_to_string(&path).unwrap();
297        assert_eq!(output, "foo  \nbar\nreplaced");
298        assert_eq!(result.bytes_before, 17); // "foo  \nbar\nfoo\nbar" = 17 bytes
299        assert_eq!(result.bytes_after, 18); // "foo  \nbar\nreplaced" = 18 bytes
300    }
301}