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