use async_trait::async_trait;
use limit_agent::error::AgentError;
use limit_agent::Tool;
use serde_json::Value;
use std::fs;
use std::path::Path;
const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024;
const LARGE_FILE_THRESHOLD: usize = 5_000;
pub struct FileReadTool;
impl FileReadTool {
pub fn new() -> Self {
FileReadTool
}
}
impl Default for FileReadTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for FileReadTool {
fn name(&self) -> &str {
"file_read"
}
async fn execute(&self, args: Value) -> Result<Value, AgentError> {
let path: String = serde_json::from_value(args["path"].clone())
.map_err(|e| AgentError::ToolError(format!("Invalid path argument: {}", e)))?;
let path_obj = Path::new(&path);
if !path_obj.exists() {
return Err(AgentError::ToolError(format!("File not found: {}", path)));
}
if !path_obj.is_file() {
return Err(AgentError::ToolError(format!(
"Path is not a file: {}",
path
)));
}
let metadata = fs::metadata(&path)
.map_err(|e| AgentError::IoError(format!("Failed to read file metadata: {}", e)))?;
if metadata.len() > MAX_FILE_SIZE {
return Err(AgentError::ToolError(format!(
"File too large: {} bytes (max: {} bytes)",
metadata.len(),
MAX_FILE_SIZE
)));
}
let content = fs::read_to_string(&path)
.map_err(|e| AgentError::IoError(format!("Failed to read file: {}", e)))?;
if content.contains('\0') {
return Err(AgentError::ToolError(format!(
"Binary file detected: {}",
path
)));
}
let (content, was_truncated) = if content.len() > LARGE_FILE_THRESHOLD {
(
content
.chars()
.take(LARGE_FILE_THRESHOLD)
.collect::<String>(),
true,
)
} else {
(content, false)
};
let mut result = serde_json::json!({
"content": content,
"size": metadata.len()
});
if was_truncated {
result["warning"] = Value::String(format!(
"File truncated ({} chars shown of {} total). Use ast_grep for structural search.",
LARGE_FILE_THRESHOLD,
metadata.len()
));
}
Ok(result)
}
}
pub struct FileWriteTool;
impl FileWriteTool {
pub fn new() -> Self {
FileWriteTool
}
}
impl Default for FileWriteTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for FileWriteTool {
fn name(&self) -> &str {
"file_write"
}
async fn execute(&self, args: Value) -> Result<Value, AgentError> {
let path: String = serde_json::from_value(args["path"].clone())
.map_err(|e| AgentError::ToolError(format!("Invalid path argument: {}", e)))?;
let content: String = serde_json::from_value(args["content"].clone())
.map_err(|e| AgentError::ToolError(format!("Invalid content argument: {}", e)))?;
let path_obj = Path::new(&path);
if let Some(parent) = path_obj.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| {
AgentError::IoError(format!("Failed to create directories: {}", e))
})?;
}
}
fs::write(&path, &content)
.map_err(|e| AgentError::IoError(format!("Failed to write file: {}", e)))?;
Ok(serde_json::json!({
"success": true,
"path": path,
"size": content.len()
}))
}
}
pub struct FileEditTool;
impl FileEditTool {
pub fn new() -> Self {
FileEditTool
}
}
impl Default for FileEditTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for FileEditTool {
fn name(&self) -> &str {
"file_edit"
}
async fn execute(&self, args: Value) -> Result<Value, AgentError> {
let path: String = serde_json::from_value(args["path"].clone())
.map_err(|e| AgentError::ToolError(format!("Invalid path argument: {}", e)))?;
let old_text: String = serde_json::from_value(args["old_text"].clone())
.map_err(|e| AgentError::ToolError(format!("Invalid old_text argument: {}", e)))?;
let new_text: String = serde_json::from_value(args["new_text"].clone())
.map_err(|e| AgentError::ToolError(format!("Invalid new_text argument: {}", e)))?;
let path_obj = Path::new(&path);
if !path_obj.exists() {
return Err(AgentError::ToolError(format!("File not found: {}", path)));
}
let current_content = fs::read_to_string(&path)
.map_err(|e| AgentError::IoError(format!("Failed to read file: {}", e)))?;
let metadata = fs::metadata(&path)
.map_err(|e| AgentError::IoError(format!("Failed to read file metadata: {}", e)))?;
if metadata.len() > MAX_FILE_SIZE {
return Err(AgentError::ToolError(format!(
"File too large: {} bytes (max: {} bytes)",
metadata.len(),
MAX_FILE_SIZE
)));
}
if current_content.contains('\0') {
return Err(AgentError::ToolError(format!(
"Binary file detected: {}",
path
)));
}
if !current_content.contains(&old_text) {
return Err(AgentError::ToolError(
"old_text not found in file".to_string(),
));
}
let new_content = current_content.replace(&old_text, &new_text);
fs::write(&path, new_content)
.map_err(|e| AgentError::IoError(format!("Failed to write file: {}", e)))?;
Ok(serde_json::json!({
"success": true,
"path": path,
"replacements": current_content.matches(&old_text).count()
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[tokio::test]
async fn test_file_read_tool_name() {
let tool = FileReadTool::new();
assert_eq!(tool.name(), "file_read");
}
#[tokio::test]
async fn test_file_read_tool_execute() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "Hello, World!").unwrap();
writeln!(temp_file, "This is a test.").unwrap();
let tool = FileReadTool::new();
let args = serde_json::json!({
"path": temp_file.path().to_str().unwrap()
});
let result = tool.execute(args).await.unwrap();
assert!(result["content"].is_string());
assert!(result["size"].is_u64());
assert!(result["content"]
.as_str()
.unwrap()
.contains("Hello, World!"));
}
#[tokio::test]
async fn test_file_read_tool_file_not_found() {
let tool = FileReadTool::new();
let args = serde_json::json!({
"path": "/nonexistent/file.txt"
});
let result = tool.execute(args).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("File not found"));
}
#[tokio::test]
async fn test_file_read_tool_invalid_path() {
let tool = FileReadTool::new();
let args = serde_json::json!({});
let result = tool.execute(args).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_file_write_tool_name() {
let tool = FileWriteTool::new();
assert_eq!(tool.name(), "file_write");
}
#[tokio::test]
async fn test_file_write_tool_execute() {
let temp_file = NamedTempFile::new().unwrap();
let tool = FileWriteTool::new();
let content = "Hello from test!";
let args = serde_json::json!({
"path": temp_file.path().to_str().unwrap(),
"content": content
});
let result = tool.execute(args).await.unwrap();
assert_eq!(result["success"], true);
assert_eq!(result["size"], content.len());
let written = fs::read_to_string(temp_file.path()).unwrap();
assert_eq!(written, content);
}
#[tokio::test]
async fn test_file_write_tool_create_dirs() {
let temp_dir = tempfile::tempdir().unwrap();
let nested_path = temp_dir.path().join("nested/dir/file.txt");
let tool = FileWriteTool::new();
let args = serde_json::json!({
"path": nested_path.to_str().unwrap(),
"content": "Test content"
});
let result = tool.execute(args).await.unwrap();
assert_eq!(result["success"], true);
assert!(nested_path.exists());
}
#[tokio::test]
async fn test_file_edit_tool_name() {
let tool = FileEditTool::new();
assert_eq!(tool.name(), "file_edit");
}
#[tokio::test]
async fn test_file_edit_tool_execute() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "Hello, World!").unwrap();
writeln!(temp_file, "Goodbye, World!").unwrap();
let tool = FileEditTool::new();
let args = serde_json::json!({
"path": temp_file.path().to_str().unwrap(),
"old_text": "Hello, World!",
"new_text": "Hello, Rust!"
});
let result = tool.execute(args).await.unwrap();
assert_eq!(result["success"], true);
assert_eq!(result["replacements"], 1);
let content = fs::read_to_string(temp_file.path()).unwrap();
assert!(content.contains("Hello, Rust!"));
assert!(!content.contains("Hello, World!"));
}
#[tokio::test]
async fn test_file_edit_tool_old_text_not_found() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "Hello, World!").unwrap();
let tool = FileEditTool::new();
let args = serde_json::json!({
"path": temp_file.path().to_str().unwrap(),
"old_text": "Nonexistent text",
"new_text": "Replacement"
});
let result = tool.execute(args).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("old_text not found"));
}
#[tokio::test]
async fn test_file_read_tool_binary_detection() {
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(b"Hello\x00World").unwrap();
let tool = FileReadTool::new();
let args = serde_json::json!({
"path": temp_file.path().to_str().unwrap()
});
let result = tool.execute(args).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Binary file"));
}
}