Skip to main content

synaps_cli/tools/
read.rs

1use serde_json::{json, Value};
2use crate::{Result, RuntimeError};
3use super::{Tool, ToolContext, expand_path};
4
5pub struct ReadTool;
6
7#[async_trait::async_trait]
8impl Tool for ReadTool {
9    fn name(&self) -> &str { "read" }
10
11    fn description(&self) -> &str {
12        "Read the contents of a file. Returns lines with line numbers. Reads up to 500 lines by default. For large files, use offset and limit to read in sections."
13    }
14
15    fn parameters(&self) -> Value {
16        json!({
17            "type": "object",
18            "properties": {
19                "path": {
20                    "type": "string",
21                    "description": "Path to the file to read"
22                },
23                "offset": {
24                    "type": "integer",
25                    "description": "Line number to start reading from (0-indexed, default: 0)"
26                },
27                "limit": {
28                    "type": "integer",
29                    "description": "Maximum number of lines to read (default: all lines)"
30                }
31            },
32            "required": ["path"]
33        })
34    }
35
36    async fn execute(&self, params: Value, _ctx: ToolContext) -> Result<String> {
37        let raw_path = params["path"].as_str()
38            .ok_or_else(|| RuntimeError::Tool("Missing path parameter".to_string()))?;
39        let path = expand_path(raw_path);
40
41        // Read raw bytes first to detect binary files
42        let bytes = tokio::fs::read(&path).await
43            .map_err(|e| RuntimeError::Tool(format!("Failed to read file '{}': {}", path.display(), e)))?;
44
45        let content = match String::from_utf8(bytes) {
46            Ok(s) => s,
47            Err(_) => return Err(RuntimeError::Tool(format!(
48                "File '{}' appears to be binary (not valid UTF-8). Use `bash` with `xxd` or `file` to inspect binary files.",
49                path.display()
50            ))),
51        };
52
53        let lines: Vec<&str> = content.lines().collect();
54        let total_lines = lines.len();
55
56        let offset = params["offset"].as_u64().unwrap_or(0) as usize;
57        let limit = params["limit"].as_u64().map(|l| l as usize).unwrap_or(500.min(total_lines));
58
59        let start = offset.min(total_lines);
60        let end = (start + limit).min(total_lines);
61
62        let mut result = String::new();
63        for (i, line) in lines[start..end].iter().enumerate() {
64            result.push_str(&format!("{}\t{}\n", start + i + 1, line));
65        }
66
67        if total_lines > end {
68            result.push_str(&format!("\n... ({} more lines)", total_lines - end));
69        }
70
71        Ok(result)
72    }
73}
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use super::super::test_helpers::create_tool_context;
78    use crate::tools::Tool;
79    use serde_json::json;
80
81    #[test]
82    fn test_read_tool_schema() {
83        let tool = ReadTool;
84        assert_eq!(tool.name(), "read");
85        assert!(!tool.description().is_empty());
86
87        let params = tool.parameters();
88        assert_eq!(params["type"], "object");
89        assert!(params["properties"].is_object());
90        assert!(params["required"].is_array());
91    }
92
93    #[tokio::test]
94    async fn test_read_tool_execution() {
95        let temp_dir = std::env::temp_dir();
96        let test_file = temp_dir.join("read_tool_test.txt");
97
98        // Create temp file with known content
99        let content = "line 1\nline 2\nline 3\nline 4\nline 5";
100        std::fs::write(&test_file, content).unwrap();
101
102        let tool = ReadTool;
103        let ctx = create_tool_context();
104
105        // Test basic read
106        let params = json!({
107            "path": test_file.to_string_lossy()
108        });
109        let result = tool.execute(params, ctx).await.unwrap();
110
111        // Verify line numbers and content
112        assert!(result.contains("1\tline 1"));
113        assert!(result.contains("2\tline 2"));
114        assert!(result.contains("5\tline 5"));
115
116        // Test with offset and limit
117        let ctx = create_tool_context();
118        let params = json!({
119            "path": test_file.to_string_lossy(),
120            "offset": 2,
121            "limit": 2
122        });
123        let result = tool.execute(params, ctx).await.unwrap();
124
125        assert!(result.contains("3\tline 3"));
126        assert!(result.contains("4\tline 4"));
127        assert!(!result.contains("1\tline 1"));
128        assert!(!result.contains("5\tline 5"));
129
130        // Cleanup
131        let _ = std::fs::remove_file(&test_file);
132    }
133
134    #[tokio::test]
135    async fn test_read_tool_offset() {
136        let temp_dir = std::env::temp_dir();
137        let test_file = temp_dir.join("test_read_tool_offset.txt");
138
139        // Write 10 lines
140        let lines = (1..=10).map(|i| format!("line {}", i)).collect::<Vec<_>>();
141        let content = lines.join("\n");
142        std::fs::write(&test_file, &content).unwrap();
143
144        let tool = ReadTool;
145        let ctx = create_tool_context();
146
147        // Read with offset=5 (0-indexed, so starts at line 6)
148        let params = json!({
149            "path": test_file.to_string_lossy(),
150            "offset": 5
151        });
152
153        let result = tool.execute(params, ctx).await.unwrap();
154
155        // First line shown should be line 6 (1-indexed in output)
156        assert!(result.contains("6\tline 6"));
157        // Should not contain earlier lines
158        assert!(!result.contains("1\tline 1"));
159        assert!(!result.contains("5\tline 5"));
160
161        // Cleanup
162        let _ = std::fs::remove_file(&test_file);
163    }
164}