Skip to main content

mcp_servers/coding/tools/edit_file/
mod.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5use crate::file_ops::{ApplyEditsResult, FileEdit, FileError, apply_edits};
6use mcp_utils::display_meta::{FileDiff, ToolDisplayMeta, ToolResultMeta, basename};
7
8#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
9#[serde(rename_all = "camelCase")]
10pub struct EditFileArgs {
11    /// Path to the file to edit
12    #[serde(alias = "file_path")]
13    pub file_path: String,
14    pub edits: Vec<FileEdit>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "camelCase")]
19pub struct EditFileResponse {
20    pub status: String,
21    /// Path of the file that was edited
22    pub file_path: String,
23    /// Total number of lines in the file after editing
24    pub total_lines: usize,
25    /// Number of replacements made
26    pub replacements_made: usize,
27    /// The new file content after editing (used internally for LSP sync)
28    #[serde(skip_serializing)]
29    pub content: String,
30    /// Display metadata for human-friendly rendering
31    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
32    #[schemars(skip)]
33    pub meta: Option<ToolResultMeta>,
34}
35
36pub async fn edit_file_contents(args: EditFileArgs) -> Result<EditFileResponse, FileError> {
37    let ApplyEditsResult { path, original_content, updated_content, replacements_made } =
38        apply_edits(Path::new(&args.file_path), &args.edits).await?;
39    let file_path = path.to_string_lossy().into_owned();
40    let total_lines = updated_content.lines().count();
41    let display_meta = ToolDisplayMeta::new("Edit file", basename(&file_path));
42    let file_diff =
43        FileDiff { path: file_path.clone(), old_text: Some(original_content), new_text: updated_content.clone() };
44
45    Ok(EditFileResponse {
46        status: "success".to_string(),
47        file_path,
48        total_lines,
49        replacements_made,
50        content: updated_content,
51        meta: Some(ToolResultMeta::with_file_diff(display_meta, file_diff)),
52    })
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use crate::file_ops::EditFailureKind;
59    use std::fs;
60    use tempfile::TempDir;
61
62    #[tokio::test]
63    async fn edit_file_nonexistent_returns_not_found() {
64        let temp_dir = TempDir::new().unwrap();
65        let file_path = temp_dir.path().join("missing.txt");
66
67        let result = edit_file_contents(EditFileArgs {
68            file_path: file_path.to_string_lossy().to_string(),
69            edits: vec![FileEdit::new("before", "after")],
70        })
71        .await;
72
73        assert!(matches!(result, Err(FileError::NotFound { .. })));
74    }
75
76    #[tokio::test]
77    async fn edit_file_existing_file_without_match_returns_edits_failed() -> Result<(), Box<dyn std::error::Error>> {
78        let temp_dir = TempDir::new()?;
79        let file_path = temp_dir.path().join("sample.txt");
80        fs::write(&file_path, "hello world")?;
81
82        let result = edit_file_contents(EditFileArgs {
83            file_path: file_path.to_string_lossy().to_string(),
84            edits: vec![FileEdit::new("missing", "replacement")],
85        })
86        .await;
87
88        let Err(FileError::EditsFailed { failures, .. }) = result else {
89            return Err("expected EditsFailed".into());
90        };
91        assert!(matches!(failures.as_slice(), [f] if matches!(f.kind, EditFailureKind::PatternNotFound { .. })));
92        Ok(())
93    }
94
95    #[tokio::test]
96    async fn edit_file_produces_file_diff_with_full_contents() {
97        let temp_dir = TempDir::new().unwrap();
98        let file_path = temp_dir.path().join("lines.txt");
99        let original = "line1\nline2\nline3\nline4\n";
100        fs::write(&file_path, original).unwrap();
101
102        let result = edit_file_contents(EditFileArgs {
103            file_path: file_path.to_string_lossy().to_string(),
104            edits: vec![FileEdit::new("line3", "replaced")],
105        })
106        .await
107        .unwrap();
108
109        let meta = result.meta.unwrap();
110        let diff = meta.file_diff.unwrap();
111        assert_eq!(diff.old_text.as_deref(), Some(original));
112        assert!(diff.new_text.contains("replaced"));
113        assert!(!diff.new_text.contains("line3"));
114        assert_eq!(diff.path, file_path.to_string_lossy().to_string());
115    }
116
117    #[tokio::test]
118    async fn edit_file_applies_batch_in_one_call() {
119        let temp_dir = TempDir::new().unwrap();
120        let file_path = temp_dir.path().join("lines.txt");
121        fs::write(&file_path, "alpha\nbeta\ngamma\n").unwrap();
122
123        let result = edit_file_contents(EditFileArgs {
124            file_path: file_path.to_string_lossy().to_string(),
125            edits: vec![FileEdit::new("alpha", "ALPHA"), FileEdit::new("gamma", "GAMMA")],
126        })
127        .await
128        .unwrap();
129
130        assert_eq!(result.replacements_made, 2);
131        assert_eq!(fs::read_to_string(&file_path).unwrap(), "ALPHA\nbeta\nGAMMA\n");
132    }
133
134    #[test]
135    fn edit_file_args_accepts_snake_case_fields() {
136        let args: EditFileArgs = serde_json::from_value(serde_json::json!({
137            "file_path": "/tmp/test.txt",
138            "edits": [{ "old_string": "foo", "new_string": "bar", "replace_all": true }]
139        }))
140        .unwrap();
141
142        assert_eq!(args.file_path, "/tmp/test.txt");
143        assert!(matches!(
144            args.edits.as_slice(),
145            [FileEdit { old_string, new_string, replace_all: true }]
146                if old_string == "foo" && new_string == "bar"
147        ));
148    }
149}