claude_agent/tools/
write.rs

1//! Write tool - creates or overwrites files with atomic operations.
2
3use async_trait::async_trait;
4use schemars::JsonSchema;
5use serde::Deserialize;
6
7use super::SchemaTool;
8use super::context::ExecutionContext;
9use crate::security::fs::SecureFileHandle;
10use crate::types::ToolResult;
11
12/// Input for the Write tool
13#[derive(Debug, Deserialize, JsonSchema)]
14#[schemars(deny_unknown_fields)]
15pub struct WriteInput {
16    /// The absolute path to the file to write (must be absolute, not relative)
17    pub file_path: String,
18    /// The content to write to the file
19    pub content: String,
20}
21
22#[derive(Debug, Clone, Copy, Default)]
23pub struct WriteTool;
24
25#[async_trait]
26impl SchemaTool for WriteTool {
27    type Input = WriteInput;
28
29    const NAME: &'static str = "Write";
30    const DESCRIPTION: &'static str = r#"Writes a file to the local filesystem.
31
32Usage:
33- This tool will overwrite the existing file if there is one at the provided path.
34- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.
35- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
36- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
37- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked."#;
38
39    async fn handle(&self, input: WriteInput, context: &ExecutionContext) -> ToolResult {
40        let path = match context.try_resolve_for(Self::NAME, &input.file_path) {
41            Ok(p) => p,
42            Err(e) => return e,
43        };
44
45        let content = input.content;
46        let content_len = content.len();
47        let display_path = path.as_path().display().to_string();
48
49        let result = tokio::task::spawn_blocking(move || {
50            let handle = SecureFileHandle::for_atomic_write(path)?;
51            handle.atomic_write(content.as_bytes())?;
52            Ok::<_, crate::security::SecurityError>(())
53        })
54        .await;
55
56        match result {
57            Ok(Ok(())) => ToolResult::success(format!(
58                "Successfully wrote {} bytes to {}",
59                content_len, display_path
60            )),
61            Ok(Err(e)) => ToolResult::error(format!("Failed to write file: {}", e)),
62            Err(e) => ToolResult::error(format!("Task failed: {}", e)),
63        }
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use crate::tools::Tool;
71    use tempfile::tempdir;
72    use tokio::fs;
73
74    #[tokio::test]
75    async fn test_write_file() {
76        let dir = tempdir().unwrap();
77        let root = std::fs::canonicalize(dir.path()).unwrap();
78        let file_path = root.join("test.txt");
79
80        let test_context = ExecutionContext::from_path(&root).unwrap();
81        let tool = WriteTool;
82
83        let result = tool
84            .execute(
85                serde_json::json!({
86                    "file_path": file_path.to_str().unwrap(),
87                    "content": "Hello, World!"
88                }),
89                &test_context,
90            )
91            .await;
92
93        assert!(!result.is_error());
94        let content = fs::read_to_string(&file_path).await.unwrap();
95        assert_eq!(content, "Hello, World!");
96    }
97
98    #[tokio::test]
99    async fn test_write_creates_directories() {
100        let dir = tempdir().unwrap();
101        let root = std::fs::canonicalize(dir.path()).unwrap();
102        let file_path = root.join("subdir/nested/test.txt");
103
104        let test_context = ExecutionContext::from_path(&root).unwrap();
105        let tool = WriteTool;
106
107        let result = tool
108            .execute(
109                serde_json::json!({
110                    "file_path": file_path.to_str().unwrap(),
111                    "content": "Nested content"
112                }),
113                &test_context,
114            )
115            .await;
116
117        assert!(!result.is_error());
118        assert!(file_path.exists());
119    }
120
121    #[tokio::test]
122    async fn test_write_path_escape_blocked() {
123        let dir = tempdir().unwrap();
124        let test_context = ExecutionContext::from_path(dir.path()).unwrap();
125        let tool = WriteTool;
126
127        let result = tool
128            .execute(
129                serde_json::json!({
130                    "file_path": "/etc/passwd",
131                    "content": "bad"
132                }),
133                &test_context,
134            )
135            .await;
136
137        assert!(result.is_error());
138    }
139
140    #[tokio::test]
141    async fn test_write_overwrites_existing() {
142        let dir = tempdir().unwrap();
143        let root = std::fs::canonicalize(dir.path()).unwrap();
144        let file_path = root.join("test.txt");
145        fs::write(&file_path, "original content").await.unwrap();
146
147        let test_context = ExecutionContext::from_path(&root).unwrap();
148        let tool = WriteTool;
149
150        let result = tool
151            .execute(
152                serde_json::json!({
153                    "file_path": file_path.to_str().unwrap(),
154                    "content": "new content"
155                }),
156                &test_context,
157            )
158            .await;
159
160        assert!(!result.is_error());
161        let content = fs::read_to_string(&file_path).await.unwrap();
162        assert_eq!(content, "new content");
163    }
164
165    #[tokio::test]
166    async fn test_write_empty_content() {
167        let dir = tempdir().unwrap();
168        let root = std::fs::canonicalize(dir.path()).unwrap();
169        let file_path = root.join("empty.txt");
170
171        let test_context = ExecutionContext::from_path(&root).unwrap();
172        let tool = WriteTool;
173
174        let result = tool
175            .execute(
176                serde_json::json!({
177                    "file_path": file_path.to_str().unwrap(),
178                    "content": ""
179                }),
180                &test_context,
181            )
182            .await;
183
184        assert!(!result.is_error());
185        let content = fs::read_to_string(&file_path).await.unwrap();
186        assert_eq!(content, "");
187    }
188
189    #[tokio::test]
190    async fn test_write_multiline_content() {
191        let dir = tempdir().unwrap();
192        let root = std::fs::canonicalize(dir.path()).unwrap();
193        let file_path = root.join("multi.txt");
194        let content = "line 1\nline 2\nline 3\n";
195
196        let test_context = ExecutionContext::from_path(&root).unwrap();
197        let tool = WriteTool;
198
199        let result = tool
200            .execute(
201                serde_json::json!({
202                    "file_path": file_path.to_str().unwrap(),
203                    "content": content
204                }),
205                &test_context,
206            )
207            .await;
208
209        assert!(!result.is_error());
210        let read_content = fs::read_to_string(&file_path).await.unwrap();
211        assert_eq!(read_content, content);
212    }
213
214    #[tokio::test]
215    async fn test_write_atomic_no_temp_files_remain() {
216        let dir = tempdir().unwrap();
217        let root = std::fs::canonicalize(dir.path()).unwrap();
218        let file_path = root.join("atomic_test.txt");
219
220        let test_context = ExecutionContext::from_path(&root).unwrap();
221        let tool = WriteTool;
222
223        let result = tool
224            .execute(
225                serde_json::json!({
226                    "file_path": file_path.to_str().unwrap(),
227                    "content": "atomic content"
228                }),
229                &test_context,
230            )
231            .await;
232
233        assert!(!result.is_error());
234
235        let entries: Vec<_> = std::fs::read_dir(&root).unwrap().collect();
236        let has_temp = entries.iter().any(|e| {
237            e.as_ref()
238                .unwrap()
239                .file_name()
240                .to_string_lossy()
241                .contains(".tmp")
242        });
243        assert!(!has_temp, "Temporary files should be cleaned up");
244    }
245
246    #[tokio::test]
247    async fn test_write_atomic_preserves_original_until_complete() {
248        let dir = tempdir().unwrap();
249        let root = std::fs::canonicalize(dir.path()).unwrap();
250        let file_path = root.join("preserve_test.txt");
251        fs::write(&file_path, "original content").await.unwrap();
252
253        let test_context = ExecutionContext::from_path(&root).unwrap();
254        let tool = WriteTool;
255
256        let result = tool
257            .execute(
258                serde_json::json!({
259                    "file_path": file_path.to_str().unwrap(),
260                    "content": "new content"
261                }),
262                &test_context,
263            )
264            .await;
265
266        assert!(!result.is_error());
267        let content = fs::read_to_string(&file_path).await.unwrap();
268        assert_eq!(content, "new content");
269    }
270}