code_mesh_core/tool/
read.rs1use 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
17pub 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 let path = if PathBuf::from(¶ms.file_path).is_absolute() {
71 PathBuf::from(¶ms.file_path)
72 } else {
73 ctx.working_directory.join(¶ms.file_path)
74 };
75
76 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 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 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 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 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 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 let title = path
154 .strip_prefix(&ctx.working_directory)
155 .unwrap_or(&path)
156 .to_string_lossy()
157 .to_string();
158
159 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 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 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 async fn read_file_contents(&self, path: &Path) -> Result<String, std::io::Error> {
212 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 if metadata.len() > 100_000_000 { 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 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 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 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 if suggestions.len() >= 3 {
263 break;
264 }
265 }
266 }
267 }
268 }
269
270 suggestions
271 }
272}