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    #[error("edit_replace invalid params: {0}")]
39    InvalidParams(String),
40}
41
42fn write_file_atomic(path: &Path, content: &str) -> Result<(), EditError> {
43    let parent = path.parent().ok_or_else(|| {
44        EditError::Io(std::io::Error::new(
45            std::io::ErrorKind::InvalidInput,
46            "path has no parent directory",
47        ))
48    })?;
49    let mut temp_file = NamedTempFile::new_in(parent)?;
50    use std::io::Write;
51    temp_file.write_all(content.as_bytes())?;
52    temp_file.persist(path).map_err(|e| e.error)?;
53    Ok(())
54}
55
56/// Normalize content for matching: replace `\r\n` with `\n`.
57/// Single `\r` bytes are left unchanged.
58fn normalize_for_match(s: &str) -> String {
59    s.replace("\r\n", "\n")
60}
61
62/// Map a byte offset in normalized content (CRLF -> LF) back to the corresponding
63/// byte offset in the original content, starting from `original_start`.
64fn norm_offset_to_original_from(
65    original: &str,
66    norm_offset: usize,
67    original_start: usize,
68) -> usize {
69    // Performance: O(n) byte walk is acceptable for the file sizes MCP tools operate on
70    // (source files, typically <1 MB). If very large file support becomes a requirement,
71    // a pre-built CRLF offset index could reduce this to O(log n) per lookup.
72    let bytes = original.as_bytes();
73    let mut norm_pos = 0usize;
74    let mut i = original_start;
75    while i < bytes.len() && norm_pos < norm_offset {
76        if bytes[i] == b'\r' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
77            norm_pos += 1;
78            i += 2;
79        } else {
80            norm_pos += 1;
81            i += 1;
82        }
83    }
84    i
85}
86
87/// Map a byte offset in normalized content back to the corresponding byte offset
88/// in the original content.
89fn norm_offset_to_original(original: &str, norm_offset: usize) -> usize {
90    norm_offset_to_original_from(original, norm_offset, 0)
91}
92
93pub fn edit_overwrite_content(
94    path: &Path,
95    content: &str,
96) -> Result<EditOverwriteOutput, EditError> {
97    if path.is_dir() {
98        return Err(EditError::NotAFile(path.to_path_buf()));
99    }
100    if let Some(parent) = path.parent()
101        && !parent.as_os_str().is_empty()
102    {
103        std::fs::create_dir_all(parent)?;
104    }
105    write_file_atomic(path, content)?;
106    Ok(EditOverwriteOutput {
107        path: path.display().to_string(),
108        bytes_written: content.len(),
109    })
110}
111
112pub fn edit_replace_block(
113    path: &Path,
114    old_text: &str,
115    new_text: &str,
116) -> Result<EditReplaceOutput, EditError> {
117    edit_replace_block_inner(path, old_text, new_text, false)
118}
119
120/// Same as `edit_replace_block` but with an explicit `replace_all` flag.
121/// When `replace_all` is true, all non-overlapping occurrences of `old_text`
122/// are replaced in a single pass.
123pub fn edit_replace_block_with_options(
124    path: &Path,
125    old_text: &str,
126    new_text: &str,
127    replace_all: bool,
128) -> Result<EditReplaceOutput, EditError> {
129    edit_replace_block_inner(path, old_text, new_text, replace_all)
130}
131
132pub(crate) fn edit_replace_block_inner(
133    path: &Path,
134    old_text: &str,
135    new_text: &str,
136    replace_all: bool,
137) -> Result<EditReplaceOutput, EditError> {
138    if path.is_dir() {
139        return Err(EditError::NotAFile(path.to_path_buf()));
140    }
141    let content = std::fs::read_to_string(path)?;
142    let norm_content = normalize_for_match(&content);
143    let norm_old = normalize_for_match(old_text);
144    if norm_old.is_empty() {
145        return Err(EditError::InvalidParams(
146            "old_text must not be empty".to_string(),
147        ));
148    }
149    let count = norm_content.matches(&norm_old).count();
150    match count {
151        0 => {
152            let first_20_lines = content.lines().take(20).collect::<Vec<_>>().join("\n");
153            return Err(EditError::NotFound {
154                path: path.display().to_string(),
155                first_20_lines,
156            });
157        }
158        1 if !replace_all => {}
159        n if !replace_all => {
160            let match_lines: Vec<usize> = norm_content
161                .match_indices(&norm_old)
162                .map(|(offset, _)| {
163                    norm_content[..offset]
164                        .bytes()
165                        .filter(|&b| b == b'\n')
166                        .count()
167                        + 1
168                })
169                .collect();
170            return Err(EditError::Ambiguous {
171                count: n,
172                path: path.display().to_string(),
173                match_lines,
174            });
175        }
176        _ => {} // replace_all=true: fall through to single-pass splice
177    }
178    let bytes_before = content.len();
179
180    if replace_all {
181        // Single-pass over original normalized content: collect all match spans,
182        // then splice new_text between unmatched spans in original byte space.
183        let mut matches: Vec<(usize, usize)> = Vec::new();
184        let mut search_from_original = 0usize;
185        let mut norm_consumed = 0usize;
186        for (norm_start, _m) in norm_content.match_indices(&norm_old) {
187            let original_start = norm_offset_to_original_from(
188                &content,
189                norm_start - norm_consumed,
190                search_from_original,
191            );
192            let original_end =
193                norm_offset_to_original_from(&content, norm_old.len(), original_start);
194            matches.push((original_start, original_end));
195            search_from_original = original_end;
196            norm_consumed = norm_start + norm_old.len();
197        }
198        let occurrences_replaced = matches.len();
199        let old_span_total: usize = matches.iter().map(|(s, e)| e - s).sum();
200        // capacity upper bound: existing bytes + new bytes added - old bytes removed
201        // saturating_mul guards against overflow on 32-bit targets with pathological inputs
202        let mut result = String::with_capacity(
203            bytes_before + new_text.len().saturating_mul(occurrences_replaced) - old_span_total,
204        );
205        let mut last_end = 0usize;
206        for (start, end) in &matches {
207            result.push_str(&content[last_end..*start]);
208            result.push_str(new_text);
209            last_end = *end;
210        }
211        result.push_str(&content[last_end..]);
212        let bytes_after = result.len();
213        write_file_atomic(path, &result)?;
214        Ok(EditReplaceOutput {
215            path: path.display().to_string(),
216            bytes_before,
217            bytes_after,
218            occurrences_replaced,
219        })
220    } else {
221        // Single-match path (existing behavior)
222        // SAFETY: match was verified above via count check; find must succeed.
223        // If count verification logic changes, this expect() site must be re-audited.
224        #[allow(clippy::expect_used)]
225        let norm_match_offset = norm_content
226            .find(&norm_old)
227            .expect("match was verified above via count check; find must succeed");
228        let original_start = norm_offset_to_original(&content, norm_match_offset);
229        let original_end = norm_offset_to_original_from(&content, norm_old.len(), original_start);
230        let updated = [
231            &content[..original_start],
232            new_text,
233            &content[original_end..],
234        ]
235        .concat();
236        let bytes_after = updated.len();
237        write_file_atomic(path, &updated)?;
238        Ok(EditReplaceOutput {
239            path: path.display().to_string(),
240            bytes_before,
241            bytes_after,
242            occurrences_replaced: 1,
243        })
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn edit_overwrite_content_creates_new_file() {
253        let dir = tempfile::tempdir().unwrap();
254        let path = dir.path().join("new.txt");
255        let result = edit_overwrite_content(&path, "hello world").unwrap();
256        assert_eq!(result.bytes_written, 11);
257        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
258    }
259
260    #[test]
261    fn edit_overwrite_content_overwrites_existing() {
262        let dir = tempfile::tempdir().unwrap();
263        let path = dir.path().join("existing.txt");
264        std::fs::write(&path, "old content").unwrap();
265        let result = edit_overwrite_content(&path, "new content").unwrap();
266        assert_eq!(result.bytes_written, 11);
267        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
268    }
269
270    #[test]
271    fn edit_overwrite_content_creates_parent_dirs() {
272        let dir = tempfile::tempdir().unwrap();
273        let path = dir.path().join("a").join("b").join("c.txt");
274        let result = edit_overwrite_content(&path, "nested").unwrap();
275        assert_eq!(result.bytes_written, 6);
276        assert!(path.exists());
277    }
278
279    #[test]
280    fn edit_overwrite_content_directory_guard() {
281        let dir = tempfile::tempdir().unwrap();
282        let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
283        std::assert_matches!(err, EditError::NotAFile(_));
284    }
285
286    #[test]
287    fn edit_replace_block_happy_path() {
288        let dir = tempfile::tempdir().unwrap();
289        let path = dir.path().join("file.txt");
290        std::fs::write(&path, "foo bar baz").unwrap();
291        let result = edit_replace_block(&path, "bar", "qux").unwrap();
292        assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
293        assert_eq!(result.bytes_before, 11);
294        assert_eq!(result.bytes_after, 11);
295    }
296
297    #[test]
298    fn edit_replace_block_not_found() {
299        let dir = tempfile::tempdir().unwrap();
300        let path = dir.path().join("file.txt");
301        std::fs::write(&path, "foo bar baz").unwrap();
302        let err = edit_replace_block(&path, "missing", "x").unwrap_err();
303        std::assert_matches!(&err, EditError::NotFound { first_20_lines, .. } if !first_20_lines.is_empty());
304    }
305
306    #[test]
307    fn edit_replace_block_ambiguous() {
308        let dir = tempfile::tempdir().unwrap();
309        let path = dir.path().join("file.txt");
310        std::fs::write(&path, "foo foo baz").unwrap();
311        let err = edit_replace_block(&path, "foo", "x").unwrap_err();
312        std::assert_matches!(&err, EditError::Ambiguous { count: 2, match_lines, .. } if match_lines == &[1, 1]);
313    }
314
315    #[test]
316    fn edit_replace_block_directory_guard() {
317        let dir = tempfile::tempdir().unwrap();
318        let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
319        std::assert_matches!(err, EditError::NotAFile(_));
320    }
321
322    #[test]
323    fn edit_replace_block_crlf_file_lf_oldtext() {
324        // CRLF file + LF old_text => match succeeds and non-replaced lines retain CRLF bytes
325        let dir = tempfile::tempdir().unwrap();
326        let path = dir.path().join("crlf.txt");
327        // Write raw CRLF bytes: "foo\r\nbar\r\nbaz"
328        std::fs::write(&path, b"foo\r\nbar\r\nbaz").unwrap();
329        let result = edit_replace_block(&path, "bar", "qux").unwrap();
330        // The result should contain "foo\r\nqux\r\nbaz" (non-replaced lines retain CRLF)
331        let output = std::fs::read_to_string(&path).unwrap();
332        assert_eq!(output, "foo\r\nqux\r\nbaz");
333        assert_eq!(result.bytes_before, 13); // "foo\r\nbar\r\nbaz" = 13 bytes
334        assert_eq!(result.bytes_after, 13); // "foo\r\nqux\r\nbaz" = 13 bytes
335    }
336
337    #[test]
338    fn edit_replace_block_lf_file_crlf_oldtext() {
339        // LF file + CRLF old_text => match succeeds
340        let dir = tempfile::tempdir().unwrap();
341        let path = dir.path().join("lf.txt");
342        std::fs::write(&path, b"foo\nbar\nbaz").unwrap();
343        let result = edit_replace_block(&path, "bar\r\n", "qux\n").unwrap();
344        // old_text "bar\r\n" is normalized to "bar\n", matches "bar\n" in file
345        let output = std::fs::read_to_string(&path).unwrap();
346        assert_eq!(output, "foo\nqux\nbaz");
347        assert_eq!(result.bytes_before, 11); // "foo\nbar\nbaz" = 11 bytes
348        assert_eq!(result.bytes_after, 11); // "foo\nqux\nbaz" = 11 bytes
349    }
350
351    #[test]
352    fn edit_replace_block_crlf_file_crlf_oldtext() {
353        // CRLF file + CRLF old_text => both normalized, match succeeds
354        let dir = tempfile::tempdir().unwrap();
355        let path = dir.path().join("bothcrlf.txt");
356        std::fs::write(&path, b"line1\r\nline2\r\nline3").unwrap();
357        let result = edit_replace_block(&path, "line2\r\n", "replaced\n").unwrap();
358        let output = std::fs::read_to_string(&path).unwrap();
359        assert_eq!(output, "line1\r\nreplaced\nline3");
360        assert_eq!(result.bytes_before, 19); // "line1\r\nline2\r\nline3" = 19 bytes
361    }
362
363    #[test]
364    fn edit_replace_block_trailing_spaces_distinct() {
365        // Two blocks differing only by trailing spaces remain distinct after normalization
366        let dir = tempfile::tempdir().unwrap();
367        let path = dir.path().join("spaces.txt");
368        std::fs::write(&path, "foo  \nbar\nfoo\nbar").unwrap();
369        // old_text "foo\nbar" should match the SECOND occurrence ("foo\nbar"),
370        // not the first ("foo  \nbar"), because trailing spaces are not stripped
371        let result = edit_replace_block(&path, "foo\nbar", "replaced").unwrap();
372        let output = std::fs::read_to_string(&path).unwrap();
373        assert_eq!(output, "foo  \nbar\nreplaced");
374        assert_eq!(result.bytes_before, 17); // "foo  \nbar\nfoo\nbar" = 17 bytes
375        assert_eq!(result.bytes_after, 18); // "foo  \nbar\nreplaced" = 18 bytes
376    }
377
378    #[test]
379    fn edit_replace_block_replace_all_three_occurrences() {
380        let dir = tempfile::tempdir().unwrap();
381        let path = dir.path().join("all.txt");
382        std::fs::write(&path, "a b a c a d").unwrap();
383        let result = edit_replace_block_with_options(&path, "a", "x", true).unwrap();
384        assert_eq!(std::fs::read_to_string(&path).unwrap(), "x b x c x d");
385        assert_eq!(result.bytes_before, 11);
386        assert_eq!(result.bytes_after, 11);
387        assert_eq!(result.occurrences_replaced, 3);
388    }
389
390    #[test]
391    fn edit_replace_block_replace_all_not_found() {
392        let dir = tempfile::tempdir().unwrap();
393        let path = dir.path().join("nf.txt");
394        std::fs::write(&path, "foo bar baz").unwrap();
395        let err = edit_replace_block_with_options(&path, "missing", "x", true).unwrap_err();
396        std::assert_matches!(&err, EditError::NotFound { .. });
397    }
398
399    #[test]
400    fn edit_replace_block_replace_all_empty_oldtext() {
401        let dir = tempfile::tempdir().unwrap();
402        let path = dir.path().join("empty.txt");
403        std::fs::write(&path, "foo bar baz").unwrap();
404        let err = edit_replace_block_with_options(&path, "", "x", true).unwrap_err();
405        std::assert_matches!(&err, EditError::InvalidParams(_));
406    }
407
408    #[test]
409    fn edit_replace_block_replace_all_preserves_crlf() {
410        // CRLF file with replace_all: unmatched spans retain CRLF bytes
411        let dir = tempfile::tempdir().unwrap();
412        let path = dir.path().join("crlf_all.txt");
413        std::fs::write(&path, b"a\r\nb\r\na\r\nc").unwrap();
414        let result = edit_replace_block_with_options(&path, "a", "x", true).unwrap();
415        assert_eq!(std::fs::read_to_string(&path).unwrap(), "x\r\nb\r\nx\r\nc");
416        assert_eq!(result.occurrences_replaced, 2);
417    }
418
419    #[test]
420    fn replace_all_deletes_all_occurrences() {
421        let dir = tempfile::tempdir().unwrap();
422        let path = dir.path().join("delete.txt");
423        std::fs::write(&path, "a b a c a d").unwrap();
424        let result = edit_replace_block_with_options(&path, "a", "", true).unwrap();
425        assert_eq!(std::fs::read_to_string(&path).unwrap(), " b  c  d");
426        assert_eq!(result.bytes_before, 11);
427        assert_eq!(result.bytes_after, 8);
428        assert_eq!(result.occurrences_replaced, 3);
429    }
430
431    #[test]
432    fn replace_all_non_overlap_adjacent() {
433        let dir = tempfile::tempdir().unwrap();
434        let path = dir.path().join("adjacent.txt");
435        std::fs::write(&path, "aaaa").unwrap();
436        let result = edit_replace_block_with_options(&path, "aa", "xx", true).unwrap();
437        assert_eq!(std::fs::read_to_string(&path).unwrap(), "xxxx");
438        assert_eq!(result.occurrences_replaced, 2);
439    }
440
441    #[test]
442    fn replace_all_size_changing() {
443        let dir = tempfile::tempdir().unwrap();
444        let path = dir.path().join("size.txt");
445        std::fs::write(&path, "x y x z x").unwrap();
446        let result = edit_replace_block_with_options(&path, "x", "yyy", true).unwrap();
447        assert_eq!(std::fs::read_to_string(&path).unwrap(), "yyy y yyy z yyy");
448        assert_eq!(result.bytes_before, 9);
449        assert_eq!(result.bytes_after, 15);
450        assert_eq!(result.occurrences_replaced, 3);
451    }
452
453    #[test]
454    fn replace_all_empty_oldtext_no_replace_all_returns_invalid_params() {
455        let dir = tempfile::tempdir().unwrap();
456        let path = dir.path().join("empty.txt");
457        std::fs::write(&path, "foo bar baz").unwrap();
458        let err = edit_replace_block(&path, "", "x").unwrap_err();
459        std::assert_matches!(&err, EditError::InvalidParams(_));
460    }
461}