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