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
54pub fn edit_overwrite_content(
55    path: &Path,
56    content: &str,
57) -> Result<EditOverwriteOutput, EditError> {
58    if path.is_dir() {
59        return Err(EditError::NotAFile(path.to_path_buf()));
60    }
61    if let Some(parent) = path.parent()
62        && !parent.as_os_str().is_empty()
63    {
64        std::fs::create_dir_all(parent)?;
65    }
66    write_file_atomic(path, content)?;
67    Ok(EditOverwriteOutput {
68        path: path.display().to_string(),
69        bytes_written: content.len(),
70    })
71}
72
73pub fn edit_replace_block(
74    path: &Path,
75    old_text: &str,
76    new_text: &str,
77) -> Result<EditReplaceOutput, EditError> {
78    if path.is_dir() {
79        return Err(EditError::NotAFile(path.to_path_buf()));
80    }
81    let content = std::fs::read_to_string(path)?;
82    let count = content.matches(old_text).count();
83    match count {
84        0 => {
85            let first_20_lines = content.lines().take(20).collect::<Vec<_>>().join("\n");
86            return Err(EditError::NotFound {
87                path: path.display().to_string(),
88                first_20_lines,
89            });
90        }
91        1 => {}
92        n => {
93            let match_lines: Vec<usize> = content
94                .match_indices(old_text)
95                .map(|(offset, _)| content[..offset].bytes().filter(|&b| b == b'\n').count() + 1)
96                .collect();
97            return Err(EditError::Ambiguous {
98                count: n,
99                path: path.display().to_string(),
100                match_lines,
101            });
102        }
103    }
104    let bytes_before = content.len();
105    let updated = content.replacen(old_text, new_text, 1);
106    let bytes_after = updated.len();
107    write_file_atomic(path, &updated)?;
108    Ok(EditReplaceOutput {
109        path: path.display().to_string(),
110        bytes_before,
111        bytes_after,
112    })
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn edit_overwrite_content_creates_new_file() {
121        let dir = tempfile::tempdir().unwrap();
122        let path = dir.path().join("new.txt");
123        let result = edit_overwrite_content(&path, "hello world").unwrap();
124        assert_eq!(result.bytes_written, 11);
125        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
126    }
127
128    #[test]
129    fn edit_overwrite_content_overwrites_existing() {
130        let dir = tempfile::tempdir().unwrap();
131        let path = dir.path().join("existing.txt");
132        std::fs::write(&path, "old content").unwrap();
133        let result = edit_overwrite_content(&path, "new content").unwrap();
134        assert_eq!(result.bytes_written, 11);
135        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
136    }
137
138    #[test]
139    fn edit_overwrite_content_creates_parent_dirs() {
140        let dir = tempfile::tempdir().unwrap();
141        let path = dir.path().join("a").join("b").join("c.txt");
142        let result = edit_overwrite_content(&path, "nested").unwrap();
143        assert_eq!(result.bytes_written, 6);
144        assert!(path.exists());
145    }
146
147    #[test]
148    fn edit_overwrite_content_directory_guard() {
149        let dir = tempfile::tempdir().unwrap();
150        let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
151        std::assert_matches!(err, EditError::NotAFile(_));
152    }
153
154    #[test]
155    fn edit_replace_block_happy_path() {
156        let dir = tempfile::tempdir().unwrap();
157        let path = dir.path().join("file.txt");
158        std::fs::write(&path, "foo bar baz").unwrap();
159        let result = edit_replace_block(&path, "bar", "qux").unwrap();
160        assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
161        assert_eq!(result.bytes_before, 11);
162        assert_eq!(result.bytes_after, 11);
163    }
164
165    #[test]
166    fn edit_replace_block_not_found() {
167        let dir = tempfile::tempdir().unwrap();
168        let path = dir.path().join("file.txt");
169        std::fs::write(&path, "foo bar baz").unwrap();
170        let err = edit_replace_block(&path, "missing", "x").unwrap_err();
171        std::assert_matches!(&err, EditError::NotFound { first_20_lines, .. } if !first_20_lines.is_empty());
172    }
173
174    #[test]
175    fn edit_replace_block_ambiguous() {
176        let dir = tempfile::tempdir().unwrap();
177        let path = dir.path().join("file.txt");
178        std::fs::write(&path, "foo foo baz").unwrap();
179        let err = edit_replace_block(&path, "foo", "x").unwrap_err();
180        std::assert_matches!(&err, EditError::Ambiguous { count: 2, match_lines, .. } if match_lines == &[1, 1]);
181    }
182
183    #[test]
184    fn edit_replace_block_directory_guard() {
185        let dir = tempfile::tempdir().unwrap();
186        let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
187        std::assert_matches!(err, EditError::NotAFile(_));
188    }
189}