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 = AgentCapabilities::full_access()
263            .with_denied_paths(vec![])
264            .with_allowed_paths(vec!["/workspace/src/**".into()]);
265
266        let tool = create_test_tool(Arc::clone(&fs), caps.clone());
267
268        // Should be able to write to src/
269        let result = tool
270            .execute(
271                &tool_ctx(),
272                json!({"path": "/workspace/src/main.rs", "content": "fn main() {}"}),
273            )
274            .await?;
275        assert!(result.success);
276
277        // Should NOT be able to write to config/
278        let tool = create_test_tool(fs, caps);
279        let result = tool
280            .execute(
281                &tool_ctx(),
282                json!({"path": "/workspace/config/settings.toml", "content": "key = value"}),
283            )
284            .await?;
285        assert!(!result.success);
286        assert!(result.output.contains("Permission denied"));
287        Ok(())
288    }
289
290    // ===================
291    // Edge Cases
292    // ===================
293
294    #[tokio::test]
295    async fn test_write_to_nested_directory() -> anyhow::Result<()> {
296        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
297
298        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
299        let result = tool
300            .execute(
301                &tool_ctx(),
302                json!({"path": "/workspace/deep/nested/dir/file.txt", "content": "nested content"}),
303            )
304            .await?;
305
306        assert!(result.success);
307
308        // Verify file was created
309        let content = fs.read_file("/workspace/deep/nested/dir/file.txt").await?;
310        assert_eq!(content, "nested content");
311        Ok(())
312    }
313
314    #[tokio::test]
315    async fn test_write_empty_content() -> anyhow::Result<()> {
316        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
317
318        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
319        let result = tool
320            .execute(
321                &tool_ctx(),
322                json!({"path": "/workspace/empty.txt", "content": ""}),
323            )
324            .await?;
325
326        assert!(result.success);
327        assert!(result.output.contains("0 lines"));
328        assert!(result.output.contains("0 bytes"));
329
330        // Verify file was created
331        let content = fs.read_file("/workspace/empty.txt").await?;
332        assert_eq!(content, "");
333        Ok(())
334    }
335
336    #[tokio::test]
337    async fn test_write_to_directory_path_returns_error() -> anyhow::Result<()> {
338        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
339        fs.create_dir("/workspace/subdir").await?;
340
341        let tool = create_test_tool(fs, AgentCapabilities::full_access());
342        let result = tool
343            .execute(
344                &tool_ctx(),
345                json!({"path": "/workspace/subdir", "content": "content"}),
346            )
347            .await?;
348
349        assert!(!result.success);
350        assert!(result.output.contains("is a directory"));
351        Ok(())
352    }
353
354    #[tokio::test]
355    async fn test_write_content_with_special_characters() -> anyhow::Result<()> {
356        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
357        let content = "特殊字符\néàü\n🎉emoji\ntab\there";
358
359        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
360        let result = tool
361            .execute(
362                &tool_ctx(),
363                json!({"path": "/workspace/special.txt", "content": content}),
364            )
365            .await?;
366
367        assert!(result.success);
368
369        // Verify content preserved
370        let read_content = fs.read_file("/workspace/special.txt").await?;
371        assert_eq!(read_content, content);
372        Ok(())
373    }
374
375    #[tokio::test]
376    async fn test_write_tool_metadata() {
377        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
378        let tool = create_test_tool(fs, AgentCapabilities::full_access());
379
380        assert_eq!(tool.name(), PrimitiveToolName::Write);
381        assert_eq!(tool.tier(), ToolTier::Confirm);
382        assert!(tool.description().contains("Write"));
383
384        let schema = tool.input_schema();
385        assert!(schema.get("properties").is_some());
386        assert!(schema["properties"].get("path").is_some());
387        assert!(schema["properties"].get("content").is_some());
388    }
389
390    #[tokio::test]
391    async fn test_write_invalid_input_missing_path() -> anyhow::Result<()> {
392        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
393        let tool = create_test_tool(fs, AgentCapabilities::full_access());
394
395        // Missing required path field
396        let result = tool
397            .execute(&tool_ctx(), json!({"content": "some content"}))
398            .await;
399
400        assert!(result.is_err());
401        Ok(())
402    }
403
404    #[tokio::test]
405    async fn test_write_invalid_input_missing_content() -> anyhow::Result<()> {
406        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
407        let tool = create_test_tool(fs, AgentCapabilities::full_access());
408
409        // Missing required content field
410        let result = tool
411            .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
412            .await;
413
414        assert!(result.is_err());
415        Ok(())
416    }
417
418    #[tokio::test]
419    async fn test_write_large_file() -> anyhow::Result<()> {
420        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
421
422        // Create content with 1000 lines
423        let content: String = (1..=1000)
424            .map(|i| format!("line {i}"))
425            .collect::<Vec<_>>()
426            .join("\n");
427
428        let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
429        let result = tool
430            .execute(
431                &tool_ctx(),
432                json!({"path": "/workspace/large.txt", "content": content}),
433            )
434            .await?;
435
436        assert!(result.success);
437        assert!(result.output.contains("1000 lines"));
438
439        // Verify content
440        let read_content = fs.read_file("/workspace/large.txt").await?;
441        assert_eq!(read_content, content);
442        Ok(())
443    }
444}