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