Skip to main content

mcp_servers/coding/tools/write_file/
mod.rs

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