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