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
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 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 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(¬ice);
137 }
138 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}