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 { 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}