1use 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 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 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(¬ice);
142 }
143 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}