1use std::io::ErrorKind;
2use std::ops::Range;
3use std::path::{Path, PathBuf};
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use tokio::fs::{create_dir_all, write};
9
10#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
11#[serde(rename_all = "camelCase", deny_unknown_fields)]
12pub struct FileEdit {
13 #[serde(alias = "old_string")]
15 pub old_string: String,
16 #[serde(alias = "new_string")]
18 pub new_string: String,
19 #[serde(default, alias = "replace_all")]
21 pub replace_all: bool,
22}
23
24impl FileEdit {
25 pub fn new(old_string: &str, new_string: &str) -> Self {
26 Self { old_string: old_string.to_string(), new_string: new_string.to_string(), replace_all: false }
27 }
28}
29
30#[derive(Debug, Error)]
31pub enum FileError {
32 #[error("File does not exist: {path}")]
33 NotFound { path: String },
34
35 #[error("Failed to read file {path}: {reason}")]
36 ReadFailed { path: String, reason: String },
37
38 #[error("Failed to write to file {path}: {reason}")]
39 WriteFailed { path: String, reason: String },
40
41 #[error("Failed to create directories for {path}: {reason}")]
42 CreateDirFailed { path: String, reason: String },
43
44 #[error("Invalid offset for file {path}: offset must be 1-indexed (start from 1)")]
45 InvalidOffset { path: String },
46
47 #[error("No edits supplied for file {path}: provide at least one edit")]
48 EmptyEdits { path: String },
49
50 #[error("{} edit(s) failed for file {path}; no changes were written:\n{}", .failures.len(), format_failures(.failures))]
51 EditsFailed { path: String, failures: Vec<EditFailure> },
52
53 #[error("IO error: {0}")]
54 Io(#[from] std::io::Error),
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct EditFailure {
61 pub index: usize,
62 pub kind: EditFailureKind,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Error)]
66pub enum EditFailureKind {
67 #[error("string not found: `{pattern}`")]
68 PatternNotFound { pattern: String },
69
70 #[error("edit overlaps the edit at index {other}; use non-overlapping edits or combine them")]
71 Overlapping { other: usize },
72}
73
74pub struct WriteTextFileResult {
75 pub path: PathBuf,
76 pub bytes_written: usize,
77}
78
79#[derive(Debug)]
80pub struct ApplyEditsResult {
81 pub path: PathBuf,
82 pub original_content: String,
83 pub updated_content: String,
84 pub replacements_made: usize,
85}
86
87pub async fn read_text_file(path: &Path) -> Result<String, FileError> {
88 tokio::fs::read_to_string(path).await.map_err(|error| match error.kind() {
89 ErrorKind::NotFound => FileError::NotFound { path: display_path(path) },
90 _ => FileError::ReadFailed { path: display_path(path), reason: error.to_string() },
91 })
92}
93
94pub async fn write_text_file(path: &Path, content: &str) -> Result<WriteTextFileResult, FileError> {
95 if let Some(parent) = path.parent()
96 && let Err(error) = create_dir_all(parent).await
97 {
98 return Err(FileError::CreateDirFailed { path: display_path(path), reason: error.to_string() });
99 }
100
101 if let Err(error) = write(path, content).await {
102 return Err(FileError::WriteFailed { path: display_path(path), reason: error.to_string() });
103 }
104
105 Ok(WriteTextFileResult { path: path.to_path_buf(), bytes_written: content.len() })
106}
107
108pub async fn apply_edits(path: &Path, edits: &[FileEdit]) -> Result<ApplyEditsResult, FileError> {
109 if edits.is_empty() {
110 return Err(FileError::EmptyEdits { path: display_path(path) });
111 }
112
113 let original_content = read_text_file(path).await?;
114 let resolved = resolve_edits(&original_content, edits)
115 .map_err(|failures| FileError::EditsFailed { path: display_path(path), failures })?;
116
117 let updated_content = splice(&original_content, &resolved);
118 let replacements_made = resolved.len();
119
120 if let Err(error) = write(path, &updated_content).await {
121 return Err(FileError::WriteFailed { path: display_path(path), reason: error.to_string() });
122 }
123
124 Ok(ApplyEditsResult { path: path.to_path_buf(), original_content, updated_content, replacements_made })
125}
126
127struct ResolvedEdit {
128 range: Range<usize>,
129 replacement: String,
130 edit_index: usize,
131}
132
133fn resolve_edits(content: &str, edits: &[FileEdit]) -> Result<Vec<ResolvedEdit>, Vec<EditFailure>> {
134 let mut resolved: Vec<ResolvedEdit> = Vec::new();
135 let mut failures: Vec<EditFailure> = Vec::new();
136
137 for (index, FileEdit { old_string, new_string, replace_all }) in edits.iter().enumerate() {
138 let starts: Vec<usize> = content.match_indices(old_string).map(|(start, _)| start).collect();
139 if starts.is_empty() {
140 failures
141 .push(EditFailure { index, kind: EditFailureKind::PatternNotFound { pattern: old_string.clone() } });
142 continue;
143 }
144 let chosen = if *replace_all { starts.as_slice() } else { &starts[..1] };
145 for &start in chosen {
146 resolved.push(ResolvedEdit {
147 range: start..start + old_string.len(),
148 replacement: new_string.clone(),
149 edit_index: index,
150 });
151 }
152 }
153
154 resolved.sort_by_key(|edit| edit.range.start);
155 let mut furthest: Option<(usize, usize)> = None;
156 for edit in &resolved {
157 match furthest {
158 Some((end, other)) if edit.range.start < end => {
159 failures.push(EditFailure { index: edit.edit_index, kind: EditFailureKind::Overlapping { other } });
160 if edit.range.end > end {
161 furthest = Some((edit.range.end, edit.edit_index));
162 }
163 }
164 _ => furthest = Some((edit.range.end, edit.edit_index)),
165 }
166 }
167
168 if failures.is_empty() {
169 Ok(resolved)
170 } else {
171 failures.sort_by_key(|failure| failure.index);
172 Err(failures)
173 }
174}
175
176fn splice(content: &str, resolved: &[ResolvedEdit]) -> String {
177 let mut result = String::with_capacity(content.len());
178 let mut last = 0;
179 for edit in resolved {
180 result.push_str(&content[last..edit.range.start]);
181 result.push_str(&edit.replacement);
182 last = edit.range.end;
183 }
184 result.push_str(&content[last..]);
185 result
186}
187
188fn format_failures(failures: &[EditFailure]) -> String {
189 failures
190 .iter()
191 .map(|failure| format!(" edits[{}]: {}", failure.index, failure.kind))
192 .collect::<Vec<_>>()
193 .join("\n")
194}
195
196fn display_path(path: &Path) -> String {
197 path.to_string_lossy().into_owned()
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use tempfile::TempDir;
204
205 #[tokio::test]
206 async fn applies_batch_against_original_content() -> Result<(), Box<dyn std::error::Error>> {
207 let (_dir, path) = with_file("alpha\nbeta\ngamma\n").await?;
208 let result = apply_edits(&path, &[FileEdit::new("alpha", "ALPHA"), FileEdit::new("gamma", "GAMMA")]).await?;
209
210 assert_eq!(result.replacements_made, 2);
211 assert_eq!(std::fs::read_to_string(&path)?, "ALPHA\nbeta\nGAMMA\n");
212 Ok(())
213 }
214
215 #[tokio::test]
216 async fn applies_multiple_replacements_without_drift() -> Result<(), Box<dyn std::error::Error>> {
217 let (_dir, path) = with_file("one\ntwo\nthree\nfour\n").await?;
218 apply_edits(&path, &[FileEdit::new("one", "ONE"), FileEdit::new("three", "THREE")]).await?;
219 assert_eq!(std::fs::read_to_string(&path)?, "ONE\ntwo\nTHREE\nfour\n");
220 Ok(())
221 }
222
223 #[tokio::test]
224 async fn replace_all_counts_every_occurrence() -> Result<(), Box<dyn std::error::Error>> {
225 let (_dir, path) = with_file("x x x\n").await?;
226 let result = apply_edits(
227 &path,
228 &[FileEdit { old_string: "x".to_string(), new_string: "y".to_string(), replace_all: true }],
229 )
230 .await?;
231
232 assert_eq!(result.replacements_made, 3);
233 assert_eq!(std::fs::read_to_string(&path)?, "y y y\n");
234 Ok(())
235 }
236
237 #[tokio::test]
238 async fn empty_new_string_deletes_the_match() -> Result<(), Box<dyn std::error::Error>> {
239 let (_dir, path) = with_file("a\nb\nc\n").await?;
240 apply_edits(&path, &[FileEdit::new("b\n", "")]).await?;
241 assert_eq!(std::fs::read_to_string(&path)?, "a\nc\n");
242 Ok(())
243 }
244
245 #[tokio::test]
246 async fn overlapping_edits_are_rejected_and_file_unchanged() -> Result<(), Box<dyn std::error::Error>> {
247 let (_dir, path) = with_file("foobar\n").await?;
248
249 let error = apply_edits(&path, &[FileEdit::new("foob", "X"), FileEdit::new("obar", "Y")])
250 .await
251 .expect_err("overlapping edits should fail");
252
253 let failures = edits_failures(error)?;
254 assert!(failures.iter().any(|f| matches!(f.kind, EditFailureKind::Overlapping { .. })));
255 assert_eq!(std::fs::read_to_string(&path)?, "foobar\n");
256 Ok(())
257 }
258
259 #[tokio::test]
260 async fn every_overlapping_edit_is_reported() -> Result<(), Box<dyn std::error::Error>> {
261 let (_dir, path) = with_file("abcdef\n").await?;
262 let error =
263 apply_edits(&path, &[FileEdit::new("abcde", "X"), FileEdit::new("bc", "Y"), FileEdit::new("de", "Z")])
264 .await
265 .expect_err("overlapping edits should fail");
266
267 let failures = edits_failures(error)?;
268 let overlapping: Vec<usize> = failures
269 .iter()
270 .filter(|f| matches!(f.kind, EditFailureKind::Overlapping { other: 0 }))
271 .map(|f| f.index)
272 .collect();
273 assert_eq!(overlapping, vec![1, 2]);
274 Ok(())
275 }
276
277 #[tokio::test]
278 async fn pattern_not_found_is_rejected_and_file_unchanged() -> Result<(), Box<dyn std::error::Error>> {
279 let (_dir, path) = with_file("hello\n").await?;
280 let error =
281 apply_edits(&path, &[FileEdit::new("missing", "x")]).await.expect_err("missing pattern should fail");
282
283 let failures = edits_failures(error)?;
284 assert_eq!(
285 failures,
286 vec![EditFailure { index: 0, kind: EditFailureKind::PatternNotFound { pattern: "missing".to_string() } }]
287 );
288 assert_eq!(std::fs::read_to_string(&path)?, "hello\n");
289 Ok(())
290 }
291
292 #[tokio::test]
293 async fn batch_failures_aggregate_and_write_nothing() -> Result<(), Box<dyn std::error::Error>> {
294 let (_dir, path) = with_file("hello\n").await?;
295 let error = apply_edits(&path, &[FileEdit::new("missing", "z"), FileEdit::new("absent", "q")])
296 .await
297 .expect_err("batch with missing patterns should fail");
298
299 let failures = edits_failures(error)?;
300 assert_eq!(failures.len(), 2);
301 assert!(failures.iter().all(|f| matches!(f.kind, EditFailureKind::PatternNotFound { .. })));
302 assert_eq!(std::fs::read_to_string(&path)?, "hello\n");
303 Ok(())
304 }
305
306 #[tokio::test]
307 async fn empty_edits_are_rejected() {
308 let (_dir, path) = with_file("hello\n").await.unwrap();
309 assert!(matches!(apply_edits(&path, &[]).await, Err(FileError::EmptyEdits { .. })));
310 }
311
312 async fn with_file(content: &str) -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
313 let dir = TempDir::new()?;
314 let path = dir.path().join("file.txt");
315 write_text_file(&path, content).await?;
316 Ok((dir, path))
317 }
318
319 fn edits_failures(error: FileError) -> Result<Vec<EditFailure>, FileError> {
320 match error {
321 FileError::EditsFailed { failures, .. } => Ok(failures),
322 other => Err(other),
323 }
324 }
325}