agent_sdk/primitive_tools/
write.rs

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