Skip to main content

soul_coder/tools/
read.rs

1//! Read tool — read file contents with line numbers, offset, and truncation.
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 crate::truncate::{add_line_numbers, truncate_head, MAX_BYTES, MAX_LINES};
15
16pub struct ReadTool {
17    fs: Arc<dyn VirtualFs>,
18    cwd: String,
19}
20
21impl ReadTool {
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    fn resolve_path(&self, path: &str) -> String {
30        if path.starts_with('/') {
31            path.to_string()
32        } else {
33            format!("{}/{}", self.cwd.trim_end_matches('/'), path)
34        }
35    }
36}
37
38#[async_trait]
39impl Tool for ReadTool {
40    fn name(&self) -> &str {
41        "read"
42    }
43
44    fn definition(&self) -> ToolDefinition {
45        ToolDefinition {
46            name: "read".into(),
47            description: "Read the contents of a file. Returns line-numbered output. Use offset and limit for large files.".into(),
48            input_schema: json!({
49                "type": "object",
50                "properties": {
51                    "path": {
52                        "type": "string",
53                        "description": "File path to read (relative to working directory or absolute)"
54                    },
55                    "offset": {
56                        "type": "integer",
57                        "description": "1-indexed line number to start reading from"
58                    },
59                    "limit": {
60                        "type": "integer",
61                        "description": "Number of lines to read"
62                    }
63                },
64                "required": ["path"]
65            }),
66        }
67    }
68
69    async fn execute(
70        &self,
71        _call_id: &str,
72        arguments: serde_json::Value,
73        _partial_tx: Option<mpsc::UnboundedSender<String>>,
74    ) -> SoulResult<ToolOutput> {
75        let path = arguments
76            .get("path")
77            .and_then(|v| v.as_str())
78            .unwrap_or("");
79
80        if path.is_empty() {
81            return Ok(ToolOutput::error("Missing required parameter: path"));
82        }
83
84        let resolved = self.resolve_path(path);
85
86        let exists = self.fs.exists(&resolved).await?;
87        if !exists {
88            return Ok(ToolOutput::error(format!("File not found: {}", path)));
89        }
90
91        let content = match self.fs.read_to_string(&resolved).await {
92            Ok(c) => c,
93            Err(e) => return Ok(ToolOutput::error(format!("Failed to read {}: {}", path, e))),
94        };
95
96        let offset = arguments
97            .get("offset")
98            .and_then(|v| v.as_u64())
99            .map(|v| v as usize)
100            .unwrap_or(1);
101
102        let limit = arguments
103            .get("limit")
104            .and_then(|v| v.as_u64())
105            .map(|v| v as usize);
106
107        let total_lines = content.lines().count();
108
109        if offset < 1 {
110            return Ok(ToolOutput::error("offset must be >= 1"));
111        }
112
113        // Extract the requested range
114        let lines: Vec<&str> = content.lines().collect();
115        let start_idx = (offset - 1).min(lines.len());
116        let end_idx = match limit {
117            Some(l) => (start_idx + l).min(lines.len()),
118            None => lines.len(),
119        };
120
121        if start_idx >= lines.len() {
122            return Ok(ToolOutput::error(format!(
123                "offset {} exceeds file length ({} lines)",
124                offset, total_lines
125            )));
126        }
127
128        let selected: String = lines[start_idx..end_idx].join("\n");
129
130        // Apply truncation
131        let max_lines = limit.unwrap_or(MAX_LINES).min(MAX_LINES);
132        let result = truncate_head(&selected, max_lines, MAX_BYTES);
133
134        let numbered = add_line_numbers(&result.content, offset);
135
136        let mut output = numbered;
137
138        if result.is_truncated() {
139            if let Some(notice) = result.truncation_notice() {
140                output.push('\n');
141                output.push_str(&notice);
142            }
143            // Suggest next read parameters
144            let next_offset = offset + result.output_lines;
145            let remaining = total_lines.saturating_sub(next_offset - 1);
146            if remaining > 0 {
147                output.push_str(&format!(
148                    "\n[To continue reading: offset={}, limit={}]",
149                    next_offset,
150                    remaining.min(MAX_LINES)
151                ));
152            }
153        }
154
155        Ok(ToolOutput::success(output).with_metadata(json!({
156            "total_lines": total_lines,
157            "offset": offset,
158            "lines_returned": result.output_lines,
159            "truncated": result.is_truncated(),
160        })))
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use soul_core::vfs::MemoryFs;
168
169    async fn setup() -> (Arc<MemoryFs>, ReadTool) {
170        let fs = Arc::new(MemoryFs::new());
171        let tool = ReadTool::new(fs.clone() as Arc<dyn VirtualFs>, "/project");
172        (fs, tool)
173    }
174
175    #[tokio::test]
176    async fn read_file() {
177        let (fs, tool) = setup().await;
178        fs.write("/project/hello.txt", "line1\nline2\nline3")
179            .await
180            .unwrap();
181
182        let result = tool
183            .execute("c1", json!({"path": "hello.txt"}), None)
184            .await
185            .unwrap();
186
187        assert!(!result.is_error);
188        assert!(result.content.contains("line1"));
189        assert!(result.content.contains("line2"));
190        assert!(result.content.contains("line3"));
191    }
192
193    #[tokio::test]
194    async fn read_with_offset_and_limit() {
195        let (fs, tool) = setup().await;
196        let content = (1..=10).map(|i| format!("line{}", i)).collect::<Vec<_>>().join("\n");
197        fs.write("/project/big.txt", &content).await.unwrap();
198
199        let result = tool
200            .execute("c2", json!({"path": "big.txt", "offset": 3, "limit": 2}), None)
201            .await
202            .unwrap();
203
204        assert!(!result.is_error);
205        assert!(result.content.contains("line3"));
206        assert!(result.content.contains("line4"));
207        assert!(!result.content.contains("line5"));
208    }
209
210    #[tokio::test]
211    async fn read_nonexistent() {
212        let (_fs, tool) = setup().await;
213        let result = tool
214            .execute("c3", json!({"path": "nope.txt"}), None)
215            .await
216            .unwrap();
217        assert!(result.is_error);
218        assert!(result.content.contains("not found"));
219    }
220
221    #[tokio::test]
222    async fn read_absolute_path() {
223        let (fs, tool) = setup().await;
224        fs.write("/abs/file.txt", "absolute").await.unwrap();
225
226        let result = tool
227            .execute("c4", json!({"path": "/abs/file.txt"}), None)
228            .await
229            .unwrap();
230        assert!(!result.is_error);
231        assert!(result.content.contains("absolute"));
232    }
233
234    #[tokio::test]
235    async fn read_empty_path() {
236        let (_fs, tool) = setup().await;
237        let result = tool
238            .execute("c5", json!({"path": ""}), None)
239            .await
240            .unwrap();
241        assert!(result.is_error);
242    }
243
244    #[tokio::test]
245    async fn read_offset_beyond_file() {
246        let (fs, tool) = setup().await;
247        fs.write("/project/short.txt", "one\ntwo").await.unwrap();
248
249        let result = tool
250            .execute("c6", json!({"path": "short.txt", "offset": 100}), None)
251            .await
252            .unwrap();
253        assert!(result.is_error);
254        assert!(result.content.contains("exceeds"));
255    }
256
257    #[tokio::test]
258    async fn tool_name_and_definition() {
259        let (_fs, tool) = setup().await;
260        assert_eq!(tool.name(), "read");
261        let def = tool.definition();
262        assert_eq!(def.name, "read");
263        assert!(def.description.contains("Read"));
264    }
265}