Skip to main content

soul_coder/tools/
write.rs

1//! Write tool — create or overwrite files, auto-creating parent directories.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use serde_json::json;
7use tokio::sync::mpsc;
8
9use soul_core::error::SoulResult;
10use soul_core::tool::{Tool, ToolOutput};
11use soul_core::types::ToolDefinition;
12use soul_core::vfs::VirtualFs;
13
14use super::resolve_path;
15
16pub struct WriteTool {
17    fs: Arc<dyn VirtualFs>,
18    cwd: String,
19}
20
21impl WriteTool {
22    pub fn new(fs: Arc<dyn VirtualFs>, cwd: impl Into<String>) -> Self {
23        Self {
24            fs,
25            cwd: cwd.into(),
26        }
27    }
28}
29
30#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
31#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
32impl Tool for WriteTool {
33    fn name(&self) -> &str {
34        "write"
35    }
36
37    fn definition(&self) -> ToolDefinition {
38        ToolDefinition {
39            name: "write".into(),
40            description: "Write content to a file. Creates the file and parent directories if they don't exist. Overwrites existing files.".into(),
41            input_schema: json!({
42                "type": "object",
43                "properties": {
44                    "path": {
45                        "type": "string",
46                        "description": "File path to write to (relative to working directory or absolute)"
47                    },
48                    "content": {
49                        "type": "string",
50                        "description": "Content to write to the file"
51                    }
52                },
53                "required": ["path", "content"]
54            }),
55        }
56    }
57
58    async fn execute(
59        &self,
60        _call_id: &str,
61        arguments: serde_json::Value,
62        _partial_tx: Option<mpsc::UnboundedSender<String>>,
63    ) -> SoulResult<ToolOutput> {
64        let path = arguments
65            .get("path")
66            .and_then(|v| v.as_str())
67            .unwrap_or("");
68        let content = arguments
69            .get("content")
70            .and_then(|v| v.as_str())
71            .unwrap_or("");
72
73        if path.is_empty() {
74            return Ok(ToolOutput::error("Missing required parameter: path"));
75        }
76
77        let resolved = resolve_path(&self.cwd, path);
78
79        // Auto-create parent directories
80        if let Some(parent) = resolved.rsplit_once('/') {
81            if !parent.0.is_empty() {
82                let _ = self.fs.create_dir_all(parent.0).await;
83            }
84        }
85
86        match self.fs.write(&resolved, content).await {
87            Ok(()) => Ok(ToolOutput::success(format!(
88                "Wrote {} bytes to {}",
89                content.len(),
90                path
91            ))
92            .with_metadata(json!({
93                "bytes_written": content.len(),
94                "path": path,
95            }))),
96            Err(e) => Ok(ToolOutput::error(format!(
97                "Failed to write {}: {}",
98                path, e
99            ))),
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use soul_core::vfs::MemoryFs;
108
109    async fn setup() -> (Arc<MemoryFs>, WriteTool) {
110        let fs = Arc::new(MemoryFs::new());
111        let tool = WriteTool::new(fs.clone() as Arc<dyn VirtualFs>, "/project");
112        (fs, tool)
113    }
114
115    #[tokio::test]
116    async fn write_new_file() {
117        let (fs, tool) = setup().await;
118        let result = tool
119            .execute("c1", json!({"path": "new.txt", "content": "hello world"}), None)
120            .await
121            .unwrap();
122
123        assert!(!result.is_error);
124        assert!(result.content.contains("11 bytes"));
125
126        let content = fs.read_to_string("/project/new.txt").await.unwrap();
127        assert_eq!(content, "hello world");
128    }
129
130    #[tokio::test]
131    async fn write_creates_parent_dirs() {
132        let (fs, tool) = setup().await;
133        let result = tool
134            .execute(
135                "c2",
136                json!({"path": "deep/nested/dir/file.txt", "content": "deep"}),
137                None,
138            )
139            .await
140            .unwrap();
141
142        assert!(!result.is_error);
143        let content = fs.read_to_string("/project/deep/nested/dir/file.txt").await.unwrap();
144        assert_eq!(content, "deep");
145    }
146
147    #[tokio::test]
148    async fn write_overwrites() {
149        let (fs, tool) = setup().await;
150        fs.write("/project/existing.txt", "old content").await.unwrap();
151
152        let result = tool
153            .execute(
154                "c3",
155                json!({"path": "existing.txt", "content": "new content"}),
156                None,
157            )
158            .await
159            .unwrap();
160
161        assert!(!result.is_error);
162        let content = fs.read_to_string("/project/existing.txt").await.unwrap();
163        assert_eq!(content, "new content");
164    }
165
166    #[tokio::test]
167    async fn write_empty_path() {
168        let (_fs, tool) = setup().await;
169        let result = tool
170            .execute("c4", json!({"path": "", "content": "data"}), None)
171            .await
172            .unwrap();
173        assert!(result.is_error);
174    }
175
176    #[tokio::test]
177    async fn write_absolute_path() {
178        let (fs, tool) = setup().await;
179        let result = tool
180            .execute(
181                "c5",
182                json!({"path": "/abs/file.txt", "content": "abs"}),
183                None,
184            )
185            .await
186            .unwrap();
187
188        assert!(!result.is_error);
189        let content = fs.read_to_string("/abs/file.txt").await.unwrap();
190        assert_eq!(content, "abs");
191    }
192
193    #[tokio::test]
194    async fn tool_name_and_definition() {
195        let (_fs, tool) = setup().await;
196        assert_eq!(tool.name(), "write");
197        let def = tool.definition();
198        assert_eq!(def.name, "write");
199    }
200}