saorsa_agent/tools/
read.rs1use std::fs;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use super::resolve_path;
9use crate::error::{Result, SaorsaAgentError};
10use crate::tool::Tool;
11
12const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
14
15pub struct ReadTool {
17 working_dir: PathBuf,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23struct ReadInput {
24 file_path: String,
26 #[serde(default)]
28 line_range: Option<String>,
29}
30
31impl ReadTool {
32 pub fn new(working_dir: impl Into<PathBuf>) -> Self {
34 Self {
35 working_dir: working_dir.into(),
36 }
37 }
38
39 fn parse_line_range(range: &str) -> Result<(Option<usize>, Option<usize>)> {
41 let parts: Vec<&str> = range.split('-').collect();
42
43 match parts.as_slice() {
44 [start, end] if !start.is_empty() && !end.is_empty() => {
46 let start = start.parse::<usize>().map_err(|_| {
47 SaorsaAgentError::Tool(format!("Invalid start line number: {start}"))
48 })?;
49 let end = end.parse::<usize>().map_err(|_| {
50 SaorsaAgentError::Tool(format!("Invalid end line number: {end}"))
51 })?;
52
53 if start == 0 || end == 0 {
54 return Err(SaorsaAgentError::Tool(
55 "Line numbers must be >= 1".to_string(),
56 ));
57 }
58 if start > end {
59 return Err(SaorsaAgentError::Tool(format!(
60 "Start line ({start}) must be <= end line ({end})"
61 )));
62 }
63
64 Ok((Some(start), Some(end)))
65 }
66 [start, ""] if !start.is_empty() => {
68 let start = start.parse::<usize>().map_err(|_| {
69 SaorsaAgentError::Tool(format!("Invalid start line number: {start}"))
70 })?;
71
72 if start == 0 {
73 return Err(SaorsaAgentError::Tool(
74 "Line numbers must be >= 1".to_string(),
75 ));
76 }
77
78 Ok((Some(start), None))
79 }
80 ["", end] if !end.is_empty() => {
82 let end = end.parse::<usize>().map_err(|_| {
83 SaorsaAgentError::Tool(format!("Invalid end line number: {end}"))
84 })?;
85
86 if end == 0 {
87 return Err(SaorsaAgentError::Tool(
88 "Line numbers must be >= 1".to_string(),
89 ));
90 }
91
92 Ok((None, Some(end)))
93 }
94 _ => Err(SaorsaAgentError::Tool(format!(
95 "Invalid line range format: {range}"
96 ))),
97 }
98 }
99
100 fn filter_lines(content: &str, range: Option<&str>) -> Result<String> {
102 let Some(range_str) = range else {
103 return Ok(content.to_string());
104 };
105
106 let (start, end) = Self::parse_line_range(range_str)?;
107 let lines: Vec<&str> = content.lines().collect();
108 let total_lines = lines.len();
109
110 let start_idx = start.map(|n| n.saturating_sub(1)).unwrap_or(0);
112 let end_idx = end.map(|n| n.min(total_lines)).unwrap_or(total_lines);
113
114 if start_idx >= total_lines {
115 return Err(SaorsaAgentError::Tool(format!(
116 "Start line {} exceeds file length ({} lines)",
117 start.unwrap_or(1),
118 total_lines
119 )));
120 }
121
122 let selected = &lines[start_idx..end_idx];
123 Ok(selected.join("\n"))
124 }
125}
126
127#[async_trait::async_trait]
128impl Tool for ReadTool {
129 fn name(&self) -> &str {
130 "read"
131 }
132
133 fn description(&self) -> &str {
134 "Read file contents with optional line range filtering"
135 }
136
137 fn input_schema(&self) -> serde_json::Value {
138 serde_json::json!({
139 "type": "object",
140 "properties": {
141 "file_path": {
142 "type": "string",
143 "description": "Path to the file to read (absolute or relative to working directory)"
144 },
145 "line_range": {
146 "type": "string",
147 "description": "Optional line range (e.g., '10-20' for lines 10 through 20, '5-' from line 5 to end, '-10' first 10 lines)"
148 }
149 },
150 "required": ["file_path"]
151 })
152 }
153
154 async fn execute(&self, input: serde_json::Value) -> Result<String> {
155 let input: ReadInput = serde_json::from_value(input)
156 .map_err(|e| SaorsaAgentError::Tool(format!("Invalid input: {e}")))?;
157
158 let path = resolve_path(&self.working_dir, &input.file_path);
159
160 if !path.exists() {
162 return Err(SaorsaAgentError::Tool(format!(
163 "File not found: {}",
164 path.display()
165 )));
166 }
167
168 if !path.is_file() {
170 return Err(SaorsaAgentError::Tool(format!(
171 "Path is not a file: {}",
172 path.display()
173 )));
174 }
175
176 let metadata = fs::metadata(&path)
178 .map_err(|e| SaorsaAgentError::Tool(format!("Failed to read file metadata: {e}")))?;
179
180 if metadata.len() > MAX_FILE_SIZE {
181 return Err(SaorsaAgentError::Tool(format!(
182 "File too large: {} bytes (max {} bytes)",
183 metadata.len(),
184 MAX_FILE_SIZE
185 )));
186 }
187
188 let content = fs::read_to_string(&path)
190 .map_err(|e| SaorsaAgentError::Tool(format!("Failed to read file: {e}")))?;
191
192 let filtered = Self::filter_lines(&content, input.line_range.as_deref())?;
194
195 Ok(filtered)
196 }
197}
198
199#[cfg(test)]
200#[allow(clippy::unwrap_used)]
201mod tests {
202 use super::*;
203 use std::io::Write;
204 use tempfile::NamedTempFile;
205
206 #[test]
207 fn parse_line_range_full() {
208 let result = ReadTool::parse_line_range("10-20");
209 assert!(result.is_ok());
210 let (start, end) = result.unwrap();
211 assert_eq!(start, Some(10));
212 assert_eq!(end, Some(20));
213 }
214
215 #[test]
216 fn parse_line_range_from() {
217 let result = ReadTool::parse_line_range("5-");
218 assert!(result.is_ok());
219 let (start, end) = result.unwrap();
220 assert_eq!(start, Some(5));
221 assert_eq!(end, None);
222 }
223
224 #[test]
225 fn parse_line_range_to() {
226 let result = ReadTool::parse_line_range("-10");
227 assert!(result.is_ok());
228 let (start, end) = result.unwrap();
229 assert_eq!(start, None);
230 assert_eq!(end, Some(10));
231 }
232
233 #[test]
234 fn parse_line_range_invalid() {
235 assert!(ReadTool::parse_line_range("invalid").is_err());
236 assert!(ReadTool::parse_line_range("10-5").is_err());
237 assert!(ReadTool::parse_line_range("0-10").is_err());
238 assert!(ReadTool::parse_line_range("10-0").is_err());
239 }
240
241 #[test]
242 fn filter_lines_no_range() {
243 let content = "line1\nline2\nline3";
244 let result = ReadTool::filter_lines(content, None);
245 assert!(result.is_ok());
246 assert_eq!(result.unwrap(), content);
247 }
248
249 #[test]
250 fn filter_lines_full_range() {
251 let content = "line1\nline2\nline3\nline4\nline5";
252 let result = ReadTool::filter_lines(content, Some("2-4"));
253 assert!(result.is_ok());
254 assert_eq!(result.unwrap(), "line2\nline3\nline4");
255 }
256
257 #[test]
258 fn filter_lines_from_range() {
259 let content = "line1\nline2\nline3\nline4\nline5";
260 let result = ReadTool::filter_lines(content, Some("3-"));
261 assert!(result.is_ok());
262 assert_eq!(result.unwrap(), "line3\nline4\nline5");
263 }
264
265 #[test]
266 fn filter_lines_to_range() {
267 let content = "line1\nline2\nline3\nline4\nline5";
268 let result = ReadTool::filter_lines(content, Some("-3"));
269 assert!(result.is_ok());
270 assert_eq!(result.unwrap(), "line1\nline2\nline3");
271 }
272
273 #[test]
274 fn filter_lines_exceeds_length() {
275 let content = "line1\nline2\nline3";
276 let result = ReadTool::filter_lines(content, Some("10-20"));
277 assert!(result.is_err());
278 }
279
280 #[tokio::test]
281 async fn read_full_file() {
282 let mut temp = NamedTempFile::new().unwrap();
283 writeln!(temp, "line1").unwrap();
284 writeln!(temp, "line2").unwrap();
285 writeln!(temp, "line3").unwrap();
286 temp.flush().unwrap();
287
288 let tool = ReadTool::new(std::env::current_dir().unwrap());
289 let input = serde_json::json!({
290 "file_path": temp.path().to_str().unwrap()
291 });
292
293 let result = tool.execute(input).await;
294 assert!(result.is_ok());
295 let content = result.unwrap();
296 assert!(content.contains("line1"));
297 assert!(content.contains("line2"));
298 assert!(content.contains("line3"));
299 }
300
301 #[tokio::test]
302 async fn read_with_range() {
303 let mut temp = NamedTempFile::new().unwrap();
304 writeln!(temp, "line1").unwrap();
305 writeln!(temp, "line2").unwrap();
306 writeln!(temp, "line3").unwrap();
307 temp.flush().unwrap();
308
309 let tool = ReadTool::new(std::env::current_dir().unwrap());
310 let input = serde_json::json!({
311 "file_path": temp.path().to_str().unwrap(),
312 "line_range": "2-3"
313 });
314
315 let result = tool.execute(input).await;
316 assert!(result.is_ok());
317 let content = result.unwrap();
318 assert!(!content.contains("line1"));
319 assert!(content.contains("line2"));
320 assert!(content.contains("line3"));
321 }
322
323 #[tokio::test]
324 async fn read_nonexistent_file() {
325 let tool = ReadTool::new(std::env::current_dir().unwrap());
326 let input = serde_json::json!({
327 "file_path": "/nonexistent/file.txt"
328 });
329
330 let result = tool.execute(input).await;
331 assert!(result.is_err());
332 }
333
334 #[tokio::test]
335 async fn read_directory() {
336 let tool = ReadTool::new(std::env::current_dir().unwrap());
337 let input = serde_json::json!({
338 "file_path": std::env::current_dir().unwrap().to_str().unwrap()
339 });
340
341 let result = tool.execute(input).await;
342 assert!(result.is_err());
343 }
344}