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::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    /// Path to the file to edit
13    #[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    /// 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}
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}