Skip to main content

mcp_servers/
file_ops.rs

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    /// Exact text to find. Must match whitespace (e.g. indentation).
14    #[serde(alias = "old_string")]
15    pub old_string: String,
16    /// Text to substitute for `oldString`.
17    #[serde(alias = "new_string")]
18    pub new_string: String,
19    /// Replace every occurrence instead of just the first.
20    #[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/// A single edit that could not be applied. `index` is the
58/// position of the failing edit in the supplied `edits` array,
59#[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}