Skip to main content

agent_sdk/primitive_tools/
write.rs

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