soul_coder/tools/
write.rs1use 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 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}