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