code_mesh_core/tool/
read.rs

1//! Enhanced Read tool implementation
2//! Features chunked reading, image detection, file suggestions, and comprehensive metadata
3
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7use std::path::{Path, PathBuf};
8use tokio::fs;
9use mime_guess::MimeGuess;
10use std::collections::VecDeque;
11
12use super::{Tool, ToolContext, ToolResult, ToolError};
13
14const DEFAULT_READ_LIMIT: usize = 2000;
15const MAX_LINE_LENGTH: usize = 2000;
16
17/// Tool for reading file contents
18pub struct ReadTool;
19
20#[derive(Debug, Deserialize)]
21struct ReadParams {
22    #[serde(rename = "filePath")]
23    file_path: String,
24    #[serde(default)]
25    offset: Option<usize>,
26    #[serde(default)]
27    limit: Option<usize>,
28}
29
30#[async_trait]
31impl Tool for ReadTool {
32    fn id(&self) -> &str {
33        "read"
34    }
35    
36    fn description(&self) -> &str {
37        "Read contents of a file with optional line offset and limit"
38    }
39    
40    fn parameters_schema(&self) -> Value {
41        json!({
42            "type": "object",
43            "properties": {
44                "filePath": {
45                    "type": "string",
46                    "description": "The absolute path to the file to read"
47                },
48                "offset": {
49                    "type": "number",
50                    "description": "The line number to start reading from (0-based)"
51                },
52                "limit": {
53                    "type": "number",
54                    "description": "The number of lines to read. Only provide if the file is too large to read at once."
55                }
56            },
57            "required": ["filePath"]
58        })
59    }
60    
61    async fn execute(
62        &self,
63        args: Value,
64        ctx: ToolContext,
65    ) -> Result<ToolResult, ToolError> {
66        let params: ReadParams = serde_json::from_value(args)
67            .map_err(|e| ToolError::InvalidParameters(e.to_string()))?;
68        
69        // Resolve path relative to working directory
70        let path = if PathBuf::from(&params.file_path).is_absolute() {
71            PathBuf::from(&params.file_path)
72        } else {
73            ctx.working_directory.join(&params.file_path)
74        };
75        
76        // Check if file exists and provide suggestions if not
77        if !path.exists() {
78            let suggestions = self.suggest_similar_files(&path).await;
79            let error_msg = if suggestions.is_empty() {
80                format!("File not found: {}", path.display())
81            } else {
82                format!(
83                    "File not found: {}\n\nDid you mean one of these?\n{}",
84                    path.display(),
85                    suggestions.join("\n")
86                )
87            };
88            return Err(ToolError::ExecutionFailed(error_msg));
89        }
90        
91        // Check if it's an image file
92        if let Some(image_type) = self.detect_image_type(&path) {
93            return Err(ToolError::ExecutionFailed(format!(
94                "This is an image file of type: {}\nUse a different tool to process images",
95                image_type
96            )));
97        }
98        
99        // Read file contents with chunking for large files
100        let content = match self.read_file_contents(&path).await {
101            Ok(content) => content,
102            Err(e) => return Err(ToolError::ExecutionFailed(format!("Failed to read file: {}", e))),
103        };
104        
105        // Process lines with offset and limit
106        let lines: Vec<&str> = content.lines().collect();
107        let total_lines = lines.len();
108        
109        let limit = params.limit.unwrap_or(DEFAULT_READ_LIMIT);
110        let offset = params.offset.unwrap_or(0);
111        
112        let start = offset.min(total_lines);
113        let end = (start + limit).min(total_lines);
114        
115        // Format output with line numbers, truncating long lines
116        let mut output_lines = Vec::new();
117        output_lines.push("<file>".to_string());
118        
119        for (i, line) in lines[start..end].iter().enumerate() {
120            let line_num = start + i + 1;
121            let truncated_line = if line.len() > MAX_LINE_LENGTH {
122                format!("{}...", &line[..MAX_LINE_LENGTH])
123            } else {
124                line.to_string()
125            };
126            output_lines.push(format!("{:05}| {}", line_num, truncated_line));
127        }
128        
129        if total_lines > end {
130            output_lines.push(format!(
131                "\n(File has more lines. Use 'offset' parameter to read beyond line {})",
132                end
133            ));
134        }
135        
136        output_lines.push("</file>".to_string());
137        
138        // Generate preview (first 20 lines for metadata)
139        let preview = lines
140            .iter()
141            .take(20)
142            .map(|line| {
143                if line.len() > 100 {
144                    format!("{}...", &line[..100])
145                } else {
146                    line.to_string()
147                }
148            })
149            .collect::<Vec<_>>()
150            .join("\n");
151        
152        // Calculate relative path for title
153        let title = path
154            .strip_prefix(&ctx.working_directory)
155            .unwrap_or(&path)
156            .to_string_lossy()
157            .to_string();
158        
159        // Prepare comprehensive metadata
160        let metadata = json!({
161            "path": path.to_string_lossy(),
162            "relative_path": title,
163            "total_lines": total_lines,
164            "lines_read": end - start,
165            "offset": start,
166            "limit": limit,
167            "encoding": "utf-8",
168            "file_size": content.len(),
169            "preview": preview,
170            "truncated_lines": lines[start..end].iter().any(|line| line.len() > MAX_LINE_LENGTH)
171        });
172        
173        Ok(ToolResult {
174            title,
175            metadata,
176            output: output_lines.join("\n"),
177        })
178    }
179}
180
181impl ReadTool {
182    /// Detect if a file is an image based on its extension
183    fn detect_image_type(&self, path: &Path) -> Option<&'static str> {
184        let extension = path.extension()?.to_str()?.to_lowercase();
185        match extension.as_str() {
186            "jpg" | "jpeg" => Some("JPEG"),
187            "png" => Some("PNG"),
188            "gif" => Some("GIF"),
189            "bmp" => Some("BMP"),
190            "svg" => Some("SVG"),
191            "webp" => Some("WebP"),
192            "tiff" | "tif" => Some("TIFF"),
193            "ico" => Some("ICO"),
194            _ => {
195                // Also check MIME type as fallback
196                let mime = MimeGuess::from_path(path).first();
197                if let Some(mime) = mime {
198                    if mime.type_() == mime_guess::mime::IMAGE {
199                        Some("Image")
200                    } else {
201                        None
202                    }
203                } else {
204                    None
205                }
206            }
207        }
208    }
209    
210    /// Read file contents with proper error handling
211    async fn read_file_contents(&self, path: &Path) -> Result<String, std::io::Error> {
212        // Check file metadata first
213        let metadata = fs::metadata(path).await?;
214        
215        if metadata.is_dir() {
216            return Err(std::io::Error::new(
217                std::io::ErrorKind::InvalidInput,
218                "Path is a directory, not a file"
219            ));
220        }
221        
222        // For very large files, we might want to limit reading
223        if metadata.len() > 100_000_000 { // 100MB limit
224            return Err(std::io::Error::new(
225                std::io::ErrorKind::InvalidInput,
226                "File too large to read (>100MB). Consider using offset and limit parameters."
227            ));
228        }
229        
230        fs::read_to_string(path).await
231    }
232    
233    /// Suggest similar files when target file is not found
234    async fn suggest_similar_files(&self, target_path: &Path) -> Vec<String> {
235        let mut suggestions = Vec::new();
236        
237        let Some(parent_dir) = target_path.parent() else {
238            return suggestions;
239        };
240        
241        let Some(target_name) = target_path.file_name().and_then(|n| n.to_str()) else {
242            return suggestions;
243        };
244        
245        // Read directory entries
246        let Ok(mut entries) = fs::read_dir(parent_dir).await else {
247            return suggestions;
248        };
249        
250        let target_lower = target_name.to_lowercase();
251        
252        while let Ok(Some(entry)) = entries.next_entry().await {
253            if let Some(name) = entry.file_name().to_str() {
254                let name_lower = name.to_lowercase();
255                
256                // Check for similar names (contains or is contained)
257                if name_lower.contains(&target_lower) || target_lower.contains(&name_lower) {
258                    if let Some(full_path) = parent_dir.join(&name).to_str() {
259                        suggestions.push(full_path.to_string());
260                        
261                        // Limit suggestions to avoid overwhelming output
262                        if suggestions.len() >= 3 {
263                            break;
264                        }
265                    }
266                }
267            }
268        }
269        
270        suggestions
271    }
272}