claude_agent/tools/
write.rs1use 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#[derive(Debug, Deserialize, JsonSchema)]
14#[schemars(deny_unknown_fields)]
15pub struct WriteInput {
16 pub file_path: String,
18 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}