Skip to main content

saorsa_agent/tools/
read.rs

1//! Read tool for reading file contents with optional line ranges.
2
3use 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
12/// Maximum file size in bytes (10 MB).
13const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
14
15/// Tool for reading file contents.
16pub struct ReadTool {
17    /// Base directory for resolving relative paths.
18    working_dir: PathBuf,
19}
20
21/// Input parameters for the Read tool.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23struct ReadInput {
24    /// Path to the file to read.
25    file_path: String,
26    /// Optional line range (e.g., "10-20", "5-", "-10").
27    #[serde(default)]
28    line_range: Option<String>,
29}
30
31impl ReadTool {
32    /// Create a new Read tool with the given working directory.
33    pub fn new(working_dir: impl Into<PathBuf>) -> Self {
34        Self {
35            working_dir: working_dir.into(),
36        }
37    }
38
39    /// Parse a line range string (e.g., "10-20", "5-", "-10").
40    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            // "N-M" - lines N through M (inclusive, 1-indexed)
45            [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            // "N-" - from line N to end
67            [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            // "-M" - from start to line M
81            ["", 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    /// Filter lines based on the line range.
101    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        // Convert 1-indexed to 0-indexed
111        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        // Check if file exists
161        if !path.exists() {
162            return Err(SaorsaAgentError::Tool(format!(
163                "File not found: {}",
164                path.display()
165            )));
166        }
167
168        // Check if path is a file
169        if !path.is_file() {
170            return Err(SaorsaAgentError::Tool(format!(
171                "Path is not a file: {}",
172                path.display()
173            )));
174        }
175
176        // Check file size
177        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        // Read file contents
189        let content = fs::read_to_string(&path)
190            .map_err(|e| SaorsaAgentError::Tool(format!("Failed to read file: {e}")))?;
191
192        // Filter by line range if specified
193        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}