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    // SAFETY: match was verified above via count check; find must succeed.
152    // If count verification logic changes, this expect() site must be re-audited.
153    #[allow(clippy::expect_used)]
154    let norm_match_offset = norm_content
155        .find(&norm_old)
156        .expect("match was verified above via count check; find must succeed");
157    let original_start = norm_offset_to_original(&content, norm_match_offset);
158    let original_end = norm_offset_to_original_from(&content, norm_old.len(), original_start);
159    let updated = [
160        &content[..original_start],
161        new_text,
162        &content[original_end..],
163    ]
164    .concat();
165    let bytes_after = updated.len();
166    write_file_atomic(path, &updated)?;
167    Ok(EditReplaceOutput {
168        path: path.display().to_string(),
169        bytes_before,
170        bytes_after,
171    })
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn edit_overwrite_content_creates_new_file() {
180        let dir = tempfile::tempdir().unwrap();
181        let path = dir.path().join("new.txt");
182        let result = edit_overwrite_content(&path, "hello world").unwrap();
183        assert_eq!(result.bytes_written, 11);
184        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
185    }
186
187    #[test]
188    fn edit_overwrite_content_overwrites_existing() {
189        let dir = tempfile::tempdir().unwrap();
190        let path = dir.path().join("existing.txt");
191        std::fs::write(&path, "old content").unwrap();
192        let result = edit_overwrite_content(&path, "new content").unwrap();
193        assert_eq!(result.bytes_written, 11);
194        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
195    }
196
197    #[test]
198    fn edit_overwrite_content_creates_parent_dirs() {
199        let dir = tempfile::tempdir().unwrap();
200        let path = dir.path().join("a").join("b").join("c.txt");
201        let result = edit_overwrite_content(&path, "nested").unwrap();
202        assert_eq!(result.bytes_written, 6);
203        assert!(path.exists());
204    }
205
206    #[test]
207    fn edit_overwrite_content_directory_guard() {
208        let dir = tempfile::tempdir().unwrap();
209        let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
210        std::assert_matches!(err, EditError::NotAFile(_));
211    }
212
213    #[test]
214    fn edit_replace_block_happy_path() {
215        let dir = tempfile::tempdir().unwrap();
216        let path = dir.path().join("file.txt");
217        std::fs::write(&path, "foo bar baz").unwrap();
218        let result = edit_replace_block(&path, "bar", "qux").unwrap();
219        assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
220        assert_eq!(result.bytes_before, 11);
221        assert_eq!(result.bytes_after, 11);
222    }
223
224    #[test]
225    fn edit_replace_block_not_found() {
226        let dir = tempfile::tempdir().unwrap();
227        let path = dir.path().join("file.txt");
228        std::fs::write(&path, "foo bar baz").unwrap();
229        let err = edit_replace_block(&path, "missing", "x").unwrap_err();
230        std::assert_matches!(&err, EditError::NotFound { first_20_lines, .. } if !first_20_lines.is_empty());
231    }
232
233    #[test]
234    fn edit_replace_block_ambiguous() {
235        let dir = tempfile::tempdir().unwrap();
236        let path = dir.path().join("file.txt");
237        std::fs::write(&path, "foo foo baz").unwrap();
238        let err = edit_replace_block(&path, "foo", "x").unwrap_err();
239        std::assert_matches!(&err, EditError::Ambiguous { count: 2, match_lines, .. } if match_lines == &[1, 1]);
240    }
241
242    #[test]
243    fn edit_replace_block_directory_guard() {
244        let dir = tempfile::tempdir().unwrap();
245        let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
246        std::assert_matches!(err, EditError::NotAFile(_));
247    }
248
249    #[test]
250    fn edit_replace_block_crlf_file_lf_oldtext() {
251        // CRLF file + LF old_text => match succeeds and non-replaced lines retain CRLF bytes
252        let dir = tempfile::tempdir().unwrap();
253        let path = dir.path().join("crlf.txt");
254        // Write raw CRLF bytes: "foo\r\nbar\r\nbaz"
255        std::fs::write(&path, b"foo\r\nbar\r\nbaz").unwrap();
256        let result = edit_replace_block(&path, "bar", "qux").unwrap();
257        // The result should contain "foo\r\nqux\r\nbaz" (non-replaced lines retain CRLF)
258        let output = std::fs::read_to_string(&path).unwrap();
259        assert_eq!(output, "foo\r\nqux\r\nbaz");
260        assert_eq!(result.bytes_before, 13); // "foo\r\nbar\r\nbaz" = 13 bytes
261        assert_eq!(result.bytes_after, 13); // "foo\r\nqux\r\nbaz" = 13 bytes
262    }
263
264    #[test]
265    fn edit_replace_block_lf_file_crlf_oldtext() {
266        // LF file + CRLF old_text => match succeeds
267        let dir = tempfile::tempdir().unwrap();
268        let path = dir.path().join("lf.txt");
269        std::fs::write(&path, b"foo\nbar\nbaz").unwrap();
270        let result = edit_replace_block(&path, "bar\r\n", "qux\n").unwrap();
271        // old_text "bar\r\n" is normalized to "bar\n", matches "bar\n" in file
272        let output = std::fs::read_to_string(&path).unwrap();
273        assert_eq!(output, "foo\nqux\nbaz");
274        assert_eq!(result.bytes_before, 11); // "foo\nbar\nbaz" = 11 bytes
275        assert_eq!(result.bytes_after, 11); // "foo\nqux\nbaz" = 11 bytes
276    }
277
278    #[test]
279    fn edit_replace_block_crlf_file_crlf_oldtext() {
280        // CRLF file + CRLF old_text => both normalized, match succeeds
281        let dir = tempfile::tempdir().unwrap();
282        let path = dir.path().join("bothcrlf.txt");
283        std::fs::write(&path, b"line1\r\nline2\r\nline3").unwrap();
284        let result = edit_replace_block(&path, "line2\r\n", "replaced\n").unwrap();
285        let output = std::fs::read_to_string(&path).unwrap();
286        assert_eq!(output, "line1\r\nreplaced\nline3");
287        assert_eq!(result.bytes_before, 19); // "line1\r\nline2\r\nline3" = 19 bytes
288    }
289
290    #[test]
291    fn edit_replace_block_trailing_spaces_distinct() {
292        // Two blocks differing only by trailing spaces remain distinct after normalization
293        let dir = tempfile::tempdir().unwrap();
294        let path = dir.path().join("spaces.txt");
295        std::fs::write(&path, "foo  \nbar\nfoo\nbar").unwrap();
296        // old_text "foo\nbar" should match the SECOND occurrence ("foo\nbar"),
297        // not the first ("foo  \nbar"), because trailing spaces are not stripped
298        let result = edit_replace_block(&path, "foo\nbar", "replaced").unwrap();
299        let output = std::fs::read_to_string(&path).unwrap();
300        assert_eq!(output, "foo  \nbar\nreplaced");
301        assert_eq!(result.bytes_before, 17); // "foo  \nbar\nfoo\nbar" = 17 bytes
302        assert_eq!(result.bytes_after, 18); // "foo  \nbar\nreplaced" = 18 bytes
303    }
304}