claude_code_acp/mcp/tools/
write.rs

1//! Write tool implementation
2//!
3//! Writes content to files on the filesystem.
4
5use async_trait::async_trait;
6use serde::Deserialize;
7use serde_json::json;
8use std::time::Instant;
9
10use super::base::{Tool, ToolKind};
11use crate::mcp::registry::{ToolContext, ToolResult};
12
13/// Write tool for creating/overwriting files
14#[derive(Debug, Default)]
15pub struct WriteTool;
16
17/// Write tool input parameters
18#[derive(Debug, Deserialize)]
19struct WriteInput {
20    /// Path to the file to write
21    file_path: String,
22    /// Content to write to the file
23    content: String,
24}
25
26impl WriteTool {
27    /// Create a new Write tool instance
28    pub fn new() -> Self {
29        Self
30    }
31
32    /// Check permission before executing the tool
33    ///
34    /// Note: Permission checking is now handled at the SDK level.
35    /// The SDK's `mcp_message` handler calls `can_use_tool` callback before executing MCP tools.
36    /// This method is kept for potential future tool-specific permission logic.
37    fn check_permission(
38        &self,
39        _input: &serde_json::Value,
40        _context: &ToolContext,
41    ) -> Option<ToolResult> {
42        // Permission check is handled by SDK's can_use_tool callback
43        None
44    }
45}
46
47#[async_trait]
48impl Tool for WriteTool {
49    fn name(&self) -> &str {
50        "Write"
51    }
52
53    fn description(&self) -> &str {
54        "Write content to a file. Creates the file if it doesn't exist, or overwrites it if it does. Creates parent directories as needed."
55    }
56
57    fn input_schema(&self) -> serde_json::Value {
58        json!({
59            "type": "object",
60            "required": ["file_path", "content"],
61            "properties": {
62                "file_path": {
63                    "type": "string",
64                    "description": "The absolute path to the file to write"
65                },
66                "content": {
67                    "type": "string",
68                    "description": "The content to write to the file"
69                }
70            }
71        })
72    }
73
74    fn kind(&self) -> ToolKind {
75        ToolKind::Edit
76    }
77
78    fn requires_permission(&self) -> bool {
79        true // Writing requires permission
80    }
81
82    async fn execute(&self, input: serde_json::Value, context: &ToolContext) -> ToolResult {
83        // Check permission before executing
84        if let Some(result) = self.check_permission(&input, context) {
85            return result;
86        }
87
88        // Parse input
89        let params: WriteInput = match serde_json::from_value(input) {
90            Ok(p) => p,
91            Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
92        };
93
94        // Resolve path relative to working directory if not absolute
95        let path = if std::path::Path::new(&params.file_path).is_absolute() {
96            std::path::PathBuf::from(&params.file_path)
97        } else {
98            context.cwd.join(&params.file_path)
99        };
100
101        let total_start = Instant::now();
102
103        // Create parent directories if they don't exist
104        if let Some(parent) = path.parent() {
105            if !parent.exists() {
106                let dir_start = Instant::now();
107                if let Err(e) = tokio::fs::create_dir_all(parent).await {
108                    return ToolResult::error(format!("Failed to create directory: {}", e));
109                }
110                tracing::debug!(
111                    parent_dir = %parent.display(),
112                    dir_creation_duration_ms = dir_start.elapsed().as_millis(),
113                    "Parent directories created"
114                );
115            }
116        }
117
118        // Check if file exists (for reporting)
119        let file_existed = path.exists();
120
121        // Write content to file
122        let write_start = Instant::now();
123        match tokio::fs::write(&path, &params.content).await {
124            Ok(()) => {
125                let write_duration = write_start.elapsed();
126                let total_elapsed = total_start.elapsed();
127
128                let action = if file_existed { "Updated" } else { "Created" };
129                let lines = params.content.lines().count();
130                let bytes = params.content.len();
131
132                tracing::info!(
133                    file_path = %path.display(),
134                    action = %action,
135                    lines = lines,
136                    bytes = bytes,
137                    write_duration_ms = write_duration.as_millis(),
138                    total_elapsed_ms = total_elapsed.as_millis(),
139                    "File write successful"
140                );
141
142                ToolResult::success(format!(
143                    "{} {} ({} lines, {} bytes)",
144                    action,
145                    path.display(),
146                    lines,
147                    bytes
148                ))
149                .with_metadata(json!({
150                    "path": path.display().to_string(),
151                    "created": !file_existed,
152                    "lines": lines,
153                    "bytes": bytes,
154                    "write_duration_ms": write_duration.as_millis(),
155                    "total_elapsed_ms": total_elapsed.as_millis()
156                }))
157            }
158            Err(e) => {
159                let elapsed = total_start.elapsed();
160                tracing::error!(
161                    file_path = %path.display(),
162                    error = %e,
163                    elapsed_ms = elapsed.as_millis(),
164                    "File write failed"
165                );
166                ToolResult::error(format!("Failed to write file: {}", e))
167            }
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use tempfile::TempDir;
176
177    #[tokio::test]
178    async fn test_write_new_file() {
179        let temp_dir = TempDir::new().unwrap();
180        let file_path = temp_dir.path().join("new_file.txt");
181
182        let tool = WriteTool::new();
183        let context = ToolContext::new("test", temp_dir.path());
184
185        let result = tool
186            .execute(
187                json!({
188                    "file_path": file_path.to_str().unwrap(),
189                    "content": "Hello, World!"
190                }),
191                &context,
192            )
193            .await;
194
195        assert!(!result.is_error);
196        assert!(result.content.contains("Created"));
197
198        // Verify file was created
199        let content = std::fs::read_to_string(&file_path).unwrap();
200        assert_eq!(content, "Hello, World!");
201    }
202
203    #[tokio::test]
204    async fn test_write_overwrite_file() {
205        let temp_dir = TempDir::new().unwrap();
206        let file_path = temp_dir.path().join("existing.txt");
207
208        // Create existing file
209        std::fs::write(&file_path, "Original content").unwrap();
210
211        let tool = WriteTool::new();
212        let context = ToolContext::new("test", temp_dir.path());
213
214        let result = tool
215            .execute(
216                json!({
217                    "file_path": file_path.to_str().unwrap(),
218                    "content": "New content"
219                }),
220                &context,
221            )
222            .await;
223
224        assert!(!result.is_error);
225        assert!(result.content.contains("Updated"));
226
227        // Verify content was replaced
228        let content = std::fs::read_to_string(&file_path).unwrap();
229        assert_eq!(content, "New content");
230    }
231
232    #[tokio::test]
233    async fn test_write_creates_directories() {
234        let temp_dir = TempDir::new().unwrap();
235        let file_path = temp_dir.path().join("nested/dir/file.txt");
236
237        let tool = WriteTool::new();
238        let context = ToolContext::new("test", temp_dir.path());
239
240        let result = tool
241            .execute(
242                json!({
243                    "file_path": file_path.to_str().unwrap(),
244                    "content": "Nested content"
245                }),
246                &context,
247            )
248            .await;
249
250        assert!(!result.is_error);
251        assert!(file_path.exists());
252    }
253
254    #[test]
255    fn test_write_tool_properties() {
256        let tool = WriteTool::new();
257        assert_eq!(tool.name(), "Write");
258        assert_eq!(tool.kind(), ToolKind::Edit);
259        assert!(tool.requires_permission());
260    }
261}