Skip to main content

mcp_servers/coding/tools/write_file/
mod.rs

1use crate::file_ops::{FileError, write_text_file};
2use mcp_utils::display_meta::{FileDiff, ToolDisplayMeta, ToolResultMeta, basename};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
8#[serde(rename_all = "camelCase")]
9pub struct WriteFileArgs {
10    /// The absolute path to the file to write
11    #[serde(alias = "file_path")]
12    pub file_path: String,
13    /// The content to write to the file
14    pub content: String,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "camelCase")]
19pub struct WriteFileResponse {
20    /// Success message
21    pub message: String,
22    /// Number of bytes written
23    pub bytes_written: usize,
24    /// File path that was written
25    pub file_path: String,
26    /// Display metadata for human-friendly rendering
27    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
28    #[schemars(skip)]
29    pub meta: Option<ToolResultMeta>,
30}
31
32pub async fn write_file_contents(args: WriteFileArgs) -> Result<WriteFileResponse, FileError> {
33    let file_path = Path::new(&args.file_path);
34    let result = write_text_file(file_path, &args.content).await?;
35    let file_path = result.path.to_string_lossy().into_owned();
36    let display_meta = ToolDisplayMeta::new("Write file", basename(&file_path));
37    let file_diff = FileDiff { path: file_path.clone(), old_text: None, new_text: args.content };
38
39    Ok(WriteFileResponse {
40        message: format!("Successfully wrote {} bytes to {}", result.bytes_written, file_path),
41        bytes_written: result.bytes_written,
42        file_path,
43        meta: Some(ToolResultMeta::with_file_diff(display_meta, file_diff)),
44    })
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use std::fs;
51    use tempfile::TempDir;
52
53    #[tokio::test]
54    async fn write_file_produces_file_diff_with_no_old_text() {
55        let temp_dir = TempDir::new().unwrap();
56        let file_path = temp_dir.path().join("new_file.rs");
57
58        let content = "fn main() {\n    println!(\"Hello\");\n}\n";
59        let result = write_file_contents(WriteFileArgs {
60            file_path: file_path.to_string_lossy().to_string(),
61            content: content.to_string(),
62        })
63        .await
64        .unwrap();
65
66        assert!(file_path.exists());
67        assert_eq!(fs::read_to_string(&file_path).unwrap(), content);
68
69        let meta = result.meta.unwrap();
70        let diff = meta.file_diff.unwrap();
71        assert!(diff.old_text.is_none());
72        assert_eq!(diff.new_text, content);
73        assert_eq!(diff.path, file_path.to_string_lossy().to_string());
74    }
75
76    #[tokio::test]
77    async fn write_file_file_diff_has_correct_path() {
78        let temp_dir = TempDir::new().unwrap();
79        let file_path = temp_dir.path().join("test.rs");
80
81        let result = write_file_contents(WriteFileArgs {
82            file_path: file_path.to_string_lossy().to_string(),
83            content: "let x = 1;".to_string(),
84        })
85        .await
86        .unwrap();
87
88        let diff = result.meta.unwrap().file_diff.unwrap();
89        assert_eq!(diff.path, file_path.to_string_lossy().to_string());
90    }
91
92    #[tokio::test]
93    async fn write_file_handles_empty_content() {
94        let temp_dir = TempDir::new().unwrap();
95        let file_path = temp_dir.path().join("empty.txt");
96
97        let result = write_file_contents(WriteFileArgs {
98            file_path: file_path.to_string_lossy().to_string(),
99            content: String::new(),
100        })
101        .await
102        .unwrap();
103
104        let diff = result.meta.unwrap().file_diff.unwrap();
105        assert_eq!(diff.new_text, "");
106        assert!(diff.old_text.is_none());
107    }
108
109    #[tokio::test]
110    async fn write_file_overwrites_existing_file() {
111        let temp_dir = TempDir::new().unwrap();
112        let file_path = temp_dir.path().join("existing.txt");
113        fs::write(&file_path, "old content").unwrap();
114
115        let new_content = "new content\nsecond line\n";
116        let result = write_file_contents(WriteFileArgs {
117            file_path: file_path.to_string_lossy().to_string(),
118            content: new_content.to_string(),
119        })
120        .await
121        .unwrap();
122
123        assert_eq!(fs::read_to_string(&file_path).unwrap(), new_content);
124
125        let diff = result.meta.unwrap().file_diff.unwrap();
126        assert!(diff.old_text.is_none());
127        assert_eq!(diff.new_text, new_content);
128    }
129
130    #[test]
131    fn write_file_args_accepts_snake_case_file_path() {
132        let args: WriteFileArgs = serde_json::from_value(serde_json::json!({
133            "file_path": "/tmp/out.txt",
134            "content": "hello"
135        }))
136        .unwrap();
137
138        assert_eq!(args.file_path, "/tmp/out.txt");
139        assert_eq!(args.content, "hello");
140    }
141}