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
54/// A single edit that could not be applied. `index` is the
55/// position of the failing edit in the supplied `edits` array,
56#[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}