1use 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}