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 { path: String },
27    #[error(
28        "old_text appears {count} times in {path} — make old_text longer and more specific to uniquely identify the block"
29    )]
30    Ambiguous { count: usize, path: String },
31}
32
33fn write_file_atomic(path: &Path, content: &str) -> Result<(), EditError> {
34    let parent = path.parent().ok_or_else(|| {
35        EditError::Io(std::io::Error::new(
36            std::io::ErrorKind::InvalidInput,
37            "path has no parent directory",
38        ))
39    })?;
40    let mut temp_file = NamedTempFile::new_in(parent)?;
41    use std::io::Write;
42    temp_file.write_all(content.as_bytes())?;
43    temp_file.persist(path).map_err(|e| e.error)?;
44    Ok(())
45}
46
47pub fn edit_overwrite_content(
48    path: &Path,
49    content: &str,
50) -> Result<EditOverwriteOutput, EditError> {
51    if path.is_dir() {
52        return Err(EditError::NotAFile(path.to_path_buf()));
53    }
54    if let Some(parent) = path.parent()
55        && !parent.as_os_str().is_empty()
56    {
57        std::fs::create_dir_all(parent)?;
58    }
59    write_file_atomic(path, content)?;
60    Ok(EditOverwriteOutput {
61        path: path.display().to_string(),
62        bytes_written: content.len(),
63    })
64}
65
66pub fn edit_replace_block(
67    path: &Path,
68    old_text: &str,
69    new_text: &str,
70) -> Result<EditReplaceOutput, EditError> {
71    if path.is_dir() {
72        return Err(EditError::NotAFile(path.to_path_buf()));
73    }
74    let content = std::fs::read_to_string(path)?;
75    let count = content.matches(old_text).count();
76    match count {
77        0 => {
78            return Err(EditError::NotFound {
79                path: path.display().to_string(),
80            });
81        }
82        1 => {}
83        n => {
84            return Err(EditError::Ambiguous {
85                count: n,
86                path: path.display().to_string(),
87            });
88        }
89    }
90    let bytes_before = content.len();
91    let updated = content.replacen(old_text, new_text, 1);
92    let bytes_after = updated.len();
93    write_file_atomic(path, &updated)?;
94    Ok(EditReplaceOutput {
95        path: path.display().to_string(),
96        bytes_before,
97        bytes_after,
98    })
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn edit_overwrite_content_creates_new_file() {
107        let dir = tempfile::tempdir().unwrap();
108        let path = dir.path().join("new.txt");
109        let result = edit_overwrite_content(&path, "hello world").unwrap();
110        assert_eq!(result.bytes_written, 11);
111        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world");
112    }
113
114    #[test]
115    fn edit_overwrite_content_overwrites_existing() {
116        let dir = tempfile::tempdir().unwrap();
117        let path = dir.path().join("existing.txt");
118        std::fs::write(&path, "old content").unwrap();
119        let result = edit_overwrite_content(&path, "new content").unwrap();
120        assert_eq!(result.bytes_written, 11);
121        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content");
122    }
123
124    #[test]
125    fn edit_overwrite_content_creates_parent_dirs() {
126        let dir = tempfile::tempdir().unwrap();
127        let path = dir.path().join("a").join("b").join("c.txt");
128        let result = edit_overwrite_content(&path, "nested").unwrap();
129        assert_eq!(result.bytes_written, 6);
130        assert!(path.exists());
131    }
132
133    #[test]
134    fn edit_overwrite_content_directory_guard() {
135        let dir = tempfile::tempdir().unwrap();
136        let err = edit_overwrite_content(dir.path(), "content").unwrap_err();
137        assert!(matches!(err, EditError::NotAFile(_)));
138    }
139
140    #[test]
141    fn edit_replace_block_happy_path() {
142        let dir = tempfile::tempdir().unwrap();
143        let path = dir.path().join("file.txt");
144        std::fs::write(&path, "foo bar baz").unwrap();
145        let result = edit_replace_block(&path, "bar", "qux").unwrap();
146        assert_eq!(std::fs::read_to_string(&path).unwrap(), "foo qux baz");
147        assert_eq!(result.bytes_before, 11);
148        assert_eq!(result.bytes_after, 11);
149    }
150
151    #[test]
152    fn edit_replace_block_not_found() {
153        let dir = tempfile::tempdir().unwrap();
154        let path = dir.path().join("file.txt");
155        std::fs::write(&path, "foo bar baz").unwrap();
156        let err = edit_replace_block(&path, "missing", "x").unwrap_err();
157        assert!(matches!(err, EditError::NotFound { .. }));
158    }
159
160    #[test]
161    fn edit_replace_block_ambiguous() {
162        let dir = tempfile::tempdir().unwrap();
163        let path = dir.path().join("file.txt");
164        std::fs::write(&path, "foo foo baz").unwrap();
165        let err = edit_replace_block(&path, "foo", "x").unwrap_err();
166        assert!(matches!(err, EditError::Ambiguous { count: 2, .. }));
167    }
168
169    #[test]
170    fn edit_replace_block_directory_guard() {
171        let dir = tempfile::tempdir().unwrap();
172        let err = edit_replace_block(dir.path(), "old", "new").unwrap_err();
173        assert!(matches!(err, EditError::NotAFile(_)));
174    }
175}