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#[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        // Auto-create parent directories
79        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}