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