mcp_servers/coding/tools/edit_file/
mod.rs1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5use crate::Displayed;
6use crate::file_ops::{ApplyEditsResult, FileEdit, FileError, apply_edits};
7use utils::display_meta::{FileDiff, ToolDisplayMeta, ToolResultMeta, basename};
8
9#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
10#[serde(rename_all = "camelCase")]
11pub struct EditFileArgs {
12 #[serde(alias = "file_path")]
14 pub file_path: String,
15 pub edits: Vec<FileEdit>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "camelCase")]
20pub struct EditFileResponse {
21 pub file_path: String,
23 pub total_lines: usize,
25 pub replacements_made: usize,
27}
28
29pub async fn edit_file_contents(args: EditFileArgs) -> Result<Displayed<EditFileResponse>, FileError> {
30 let ApplyEditsResult { path, original_content, updated_content, replacements_made } =
31 apply_edits(Path::new(&args.file_path), &args.edits).await?;
32 let file_path = path.to_string_lossy().into_owned();
33 let total_lines = updated_content.lines().count();
34 let display_meta = ToolDisplayMeta::new("Edit file", basename(&file_path));
35 let file_diff =
36 FileDiff { path: file_path.clone(), old_text: Some(original_content), new_text: Some(updated_content) };
37
38 let response = EditFileResponse { file_path, total_lines, replacements_made };
39 Ok(Displayed::new(response, ToolResultMeta::with_file_diff(display_meta, file_diff)))
40}
41
42#[cfg(test)]
43mod tests {
44 use super::*;
45 use crate::file_ops::EditFailureKind;
46 use crate::testing::TestWorkspace;
47 use std::fs;
48
49 #[tokio::test]
50 async fn edit_file_nonexistent_returns_not_found() {
51 let workspace = TestWorkspace::new();
52 let file_path = workspace.path("missing.txt");
53
54 let result = edit_file_contents(EditFileArgs {
55 file_path: file_path.to_string_lossy().to_string(),
56 edits: vec![FileEdit::new("before", "after")],
57 })
58 .await;
59
60 assert!(matches!(result, Err(FileError::NotFound { .. })));
61 }
62
63 #[tokio::test]
64 async fn edit_file_existing_file_without_match_returns_edits_failed() -> Result<(), Box<dyn std::error::Error>> {
65 let workspace = TestWorkspace::new().file("sample.txt", "hello world");
66 let file_path = workspace.path("sample.txt");
67
68 let result = edit_file_contents(EditFileArgs {
69 file_path: file_path.to_string_lossy().to_string(),
70 edits: vec![FileEdit::new("missing", "replacement")],
71 })
72 .await;
73
74 let Err(FileError::EditsFailed { failures, .. }) = result else {
75 return Err("expected EditsFailed".into());
76 };
77 assert!(matches!(failures.as_slice(), [f] if matches!(f.kind, EditFailureKind::PatternNotFound { .. })));
78 Ok(())
79 }
80
81 #[tokio::test]
82 async fn edit_file_produces_file_diff_with_full_contents() {
83 let original = "line1\nline2\nline3\nline4\n";
84 let workspace = TestWorkspace::new().file("lines.txt", original);
85 let file_path = workspace.path("lines.txt");
86
87 let result = edit_file_contents(EditFileArgs {
88 file_path: file_path.to_string_lossy().to_string(),
89 edits: vec![FileEdit::new("line3", "replaced")],
90 })
91 .await
92 .unwrap();
93
94 let meta = result.meta.unwrap();
95 let diff = meta.file_diff.unwrap();
96 assert_eq!(diff.old_text.as_deref(), Some(original));
97 assert!(diff.new_text.as_deref().unwrap().contains("replaced"));
98 assert!(!diff.new_text.as_deref().unwrap().contains("line3"));
99 assert_eq!(diff.path, file_path.to_string_lossy().to_string());
100 }
101
102 #[tokio::test]
103 async fn edit_file_applies_batch_in_one_call() {
104 let workspace = TestWorkspace::new().file("lines.txt", "alpha\nbeta\ngamma\n");
105 let file_path = workspace.path("lines.txt");
106
107 let result = edit_file_contents(EditFileArgs {
108 file_path: file_path.to_string_lossy().to_string(),
109 edits: vec![FileEdit::new("alpha", "ALPHA"), FileEdit::new("gamma", "GAMMA")],
110 })
111 .await
112 .unwrap();
113
114 assert_eq!(result.output.replacements_made, 2);
115 assert_eq!(fs::read_to_string(&file_path).unwrap(), "ALPHA\nbeta\nGAMMA\n");
116 }
117
118 #[test]
119 fn edit_file_args_accepts_snake_case_fields() {
120 let args: EditFileArgs = serde_json::from_value(serde_json::json!({
121 "file_path": "/tmp/test.txt",
122 "edits": [{ "old_string": "foo", "new_string": "bar", "replace_all": true }]
123 }))
124 .unwrap();
125
126 assert_eq!(args.file_path, "/tmp/test.txt");
127 assert!(matches!(
128 args.edits.as_slice(),
129 [FileEdit { old_string, new_string, replace_all: true }]
130 if old_string == "foo" && new_string == "bar"
131 ));
132 }
133}