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::borrow::Cow;
7use std::path::{Path, PathBuf};
8use tempfile::NamedTempFile;
9use thiserror::Error;
10
11#[non_exhaustive]
12#[derive(Debug, Error)]
13pub enum EditError {
14    #[error("I/O error: {0}")]
15    Io(#[from] std::io::Error),
16    #[error("invalid range: start ({start}) > end ({end}); file has {total} lines")]
17    InvalidRange {
18        start: usize,
19        end: usize,
20        total: usize,
21    },
22    #[error("path is a directory, not a file: {0}")]
23    NotAFile(PathBuf),
24    #[error(
25        "old_text not found in {path} — verify the text matches exactly, including whitespace and newlines"
26    )]
27    NotFound {
28        path: String,
29        first_20_lines: String,
30    },
31    #[error(
32        "old_text appears {count} times in {path} — make old_text longer and more specific to uniquely identify the block"
33    )]
34    Ambiguous {
35        count: usize,
36        path: String,
37        match_lines: Vec<usize>,
38    },
39    #[error("edit_replace invalid params: {0}")]
40    InvalidParams(String),
41    #[error(
42        "stale content hash for {path}: expected {expected} but file has {actual} — re-read the file with analyze_file or analyze_module, then retry with the current content hash"
43    )]
44    StaleContentHash {
45        expected: String,
46        actual: String,
47        path: String,
48    },
49}
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
65/// Normalize content for matching: replace `\r\n` with `\n`.
66/// Single `\r` bytes are left unchanged.
67///
68/// Returns `Cow::Borrowed` when no `\r` byte is present (fast path, zero allocation).
69/// Returns `Cow::Owned` when CRLF sequences require replacement.
70fn normalize_for_match(s: &str) -> Cow<'_, str> {
71    if !s.as_bytes().contains(&b'\r') {
72        Cow::Borrowed(s)
73    } else {
74        Cow::Owned(s.replace("\r\n", "\n"))
75    }
76}
77
78/// Build a sorted vector of normalized byte offsets at which each `\r\n`-derived `\n`
79/// occurs. Used to map normalized offsets back to original byte offsets via binary search.
80///
81/// When the vector is empty, the content has no CRLF sequences and normalized offsets
82/// are identical to original offsets (identity mapping).
83fn build_crlf_positions(original: &str) -> Vec<usize> {
84    let bytes = original.as_bytes();
85    let mut positions = Vec::new();
86    let mut norm_pos = 0usize;
87    let mut i = 0usize;
88    while i < bytes.len() {
89        if bytes[i] == b'\r' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
90            positions.push(norm_pos);
91            norm_pos += 1;
92            i += 2;
93        } else {
94            norm_pos += 1;
95            i += 1;
96        }
97    }
98    positions
99}
100
101/// Map a normalized byte offset back to the corresponding original byte offset
102/// using a pre-built CRLF position index.
103///
104/// When `crlf_positions` is empty, returns `norm_offset` unchanged (identity).
105/// Otherwise, counts how many CRLF sequences precede `norm_offset` via binary search
106/// and adds that count: `original = norm_offset + crlf_count`.
107fn norm_to_original_offset(norm_offset: usize, crlf_positions: &[usize]) -> usize {
108    if crlf_positions.is_empty() {
109        norm_offset
110    } else {
111        norm_offset + crlf_positions.partition_point(|&x| x < norm_offset)
112    }
113}
114
115pub fn edit_overwrite_content(
116    path: &Path,
117    content: &str,
118) -> Result<EditOverwriteOutput, EditError> {
119    if path.is_dir() {
120        return Err(EditError::NotAFile(path.to_path_buf()));
121    }
122    if let Some(parent) = path.parent()
123        && !parent.as_os_str().is_empty()
124    {
125        std::fs::create_dir_all(parent)?;
126    }
127    write_file_atomic(path, content)?;
128    Ok(EditOverwriteOutput {
129        path: path.display().to_string(),
130        bytes_written: content.len(),
131    })
132}
133
134pub fn edit_replace_block(
135    path: &Path,
136    old_text: &str,
137    new_text: &str,
138) -> Result<EditReplaceOutput, EditError> {
139    edit_replace_block_inner(path, old_text, new_text, false, None)
140}
141
142/// Same as `edit_replace_block` but with an explicit `replace_all` flag and an
143/// optional `expected_content_hash` for optimistic-concurrency staleness
144/// detection.
145///
146/// When `replace_all` is true, all non-overlapping occurrences of `old_text`
147/// are replaced in a single pass. When `expected_content_hash` is `Some`, the
148/// raw file bytes are hashed with blake3 and compared before the edit proceeds.
149/// A mismatch returns `EditError::StaleContentHash`.
150pub fn edit_replace_block_with_options(
151    path: &Path,
152    old_text: &str,
153    new_text: &str,
154    replace_all: bool,
155    expected_content_hash: Option<&str>,
156) -> Result<EditReplaceOutput, EditError> {
157    edit_replace_block_inner(path, old_text, new_text, replace_all, expected_content_hash)
158}
159
160pub(crate) fn edit_replace_block_inner(
161    path: &Path,
162    old_text: &str,
163    new_text: &str,
164    replace_all: bool,
165    expected_content_hash: Option<&str>,
166) -> Result<EditReplaceOutput, EditError> {
167    if path.is_dir() {
168        return Err(EditError::NotAFile(path.to_path_buf()));
169    }
170    let content = std::fs::read_to_string(path)?;
171
172    // Staleness check: hash the raw file bytes (as read by read_to_string) and compare
173    // with the caller-provided expected hash. Runs inside the per-path lock (caller
174    // acquires the lock before calling this function via spawn_blocking).
175    if let Some(expected_hash) = expected_content_hash {
176        let actual_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
177        if actual_hash != expected_hash {
178            return Err(EditError::StaleContentHash {
179                expected: expected_hash.to_string(),
180                actual: actual_hash,
181                path: path.display().to_string(),
182            });
183        }
184    }
185
186    let norm_content = normalize_for_match(&content);
187    let norm_old = normalize_for_match(old_text);
188    if norm_old.is_empty() {
189        return Err(EditError::InvalidParams(
190            "old_text must not be empty".to_string(),
191        ));
192    }
193    // Build CRLF offset index once. When no CRLF is present (Cow::Borrowed),
194    // the index is empty and offset mapping is identity.
195    let crlf_positions = build_crlf_positions(&content);
196    let count = norm_content.matches(norm_old.as_ref()).count();
197    match count {
198        0 => {
199            let first_20_lines = content.lines().take(20).collect::<Vec<_>>().join("\n");
200            return Err(EditError::NotFound {
201                path: path.display().to_string(),
202                first_20_lines,
203            });
204        }
205        1 if !replace_all => {}
206        n if !replace_all => {
207            let match_lines: Vec<usize> = norm_content
208                .match_indices(norm_old.as_ref())
209                .map(|(offset, _)| {
210                    norm_content[..offset]
211                        .bytes()
212                        .filter(|&b| b == b'\n')
213                        .count()
214                        + 1
215                })
216                .collect();
217            return Err(EditError::Ambiguous {
218                count: n,
219                path: path.display().to_string(),
220                match_lines,
221            });
222        }
223        _ => {} // replace_all=true: fall through to single-pass splice
224    }
225    let bytes_before = content.len();
226
227    if replace_all {
228        // Single-pass over original normalized content: collect all match spans,
229        // then splice new_text between unmatched spans in original byte space.
230        let mut matches: Vec<(usize, usize)> = Vec::new();
231        for (norm_start, _m) in norm_content.match_indices(norm_old.as_ref()) {
232            let original_start = norm_to_original_offset(norm_start, &crlf_positions);
233            let original_end =
234                norm_to_original_offset(norm_start + norm_old.len(), &crlf_positions);
235            matches.push((original_start, original_end));
236        }
237        let occurrences_replaced = matches.len();
238        let old_span_total: usize = matches.iter().map(|(s, e)| e - s).sum();
239        // capacity upper bound: existing bytes + new bytes added - old bytes removed
240        // saturating_mul guards against overflow on 32-bit targets with pathological inputs
241        let mut result = String::with_capacity(
242            bytes_before + new_text.len().saturating_mul(occurrences_replaced) - old_span_total,
243        );
244        let mut last_end = 0usize;
245        for (start, end) in &matches {
246            result.push_str(&content[last_end..*start]);
247            result.push_str(new_text);
248            last_end = *end;
249        }
250        result.push_str(&content[last_end..]);
251        let bytes_after = result.len();
252        write_file_atomic(path, &result)?;
253        Ok(EditReplaceOutput {
254            path: path.display().to_string(),
255            bytes_before,
256            bytes_after,
257            occurrences_replaced,
258        })
259    } else {
260        // Single-match path (existing behavior)
261        // SAFETY: match was verified above via count check; find must succeed.
262        // If count verification logic changes, this expect() site must be re-audited.
263        #[allow(clippy::expect_used)]
264        let norm_match_offset = norm_content
265            .find(norm_old.as_ref())
266            .expect("match was verified above via count check; find must succeed");
267        let original_start = norm_to_original_offset(norm_match_offset, &crlf_positions);
268        let original_end =
269            norm_to_original_offset(norm_match_offset + norm_old.len(), &crlf_positions);
270        let updated = [
271            &content[..original_start],
272            new_text,
273            &content[original_end..],
274        ]
275        .concat();
276        let bytes_after = updated.len();
277        write_file_atomic(path, &updated)?;
278        Ok(EditReplaceOutput {
279            path: path.display().to_string(),
280            bytes_before,
281            bytes_after,
282            occurrences_replaced: 1,
283        })
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn edit_overwrite_content_creates_new_file() {
293        let dir = tempfile::tempdir().unwrap();
294        let path = dir.path().join("new.txt");
295        let result = edit_overwrite_content(&path, "hello world").unwrap();
296        assert_eq!(result.bytes_written, 11);
297        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
298    }
299
300    #[test]
301    fn edit_overwrite_content_overwrites_existing() {
302        let dir = tempfile::tempdir().unwrap();
303        let path = dir.path().join("existing.txt");
304        std::fs::write(&path, "old content").unwrap();
305        let result = edit_overwrite_content(&path, "new content").unwrap();
306        assert_eq!(result.bytes_written, 11);
307        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
308    }
309
310    #[test]
311    fn edit_overwrite_content_creates_parent_dirs() {
312        let dir = tempfile::tempdir().unwrap();
313        let path = dir.path().join("a").join("b").join("c.txt");
314        let result = edit_overwrite_content(&path, "nested").unwrap();
315        assert_eq!(result.bytes_written, 6);
316        assert!(path.exists());
317    }
318
319    #[test]
320    fn edit_overwrite_content_directory_guard() {
321        let dir = tempfile::tempdir().unwrap();
322        let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
323        std::assert_matches!(err, EditError::NotAFile(_));
324    }
325
326    #[test]
327    fn edit_replace_block_happy_path() {
328        let dir = tempfile::tempdir().unwrap();
329        let path = dir.path().join("file.txt");
330        std::fs::write(&path, "foo bar baz").unwrap();
331        let result = edit_replace_block(&path, "bar", "qux").unwrap();
332        assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
333        assert_eq!(result.bytes_before, 11);
334        assert_eq!(result.bytes_after, 11);
335    }
336
337    #[test]
338    fn edit_replace_block_not_found() {
339        let dir = tempfile::tempdir().unwrap();
340        let path = dir.path().join("file.txt");
341        std::fs::write(&path, "foo bar baz").unwrap();
342        let err = edit_replace_block(&path, "missing", "x").unwrap_err();
343        std::assert_matches!(&err, EditError::NotFound { first_20_lines, .. } if !first_20_lines.is_empty());
344    }
345
346    #[test]
347    fn edit_replace_block_ambiguous() {
348        let dir = tempfile::tempdir().unwrap();
349        let path = dir.path().join("file.txt");
350        std::fs::write(&path, "foo foo baz").unwrap();
351        let err = edit_replace_block(&path, "foo", "x").unwrap_err();
352        std::assert_matches!(&err, EditError::Ambiguous { count: 2, match_lines, .. } if match_lines == &[1, 1]);
353    }
354
355    #[test]
356    fn edit_replace_block_directory_guard() {
357        let dir = tempfile::tempdir().unwrap();
358        let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
359        std::assert_matches!(err, EditError::NotAFile(_));
360    }
361
362    #[test]
363    fn edit_replace_block_crlf_file_lf_oldtext() {
364        // CRLF file + LF old_text => match succeeds and non-replaced lines retain CRLF bytes
365        let dir = tempfile::tempdir().unwrap();
366        let path = dir.path().join("crlf.txt");
367        // Write raw CRLF bytes: "foo\r\nbar\r\nbaz"
368        std::fs::write(&path, b"foo\r\nbar\r\nbaz").unwrap();
369        let result = edit_replace_block(&path, "bar", "qux").unwrap();
370        // The result should contain "foo\r\nqux\r\nbaz" (non-replaced lines retain CRLF)
371        let output = std::fs::read_to_string(&path).unwrap();
372        assert_eq!(output, "foo\r\nqux\r\nbaz");
373        assert_eq!(result.bytes_before, 13); // "foo\r\nbar\r\nbaz" = 13 bytes
374        assert_eq!(result.bytes_after, 13); // "foo\r\nqux\r\nbaz" = 13 bytes
375    }
376
377    #[test]
378    fn edit_replace_block_lf_file_crlf_oldtext() {
379        // LF file + CRLF old_text => match succeeds
380        let dir = tempfile::tempdir().unwrap();
381        let path = dir.path().join("lf.txt");
382        std::fs::write(&path, b"foo\nbar\nbaz").unwrap();
383        let result = edit_replace_block(&path, "bar\r\n", "qux\n").unwrap();
384        // old_text "bar\r\n" is normalized to "bar\n", matches "bar\n" in file
385        let output = std::fs::read_to_string(&path).unwrap();
386        assert_eq!(output, "foo\nqux\nbaz");
387        assert_eq!(result.bytes_before, 11); // "foo\nbar\nbaz" = 11 bytes
388        assert_eq!(result.bytes_after, 11); // "foo\nqux\nbaz" = 11 bytes
389    }
390
391    #[test]
392    fn edit_replace_block_crlf_file_crlf_oldtext() {
393        // CRLF file + CRLF old_text => both normalized, match succeeds
394        let dir = tempfile::tempdir().unwrap();
395        let path = dir.path().join("bothcrlf.txt");
396        std::fs::write(&path, b"line1\r\nline2\r\nline3").unwrap();
397        let result = edit_replace_block(&path, "line2\r\n", "replaced\n").unwrap();
398        let output = std::fs::read_to_string(&path).unwrap();
399        assert_eq!(output, "line1\r\nreplaced\nline3");
400        assert_eq!(result.bytes_before, 19); // "line1\r\nline2\r\nline3" = 19 bytes
401    }
402
403    #[test]
404    fn edit_replace_block_trailing_spaces_distinct() {
405        // Two blocks differing only by trailing spaces remain distinct after normalization
406        let dir = tempfile::tempdir().unwrap();
407        let path = dir.path().join("spaces.txt");
408        std::fs::write(&path, "foo  \nbar\nfoo\nbar").unwrap();
409        // old_text "foo\nbar" should match the SECOND occurrence ("foo\nbar"),
410        // not the first ("foo  \nbar"), because trailing spaces are not stripped
411        let result = edit_replace_block(&path, "foo\nbar", "replaced").unwrap();
412        let output = std::fs::read_to_string(&path).unwrap();
413        assert_eq!(output, "foo  \nbar\nreplaced");
414        assert_eq!(result.bytes_before, 17); // "foo  \nbar\nfoo\nbar" = 17 bytes
415        assert_eq!(result.bytes_after, 18); // "foo  \nbar\nreplaced" = 18 bytes
416    }
417
418    #[test]
419    fn edit_replace_block_replace_all_three_occurrences() {
420        let dir = tempfile::tempdir().unwrap();
421        let path = dir.path().join("all.txt");
422        std::fs::write(&path, "a b a c a d").unwrap();
423        let result = edit_replace_block_with_options(&path, "a", "x", true, None).unwrap();
424        assert_eq!(std::fs::read_to_string(&path).unwrap(), "x b x c x d");
425        assert_eq!(result.bytes_before, 11);
426        assert_eq!(result.bytes_after, 11);
427        assert_eq!(result.occurrences_replaced, 3);
428    }
429
430    #[test]
431    fn edit_replace_block_replace_all_not_found() {
432        let dir = tempfile::tempdir().unwrap();
433        let path = dir.path().join("nf.txt");
434        std::fs::write(&path, "foo bar baz").unwrap();
435        let err = edit_replace_block_with_options(&path, "missing", "x", true, None).unwrap_err();
436        std::assert_matches!(&err, EditError::NotFound { .. });
437    }
438
439    #[test]
440    fn edit_replace_block_replace_all_empty_oldtext() {
441        let dir = tempfile::tempdir().unwrap();
442        let path = dir.path().join("empty.txt");
443        std::fs::write(&path, "foo bar baz").unwrap();
444        let err = edit_replace_block_with_options(&path, "", "x", true, None).unwrap_err();
445        std::assert_matches!(&err, EditError::InvalidParams(_));
446    }
447
448    #[test]
449    fn edit_replace_block_replace_all_preserves_crlf() {
450        // CRLF file with replace_all: unmatched spans retain CRLF bytes
451        let dir = tempfile::tempdir().unwrap();
452        let path = dir.path().join("crlf_all.txt");
453        std::fs::write(&path, b"a\r\nb\r\na\r\nc").unwrap();
454        let result = edit_replace_block_with_options(&path, "a", "x", true, None).unwrap();
455        assert_eq!(std::fs::read_to_string(&path).unwrap(), "x\r\nb\r\nx\r\nc");
456        assert_eq!(result.occurrences_replaced, 2);
457    }
458
459    #[test]
460    fn replace_all_deletes_all_occurrences() {
461        let dir = tempfile::tempdir().unwrap();
462        let path = dir.path().join("delete.txt");
463        std::fs::write(&path, "a b a c a d").unwrap();
464        let result = edit_replace_block_with_options(&path, "a", "", true, None).unwrap();
465        assert_eq!(std::fs::read_to_string(&path).unwrap(), " b  c  d");
466        assert_eq!(result.bytes_before, 11);
467        assert_eq!(result.bytes_after, 8);
468        assert_eq!(result.occurrences_replaced, 3);
469    }
470
471    #[test]
472    fn replace_all_non_overlap_adjacent() {
473        let dir = tempfile::tempdir().unwrap();
474        let path = dir.path().join("adjacent.txt");
475        std::fs::write(&path, "aaaa").unwrap();
476        let result = edit_replace_block_with_options(&path, "aa", "xx", true, None).unwrap();
477        assert_eq!(std::fs::read_to_string(&path).unwrap(), "xxxx");
478        assert_eq!(result.occurrences_replaced, 2);
479    }
480
481    #[test]
482    fn replace_all_size_changing() {
483        let dir = tempfile::tempdir().unwrap();
484        let path = dir.path().join("size.txt");
485        std::fs::write(&path, "x y x z x").unwrap();
486        let result = edit_replace_block_with_options(&path, "x", "yyy", true, None).unwrap();
487        assert_eq!(std::fs::read_to_string(&path).unwrap(), "yyy y yyy z yyy");
488        assert_eq!(result.bytes_before, 9);
489        assert_eq!(result.bytes_after, 15);
490        assert_eq!(result.occurrences_replaced, 3);
491    }
492
493    #[test]
494    fn replace_all_empty_oldtext_no_replace_all_returns_invalid_params() {
495        let dir = tempfile::tempdir().unwrap();
496        let path = dir.path().join("empty.txt");
497        std::fs::write(&path, "foo bar baz").unwrap();
498        let err = edit_replace_block(&path, "", "x").unwrap_err();
499        std::assert_matches!(&err, EditError::InvalidParams(_));
500    }
501
502    #[test]
503    fn expected_content_hash_mismatch_returns_stale_error() {
504        let dir = tempfile::tempdir().unwrap();
505        let path = dir.path().join("stale.txt");
506        std::fs::write(&path, "hello world").unwrap();
507        let err = edit_replace_block_with_options(
508            &path,
509            "hello",
510            "hi",
511            false,
512            Some("0000000000000000000000000000000000000000000000000000000000000000"),
513        )
514        .unwrap_err();
515        std::assert_matches!(&err, EditError::StaleContentHash { path: p, .. } if p.contains("stale.txt"));
516        // File must be unmodified
517        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
518    }
519
520    #[test]
521    fn expected_content_hash_match_proceeds_normally() {
522        let dir = tempfile::tempdir().unwrap();
523        let path = dir.path().join("match.txt");
524        std::fs::write(&path, "hello world").unwrap();
525        let raw_bytes = std::fs::read(&path).unwrap();
526        let hash = blake3::hash(&raw_bytes).to_hex().to_string();
527        let result =
528            edit_replace_block_with_options(&path, "hello", "hi", false, Some(&hash)).unwrap();
529        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hi world");
530        assert_eq!(result.occurrences_replaced, 1);
531    }
532
533    #[test]
534    fn expected_content_hash_none_skips_check() {
535        let dir = tempfile::tempdir().unwrap();
536        let path = dir.path().join("nohash.txt");
537        std::fs::write(&path, "foo bar baz").unwrap();
538        let result = edit_replace_block_with_options(&path, "bar", "qux", false, None).unwrap();
539        assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
540        assert_eq!(result.occurrences_replaced, 1);
541    }
542
543    #[test]
544    fn replace_all_crlf_offset_index_byte_identical() {
545        // After Cow fast path and offset-index refactor, replace_all on a mixed
546        // CRLF/LF file produces byte-identical output to the previous linear-walk.
547        let dir = tempfile::tempdir().unwrap();
548        let path = dir.path().join("mixed_crlf.txt");
549        // Mixed CRLF and LF: "a\r\nb\na\r\nc\na\r\nd"
550        let original = b"a\r\nb\na\r\nc\na\r\nd";
551        std::fs::write(&path, original).unwrap();
552        let result = edit_replace_block_with_options(&path, "a", "XYZ", true, None).unwrap();
553        assert_eq!(result.occurrences_replaced, 3);
554        let output = std::fs::read(&path).unwrap();
555        // Expected: "XYZ\r\nb\nXYZ\r\nc\nXYZ\r\nd"
556        assert_eq!(output, b"XYZ\r\nb\nXYZ\r\nc\nXYZ\r\nd");
557    }
558}