claude_code_acp/mcp/tools/
read.rs

1//! Read tool implementation
2//!
3//! Reads file contents from the filesystem.
4
5use async_trait::async_trait;
6use serde::Deserialize;
7use serde_json::json;
8
9use super::base::{Tool, ToolKind};
10use crate::mcp::registry::{ToolContext, ToolResult};
11
12/// Read tool for reading file contents
13#[derive(Debug, Default)]
14pub struct ReadTool;
15
16/// Read tool input parameters
17#[derive(Debug, Deserialize)]
18struct ReadInput {
19    /// Path to the file to read
20    file_path: String,
21    /// Optional line offset to start reading from (1-indexed)
22    #[serde(default)]
23    offset: Option<usize>,
24    /// Optional maximum number of lines to read
25    #[serde(default)]
26    limit: Option<usize>,
27}
28
29impl ReadTool {
30    /// Create a new Read tool instance
31    pub fn new() -> Self {
32        Self
33    }
34}
35
36#[async_trait]
37impl Tool for ReadTool {
38    fn name(&self) -> &str {
39        "Read"
40    }
41
42    fn description(&self) -> &str {
43        "Read the contents of a file from the filesystem. Supports reading specific line ranges with offset and limit parameters."
44    }
45
46    fn input_schema(&self) -> serde_json::Value {
47        json!({
48            "type": "object",
49            "required": ["file_path"],
50            "properties": {
51                "file_path": {
52                    "type": "string",
53                    "description": "The absolute path to the file to read"
54                },
55                "offset": {
56                    "type": "integer",
57                    "description": "Line number to start reading from (1-indexed). Defaults to 1."
58                },
59                "limit": {
60                    "type": "integer",
61                    "description": "Maximum number of lines to read. Defaults to reading entire file."
62                }
63            }
64        })
65    }
66
67    fn kind(&self) -> ToolKind {
68        ToolKind::Read
69    }
70
71    fn requires_permission(&self) -> bool {
72        false // Reading doesn't require explicit permission
73    }
74
75    async fn execute(&self, input: serde_json::Value, context: &ToolContext) -> ToolResult {
76        // Parse input
77        let params: ReadInput = match serde_json::from_value(input) {
78            Ok(p) => p,
79            Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
80        };
81
82        // Resolve path relative to working directory if not absolute
83        let path = if std::path::Path::new(&params.file_path).is_absolute() {
84            std::path::PathBuf::from(&params.file_path)
85        } else {
86            context.cwd.join(&params.file_path)
87        };
88
89        // Check if file exists
90        if !path.exists() {
91            return ToolResult::error(format!("File not found: {}", path.display()));
92        }
93
94        // Check if it's a file
95        if !path.is_file() {
96            return ToolResult::error(format!("Not a file: {}", path.display()));
97        }
98
99        // Read file content with timing
100        let read_start = std::time::Instant::now();
101        let content = match tokio::fs::read_to_string(&path).await {
102            Ok(c) => c,
103            Err(e) => {
104                let read_duration = read_start.elapsed();
105                return ToolResult::error(format!(
106                    "Failed to read file: {} (elapsed: {}ms)",
107                    e,
108                    read_duration.as_millis()
109                ));
110            }
111        };
112        let read_duration = read_start.elapsed();
113
114        tracing::debug!(
115            file_path = %path.display(),
116            file_size_bytes = content.len(),
117            read_duration_ms = read_duration.as_millis(),
118            "File read completed"
119        );
120
121        // Apply offset and limit
122        let lines: Vec<&str> = content.lines().collect();
123        let total_lines = lines.len();
124
125        let offset = params.offset.unwrap_or(1).saturating_sub(1); // Convert to 0-indexed
126        let limit = params.limit.unwrap_or(lines.len());
127
128        if offset >= lines.len() {
129            return ToolResult::success("").with_metadata(json!({
130                "total_lines": total_lines,
131                "returned_lines": 0
132            }));
133        }
134
135        let selected_lines: Vec<String> = lines
136            .iter()
137            .skip(offset)
138            .take(limit)
139            .enumerate()
140            .map(|(i, line)| format!("{:6}→{}", offset + i + 1, line))
141            .collect();
142
143        let returned_lines = selected_lines.len();
144
145        // Calculate display path:
146        // - If file is under cwd, show relative path with ./ prefix for cwd files
147        // - If file is outside cwd, show absolute path
148        let display_path = if let Ok(rel) = path.strip_prefix(&context.cwd) {
149            let rel_str = rel.to_string_lossy();
150            if rel_str.is_empty() {
151                // File is the cwd directory itself (unlikely)
152                path.display().to_string()
153            } else if rel_str.contains('/') {
154                // File in subdirectory: crates/rcoder/Cargo.toml
155                rel_str.to_string()
156            } else {
157                // File directly in cwd: add ./ prefix
158                format!("./{}", rel_str)
159            }
160        } else {
161            // File outside cwd: show absolute path
162            path.display().to_string()
163        };
164
165        // Add file header with path and line range information
166        let header = format!(
167            "File: {} (lines {}-{} of {}, total {} lines)\n{}\n",
168            display_path,
169            offset + 1,
170            offset + returned_lines.min(total_lines),
171            total_lines,
172            total_lines,
173            "-".repeat(60)
174        );
175
176        let result = format!("{}\n{}", header, selected_lines.join("\n"));
177
178        tracing::info!(
179            file_path = %path.display(),
180            total_lines = total_lines,
181            returned_lines = returned_lines,
182            offset = offset + 1,
183            "File read successfully"
184        );
185
186        ToolResult::success(result).with_metadata(json!({
187            "total_lines": total_lines,
188            "returned_lines": returned_lines,
189            "offset": offset + 1,
190            "path": path.display().to_string(),
191            "read_duration_ms": read_duration.as_millis(),
192            "file_size_bytes": content.len()
193        }))
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use std::io::Write;
201    use tempfile::TempDir;
202
203    #[tokio::test]
204    async fn test_read_file() {
205        let temp_dir = TempDir::new().unwrap();
206        let file_path = temp_dir.path().join("test.txt");
207
208        let mut file = std::fs::File::create(&file_path).unwrap();
209        writeln!(file, "Line 1").unwrap();
210        writeln!(file, "Line 2").unwrap();
211        writeln!(file, "Line 3").unwrap();
212
213        let tool = ReadTool::new();
214        let context = ToolContext::new("test", temp_dir.path());
215
216        let result = tool
217            .execute(json!({"file_path": file_path.to_str().unwrap()}), &context)
218            .await;
219
220        assert!(!result.is_error);
221        assert!(result.content.contains("Line 1"));
222        assert!(result.content.contains("Line 2"));
223        assert!(result.content.contains("Line 3"));
224    }
225
226    #[tokio::test]
227    async fn test_read_with_offset_and_limit() {
228        let temp_dir = TempDir::new().unwrap();
229        let file_path = temp_dir.path().join("test.txt");
230
231        let mut file = std::fs::File::create(&file_path).unwrap();
232        for i in 1..=10 {
233            writeln!(file, "Line {}", i).unwrap();
234        }
235
236        let tool = ReadTool::new();
237        let context = ToolContext::new("test", temp_dir.path());
238
239        let result = tool
240            .execute(
241                json!({
242                    "file_path": file_path.to_str().unwrap(),
243                    "offset": 3,
244                    "limit": 2
245                }),
246                &context,
247            )
248            .await;
249
250        assert!(!result.is_error);
251        assert!(result.content.contains("Line 3"));
252        assert!(result.content.contains("Line 4"));
253        assert!(!result.content.contains("Line 5"));
254    }
255
256    #[tokio::test]
257    async fn test_read_file_not_found() {
258        let temp_dir = TempDir::new().unwrap();
259        let tool = ReadTool::new();
260        let context = ToolContext::new("test", temp_dir.path());
261
262        let result = tool
263            .execute(json!({"file_path": "/nonexistent/file.txt"}), &context)
264            .await;
265
266        assert!(result.is_error);
267        assert!(result.content.contains("not found"));
268    }
269
270    #[test]
271    fn test_read_tool_properties() {
272        let tool = ReadTool::new();
273        assert_eq!(tool.name(), "Read");
274        assert_eq!(tool.kind(), ToolKind::Read);
275        assert!(!tool.requires_permission());
276    }
277}