use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use tokio::fs;
use mime_guess::MimeGuess;
use std::collections::VecDeque;
use super::{Tool, ToolContext, ToolResult, ToolError};
const DEFAULT_READ_LIMIT: usize = 2000;
const MAX_LINE_LENGTH: usize = 2000;
pub struct ReadTool;
#[derive(Debug, Deserialize)]
struct ReadParams {
#[serde(rename = "filePath")]
file_path: String,
#[serde(default)]
offset: Option<usize>,
#[serde(default)]
limit: Option<usize>,
}
#[async_trait]
impl Tool for ReadTool {
fn id(&self) -> &str {
"read"
}
fn description(&self) -> &str {
"Read contents of a file with optional line offset and limit"
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"filePath": {
"type": "string",
"description": "The absolute path to the file to read"
},
"offset": {
"type": "number",
"description": "The line number to start reading from (0-based)"
},
"limit": {
"type": "number",
"description": "The number of lines to read. Only provide if the file is too large to read at once."
}
},
"required": ["filePath"]
})
}
async fn execute(
&self,
args: Value,
ctx: ToolContext,
) -> Result<ToolResult, ToolError> {
let params: ReadParams = serde_json::from_value(args)
.map_err(|e| ToolError::InvalidParameters(e.to_string()))?;
let path = if PathBuf::from(¶ms.file_path).is_absolute() {
PathBuf::from(¶ms.file_path)
} else {
ctx.working_directory.join(¶ms.file_path)
};
if !path.exists() {
let suggestions = self.suggest_similar_files(&path).await;
let error_msg = if suggestions.is_empty() {
format!("File not found: {}", path.display())
} else {
format!(
"File not found: {}\n\nDid you mean one of these?\n{}",
path.display(),
suggestions.join("\n")
)
};
return Err(ToolError::ExecutionFailed(error_msg));
}
if let Some(image_type) = self.detect_image_type(&path) {
return Err(ToolError::ExecutionFailed(format!(
"This is an image file of type: {}\nUse a different tool to process images",
image_type
)));
}
let content = match self.read_file_contents(&path).await {
Ok(content) => content,
Err(e) => return Err(ToolError::ExecutionFailed(format!("Failed to read file: {}", e))),
};
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
let limit = params.limit.unwrap_or(DEFAULT_READ_LIMIT);
let offset = params.offset.unwrap_or(0);
let start = offset.min(total_lines);
let end = (start + limit).min(total_lines);
let mut output_lines = Vec::new();
output_lines.push("<file>".to_string());
for (i, line) in lines[start..end].iter().enumerate() {
let line_num = start + i + 1;
let truncated_line = if line.len() > MAX_LINE_LENGTH {
format!("{}...", &line[..MAX_LINE_LENGTH])
} else {
line.to_string()
};
output_lines.push(format!("{:05}| {}", line_num, truncated_line));
}
if total_lines > end {
output_lines.push(format!(
"\n(File has more lines. Use 'offset' parameter to read beyond line {})",
end
));
}
output_lines.push("</file>".to_string());
let preview = lines
.iter()
.take(20)
.map(|line| {
if line.len() > 100 {
format!("{}...", &line[..100])
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
let title = path
.strip_prefix(&ctx.working_directory)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
let metadata = json!({
"path": path.to_string_lossy(),
"relative_path": title,
"total_lines": total_lines,
"lines_read": end - start,
"offset": start,
"limit": limit,
"encoding": "utf-8",
"file_size": content.len(),
"preview": preview,
"truncated_lines": lines[start..end].iter().any(|line| line.len() > MAX_LINE_LENGTH)
});
Ok(ToolResult {
title,
metadata,
output: output_lines.join("\n"),
})
}
}
impl ReadTool {
fn detect_image_type(&self, path: &Path) -> Option<&'static str> {
let extension = path.extension()?.to_str()?.to_lowercase();
match extension.as_str() {
"jpg" | "jpeg" => Some("JPEG"),
"png" => Some("PNG"),
"gif" => Some("GIF"),
"bmp" => Some("BMP"),
"svg" => Some("SVG"),
"webp" => Some("WebP"),
"tiff" | "tif" => Some("TIFF"),
"ico" => Some("ICO"),
_ => {
let mime = MimeGuess::from_path(path).first();
if let Some(mime) = mime {
if mime.type_() == mime_guess::mime::IMAGE {
Some("Image")
} else {
None
}
} else {
None
}
}
}
}
async fn read_file_contents(&self, path: &Path) -> Result<String, std::io::Error> {
let metadata = fs::metadata(path).await?;
if metadata.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Path is a directory, not a file"
));
}
if metadata.len() > 100_000_000 { return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"File too large to read (>100MB). Consider using offset and limit parameters."
));
}
fs::read_to_string(path).await
}
async fn suggest_similar_files(&self, target_path: &Path) -> Vec<String> {
let mut suggestions = Vec::new();
let Some(parent_dir) = target_path.parent() else {
return suggestions;
};
let Some(target_name) = target_path.file_name().and_then(|n| n.to_str()) else {
return suggestions;
};
let Ok(mut entries) = fs::read_dir(parent_dir).await else {
return suggestions;
};
let target_lower = target_name.to_lowercase();
while let Ok(Some(entry)) = entries.next_entry().await {
if let Some(name) = entry.file_name().to_str() {
let name_lower = name.to_lowercase();
if name_lower.contains(&target_lower) || target_lower.contains(&name_lower) {
if let Some(full_path) = parent_dir.join(&name).to_str() {
suggestions.push(full_path.to_string());
if suggestions.len() >= 3 {
break;
}
}
}
}
}
suggestions
}
}