agent_sdk/primitive_tools/
write.rs

1use crate::reminders::{append_reminder, builtin};
2use crate::{Environment, Tool, ToolContext, ToolResult, ToolTier};
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use serde::Deserialize;
6use serde_json::{Value, json};
7use std::sync::Arc;
8
9use super::PrimitiveToolContext;
10
11/// Tool for writing file contents
12pub struct WriteTool<E: Environment> {
13    ctx: PrimitiveToolContext<E>,
14}
15
16impl<E: Environment> WriteTool<E> {
17    #[must_use]
18    pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
19        Self {
20            ctx: PrimitiveToolContext::new(environment, capabilities),
21        }
22    }
23}
24
25#[derive(Debug, Deserialize)]
26struct WriteInput {
27    /// Path to the file to write (also accepts `file_path` for compatibility)
28    #[serde(alias = "file_path")]
29    path: String,
30    /// Content to write to the file
31    content: String,
32}
33
34#[async_trait]
35impl<E: Environment + 'static> Tool<()> for WriteTool<E> {
36    fn name(&self) -> &'static str {
37        "write"
38    }
39
40    fn description(&self) -> &'static str {
41        "Write content to a file. Creates the file if it doesn't exist, overwrites if it does."
42    }
43
44    fn tier(&self) -> ToolTier {
45        ToolTier::Confirm
46    }
47
48    fn input_schema(&self) -> Value {
49        json!({
50            "type": "object",
51            "properties": {
52                "path": {
53                    "type": "string",
54                    "description": "Path to the file to write"
55                },
56                "content": {
57                    "type": "string",
58                    "description": "Content to write to the file"
59                }
60            },
61            "required": ["path", "content"]
62        })
63    }
64
65    async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
66        let input: WriteInput =
67            serde_json::from_value(input).context("Invalid input for write tool")?;
68
69        let path = self.ctx.environment.resolve_path(&input.path);
70
71        // Check capabilities
72        if !self.ctx.capabilities.can_write(&path) {
73            return Ok(ToolResult::error(format!(
74                "Permission denied: cannot write to '{path}'"
75            )));
76        }
77
78        // Check if target is a directory
79        let exists = self
80            .ctx
81            .environment
82            .exists(&path)
83            .await
84            .context("Failed to check path existence")?;
85
86        if exists {
87            let is_dir = self
88                .ctx
89                .environment
90                .is_dir(&path)
91                .await
92                .context("Failed to check if path is directory")?;
93
94            if is_dir {
95                return Ok(ToolResult::error(format!(
96                    "'{path}' is a directory, cannot write"
97                )));
98            }
99        }
100
101        // Write file
102        self.ctx
103            .environment
104            .write_file(&path, &input.content)
105            .await
106            .context("Failed to write file")?;
107
108        let lines = input.content.lines().count();
109        let bytes = input.content.len();
110
111        let mut result = ToolResult::success(format!(
112            "Successfully wrote {lines} lines ({bytes} bytes) to '{path}'"
113        ));
114
115        // Add verification reminder
116        append_reminder(&mut result, builtin::WRITE_VERIFICATION_REMINDER);
117
118        Ok(result)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::{AgentCapabilities, InMemoryFileSystem};
126
127    fn create_test_tool(
128        fs: Arc<InMemoryFileSystem>,
129        capabilities: AgentCapabilities,
130    ) -> WriteTool<InMemoryFileSystem> {
131        WriteTool::new(fs, capabilities)
132    }
133
134    fn tool_ctx() -> ToolContext<()> {
135        ToolContext::new(())
136    }
137
138    // ===================
139    // Unit Tests
140    // ===================
141
142    #[tokio::test]
143    async fn test_write_new_file() -> anyhow::Result<()> {
144        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
145
146        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
147        let result = tool
148            .execute(
149                &tool_ctx(),
150                json!({"path": "/workspace/new_file.txt", "content": "Hello, World!"}),
151            )
152            .await?;
153
154        assert!(result.success);
155        assert!(result.output.contains("Successfully wrote"));
156        assert!(result.output.contains("1 lines"));
157        assert!(result.output.contains("13 bytes"));
158
159        // Verify file was created
160        let content = fs.read_file("/workspace/new_file.txt").await?;
161        assert_eq!(content, "Hello, World!");
162        Ok(())
163    }
164
165    #[tokio::test]
166    async fn test_write_overwrite_existing_file() -> anyhow::Result<()> {
167        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
168        fs.write_file("existing.txt", "old content").await?;
169
170        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
171        let result = tool
172            .execute(
173                &tool_ctx(),
174                json!({"path": "/workspace/existing.txt", "content": "new content"}),
175            )
176            .await?;
177
178        assert!(result.success);
179
180        // Verify file was overwritten
181        let content = fs.read_file("/workspace/existing.txt").await?;
182        assert_eq!(content, "new content");
183        Ok(())
184    }
185
186    #[tokio::test]
187    async fn test_write_multiline_content() -> anyhow::Result<()> {
188        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
189        let content = "line 1\nline 2\nline 3\nline 4";
190
191        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
192        let result = tool
193            .execute(
194                &tool_ctx(),
195                json!({"path": "/workspace/multi.txt", "content": content}),
196            )
197            .await?;
198
199        assert!(result.success);
200        assert!(result.output.contains("4 lines"));
201
202        // Verify content
203        let read_content = fs.read_file("/workspace/multi.txt").await?;
204        assert_eq!(read_content, content);
205        Ok(())
206    }
207
208    // ===================
209    // Integration Tests
210    // ===================
211
212    #[tokio::test]
213    async fn test_write_permission_denied_no_write_capability() -> anyhow::Result<()> {
214        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
215
216        // Read-only capabilities
217        let caps = AgentCapabilities::read_only();
218
219        let tool = create_test_tool(fs, caps);
220        let result = tool
221            .execute(
222                &tool_ctx(),
223                json!({"path": "/workspace/test.txt", "content": "content"}),
224            )
225            .await?;
226
227        assert!(!result.success);
228        assert!(result.output.contains("Permission denied"));
229        Ok(())
230    }
231
232    #[tokio::test]
233    async fn test_write_permission_denied_via_denied_paths() -> anyhow::Result<()> {
234        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
235
236        // Full access but deny secrets directory
237        let caps = AgentCapabilities::full_access()
238            .with_denied_paths(vec!["/workspace/secrets/**".into()]);
239
240        let tool = create_test_tool(fs, caps);
241        let result = tool
242            .execute(
243                &tool_ctx(),
244                json!({"path": "/workspace/secrets/key.txt", "content": "secret"}),
245            )
246            .await?;
247
248        assert!(!result.success);
249        assert!(result.output.contains("Permission denied"));
250        Ok(())
251    }
252
253    #[tokio::test]
254    async fn test_write_allowed_path_restriction() -> anyhow::Result<()> {
255        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
256
257        // Only allow writing to src/
258        let caps = AgentCapabilities::full_access()
259            .with_denied_paths(vec![])
260            .with_allowed_paths(vec!["/workspace/src/**".into()]);
261
262        let tool = create_test_tool(Arc::clone(&fs), caps.clone());
263
264        // Should be able to write to src/
265        let result = tool
266            .execute(
267                &tool_ctx(),
268                json!({"path": "/workspace/src/main.rs", "content": "fn main() {}"}),
269            )
270            .await?;
271        assert!(result.success);
272
273        // Should NOT be able to write to config/
274        let tool = create_test_tool(fs, caps);
275        let result = tool
276            .execute(
277                &tool_ctx(),
278                json!({"path": "/workspace/config/settings.toml", "content": "key = value"}),
279            )
280            .await?;
281        assert!(!result.success);
282        assert!(result.output.contains("Permission denied"));
283        Ok(())
284    }
285
286    // ===================
287    // Edge Cases
288    // ===================
289
290    #[tokio::test]
291    async fn test_write_to_nested_directory() -> anyhow::Result<()> {
292        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
293
294        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
295        let result = tool
296            .execute(
297                &tool_ctx(),
298                json!({"path": "/workspace/deep/nested/dir/file.txt", "content": "nested content"}),
299            )
300            .await?;
301
302        assert!(result.success);
303
304        // Verify file was created
305        let content = fs.read_file("/workspace/deep/nested/dir/file.txt").await?;
306        assert_eq!(content, "nested content");
307        Ok(())
308    }
309
310    #[tokio::test]
311    async fn test_write_empty_content() -> anyhow::Result<()> {
312        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
313
314        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
315        let result = tool
316            .execute(
317                &tool_ctx(),
318                json!({"path": "/workspace/empty.txt", "content": ""}),
319            )
320            .await?;
321
322        assert!(result.success);
323        assert!(result.output.contains("0 lines"));
324        assert!(result.output.contains("0 bytes"));
325
326        // Verify file was created
327        let content = fs.read_file("/workspace/empty.txt").await?;
328        assert_eq!(content, "");
329        Ok(())
330    }
331
332    #[tokio::test]
333    async fn test_write_to_directory_path_returns_error() -> anyhow::Result<()> {
334        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
335        fs.create_dir("/workspace/subdir").await?;
336
337        let tool = create_test_tool(fs, AgentCapabilities::full_access());
338        let result = tool
339            .execute(
340                &tool_ctx(),
341                json!({"path": "/workspace/subdir", "content": "content"}),
342            )
343            .await?;
344
345        assert!(!result.success);
346        assert!(result.output.contains("is a directory"));
347        Ok(())
348    }
349
350    #[tokio::test]
351    async fn test_write_content_with_special_characters() -> anyhow::Result<()> {
352        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
353        let content = "特殊字符\néàü\n🎉emoji\ntab\there";
354
355        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
356        let result = tool
357            .execute(
358                &tool_ctx(),
359                json!({"path": "/workspace/special.txt", "content": content}),
360            )
361            .await?;
362
363        assert!(result.success);
364
365        // Verify content preserved
366        let read_content = fs.read_file("/workspace/special.txt").await?;
367        assert_eq!(read_content, content);
368        Ok(())
369    }
370
371    #[tokio::test]
372    async fn test_write_tool_metadata() {
373        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
374        let tool = create_test_tool(fs, AgentCapabilities::full_access());
375
376        assert_eq!(tool.name(), "write");
377        assert_eq!(tool.tier(), ToolTier::Confirm);
378        assert!(tool.description().contains("Write"));
379
380        let schema = tool.input_schema();
381        assert!(schema.get("properties").is_some());
382        assert!(schema["properties"].get("path").is_some());
383        assert!(schema["properties"].get("content").is_some());
384    }
385
386    #[tokio::test]
387    async fn test_write_invalid_input_missing_path() -> anyhow::Result<()> {
388        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
389        let tool = create_test_tool(fs, AgentCapabilities::full_access());
390
391        // Missing required path field
392        let result = tool
393            .execute(&tool_ctx(), json!({"content": "some content"}))
394            .await;
395
396        assert!(result.is_err());
397        Ok(())
398    }
399
400    #[tokio::test]
401    async fn test_write_invalid_input_missing_content() -> anyhow::Result<()> {
402        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
403        let tool = create_test_tool(fs, AgentCapabilities::full_access());
404
405        // Missing required content field
406        let result = tool
407            .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
408            .await;
409
410        assert!(result.is_err());
411        Ok(())
412    }
413
414    #[tokio::test]
415    async fn test_write_large_file() -> anyhow::Result<()> {
416        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
417
418        // Create content with 1000 lines
419        let content: String = (1..=1000)
420            .map(|i| format!("line {i}"))
421            .collect::<Vec<_>>()
422            .join("\n");
423
424        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
425        let result = tool
426            .execute(
427                &tool_ctx(),
428                json!({"path": "/workspace/large.txt", "content": content}),
429            )
430            .await?;
431
432        assert!(result.success);
433        assert!(result.output.contains("1000 lines"));
434
435        // Verify content
436        let read_content = fs.read_file("/workspace/large.txt").await?;
437        assert_eq!(read_content, content);
438        Ok(())
439    }
440}