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
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 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}