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#[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        // Extract the requested range
108        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        // Apply truncation
125        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(&notice);
136            }
137            // Suggest next read parameters
138            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}