use super::state::ToolState;
use crate::core::{Tool, ToolArgs, ToolError, ToolResult};
use anyhow::Result;
use regex::Regex;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use walkdir::WalkDir;
pub mod filtering;
pub use filtering::ConfigurableFilter;
const MAX_SEARCH_RESULTS: usize = 1000;
#[derive(Debug, Clone)]
pub struct SearchMatch {
pub file: PathBuf,
pub line_number: usize,
pub line_content: String,
pub match_start: usize,
pub match_end: usize,
}
pub struct FindFileTool {
name: String,
}
impl FindFileTool {
pub fn new() -> Self {
Self {
name: "find_file".to_string(),
}
}
fn glob_to_regex(pattern: &str) -> Result<Regex, ToolError> {
let mut regex_pattern = String::new();
let mut chars = pattern.chars().peekable();
regex_pattern.push('^');
while let Some(c) = chars.next() {
match c {
'*' => {
if chars.peek() == Some(&'*') {
chars.next(); if chars.peek() == Some(&'/') {
chars.next(); regex_pattern.push_str("(?:.*/)?");
} else {
regex_pattern.push_str(".*");
}
} else {
regex_pattern.push_str("[^/]*");
}
}
'?' => regex_pattern.push_str("[^/]"),
'[' => {
regex_pattern.push('[');
while let Some(c) = chars.next() {
if c == ']' {
regex_pattern.push(']');
break;
}
regex_pattern.push(c);
}
}
'.' | '^' | '$' | '(' | ')' | '{' | '}' | '|' | '+' | '\\' => {
regex_pattern.push('\\');
regex_pattern.push(c);
}
_ => regex_pattern.push(c),
}
}
regex_pattern.push('$');
Regex::new(®ex_pattern).map_err(ToolError::from)
}
}
impl Tool for FindFileTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Finds all files with the given name or pattern in dir. If dir is not provided, searches in the current directory"
}
fn signature(&self) -> &str {
"find_file <file_name> [<dir>]"
}
fn validate_args(&self, args: &ToolArgs) -> Result<(), ToolError> {
if args.is_empty() {
return Err(ToolError::InvalidArgs {
message: "Usage: find_file <file_name> [<dir>]".to_string(),
});
}
Ok(())
}
fn execute(&mut self, args: &ToolArgs, _state: &Arc<Mutex<ToolState>>) -> Result<ToolResult> {
let pattern = args.get_arg(0).unwrap();
let default_dir = "./".to_string();
let search_dir = args.get_arg(1).unwrap_or(&default_dir);
let search_path = Path::new(search_dir);
if !search_path.exists() {
return Ok(ToolResult::error(format!(
"Directory {} not found",
search_dir
)));
}
if !search_path.is_dir() {
return Ok(ToolResult::error(format!(
"{} is not a directory",
search_dir
)));
}
let regex = Self::glob_to_regex(pattern)?;
let mut matches = Vec::new();
let filter = ConfigurableFilter::new(None);
let walker = WalkDir::new(search_path)
.max_depth(100) .follow_links(false)
.into_iter()
.filter_entry(|e| {
let path = e.path();
if path == search_path {
return true;
}
if path.is_dir() {
filter.should_include_path(path)
} else {
true
}
});
for entry in walker {
match entry {
Ok(entry) => {
if entry.file_type().is_file() {
let file_name = entry.file_name().to_string_lossy();
let relative_path = entry
.path()
.strip_prefix(search_path)
.unwrap_or(entry.path());
let path_str = relative_path.to_string_lossy();
if regex.is_match(&file_name) || regex.is_match(&path_str) {
matches.push(entry.path().to_path_buf());
if matches.len() >= MAX_SEARCH_RESULTS {
break;
}
}
}
}
Err(_) => continue, }
}
if matches.is_empty() {
return Ok(ToolResult::success(format!(
"No matches found for \"{}\" in {}",
pattern,
search_path.display()
)));
}
matches.sort();
let mut result_text = format!(
"Found {} matches for \"{}\" in {}:\n",
matches.len(),
pattern,
search_path.display()
);
for path in &matches {
result_text.push_str(&format!("{}\n", path.display()));
}
Ok(ToolResult::success_with_data(
result_text.trim().to_string(),
serde_json::json!({
"pattern": pattern,
"search_dir": search_dir,
"matches": matches.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>(),
"count": matches.len()
}),
))
}
fn get_parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"file_name": {
"type": "string",
"description": "The name of the file or pattern to search for. Supports shell-style wildcards (e.g., *.py, **/*.rs)"
},
"dir": {
"type": "string",
"description": "The directory to search in (if not provided, searches in the current directory)",
"default": "./"
}
},
"required": ["file_name"]
})
}
}
pub struct SearchFileTool {
name: String,
}
impl SearchFileTool {
pub fn new() -> Self {
Self {
name: "search_file".to_string(),
}
}
fn search_in_file(
&self,
search_term: &str,
file_path: &Path,
) -> Result<Vec<SearchMatch>, ToolError> {
let content = fs::read_to_string(file_path).map_err(|_| ToolError::FileNotFound {
path: file_path.to_string_lossy().to_string(),
})?;
let mut matches = Vec::new();
let escaped = regex::escape(search_term);
let regex = Regex::new(&escaped).map_err(ToolError::from)?;
for (line_num, line) in content.lines().enumerate() {
for mat in regex.find_iter(line) {
matches.push(SearchMatch {
file: file_path.to_path_buf(),
line_number: line_num + 1,
line_content: line.to_string(),
match_start: mat.start(),
match_end: mat.end(),
});
if matches.len() >= MAX_SEARCH_RESULTS {
return Ok(matches);
}
}
}
Ok(matches)
}
}
impl Tool for SearchFileTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Searches for search_term in file. If file is not provided, searches in the current open file"
}
fn signature(&self) -> &str {
"search_file <search_term> [<file>]"
}
fn validate_args(&self, args: &ToolArgs) -> Result<(), ToolError> {
if args.is_empty() {
return Err(ToolError::InvalidArgs {
message: "Usage: search_file <search_term> [<file>]".to_string(),
});
}
Ok(())
}
fn execute(&mut self, args: &ToolArgs, state: &Arc<Mutex<ToolState>>) -> Result<ToolResult> {
let search_term = args.get_arg(0).unwrap();
let target_file = if let Some(file_arg) = args.get_arg(1) {
PathBuf::from(file_arg)
} else {
let state_guard = state
.lock()
.map_err(|e| anyhow::anyhow!("Failed to lock state: {}", e))?;
state_guard
.current_file
.clone()
.ok_or_else(|| anyhow::anyhow!("No file specified and no file is currently open"))?
};
if !target_file.exists() {
return Ok(ToolResult::error(format!(
"File not found: {}",
target_file.display()
)));
}
let matches = self.search_in_file(search_term, &target_file)?;
if matches.is_empty() {
return Ok(ToolResult::success(format!(
"No matches found for \"{}\" in {}",
search_term,
target_file.display()
)));
}
let mut result_text = format!(
"Found {} matches for \"{}\" in {}:\n\n",
matches.len(),
search_term,
target_file.display()
);
for (i, m) in matches.iter().enumerate() {
result_text.push_str(&format!(
"{}. Line {}: {}\n",
i + 1,
m.line_number,
m.line_content.trim()
));
let indent = format!("{}. Line {}: ", i + 1, m.line_number);
let spaces = " ".repeat(indent.len() + m.match_start);
let highlight = "^".repeat(m.match_end - m.match_start);
result_text.push_str(&format!("{}{}\n\n", spaces, highlight));
}
Ok(ToolResult::success_with_data(
result_text.trim().to_string(),
serde_json::json!({
"search_term": search_term,
"file": target_file.to_string_lossy(),
"matches": matches.iter().map(|m| {
serde_json::json!({
"line_number": m.line_number,
"line_content": m.line_content,
"match_start": m.match_start,
"match_end": m.match_end
})
}).collect::<Vec<_>>(),
"count": matches.len()
}),
))
}
fn get_parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"search_term": {
"type": "string",
"description": "The term to search for (supports regex patterns)"
},
"file": {
"type": "string",
"description": "The file to search in (if not provided, searches in the current open file)"
}
},
"required": ["search_term"]
})
}
}
pub struct SearchDirTool {
name: String,
}
impl SearchDirTool {
pub fn new() -> Self {
Self {
name: "search_dir".to_string(),
}
}
fn search_in_directory(
&self,
search_term: &str,
dir_path: &Path,
) -> Result<HashMap<PathBuf, Vec<SearchMatch>>, ToolError> {
let escaped = regex::escape(search_term);
let regex = Regex::new(&escaped).map_err(ToolError::from)?;
let mut all_matches = HashMap::new();
let mut total_matches = 0;
let filter = ConfigurableFilter::new(None);
let walker = WalkDir::new(dir_path)
.max_depth(100)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
let path = e.path();
if path == dir_path {
return true;
}
if path.is_dir() {
filter.should_include_path(path)
} else {
true
}
});
for entry in walker {
if total_matches >= MAX_SEARCH_RESULTS {
break;
}
match entry {
Ok(entry) => {
if entry.file_type().is_file() {
let path = entry.path();
if !filter.should_include_path(path) {
continue;
}
if let Ok(content) = fs::read_to_string(path) {
let mut file_matches = Vec::new();
for (line_num, line) in content.lines().enumerate() {
for mat in regex.find_iter(line) {
file_matches.push(SearchMatch {
file: path.to_path_buf(),
line_number: line_num + 1,
line_content: line.to_string(),
match_start: mat.start(),
match_end: mat.end(),
});
total_matches += 1;
if total_matches >= MAX_SEARCH_RESULTS {
break;
}
}
if total_matches >= MAX_SEARCH_RESULTS {
break;
}
}
if !file_matches.is_empty() {
all_matches.insert(path.to_path_buf(), file_matches);
}
}
}
}
Err(_) => continue, }
}
Ok(all_matches)
}
}
impl Tool for SearchDirTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Searches for search_term in all files in dir. If dir is not provided, searches in the current directory"
}
fn signature(&self) -> &str {
"search_dir <search_term> [<dir>]"
}
fn validate_args(&self, args: &ToolArgs) -> Result<(), ToolError> {
if args.is_empty() {
return Err(ToolError::InvalidArgs {
message: "Usage: search_dir <search_term> [<dir>]".to_string(),
});
}
Ok(())
}
fn execute(&mut self, args: &ToolArgs, _state: &Arc<Mutex<ToolState>>) -> Result<ToolResult> {
let search_term = args.get_arg(0).unwrap();
let default_dir = "./".to_string();
let search_dir = args.get_arg(1).unwrap_or(&default_dir);
let search_path = Path::new(search_dir);
if !search_path.exists() {
return Ok(ToolResult::error(format!(
"Directory {} not found",
search_dir
)));
}
if !search_path.is_dir() {
return Ok(ToolResult::error(format!(
"{} is not a directory",
search_dir
)));
}
let matches = self.search_in_directory(search_term, search_path)?;
if matches.is_empty() {
return Ok(ToolResult::success(format!(
"No matches found for \"{}\" in {}",
search_term,
search_path.display()
)));
}
let total_matches: usize = matches.values().map(|v| v.len()).sum();
let mut result_text = format!(
"Found {} matches for \"{}\" in {} across {} files:\n\n",
total_matches,
search_term,
search_path.display(),
matches.len()
);
let mut file_matches: Vec<_> = matches.iter().collect();
file_matches.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
for (file_path, file_matches) in file_matches.iter().take(20) {
result_text.push_str(&format!(
"📁 {} ({} matches):\n",
file_path.display(),
file_matches.len()
));
for (i, m) in file_matches.iter().take(5).enumerate() {
result_text.push_str(&format!(
" {}. Line {}: {}\n",
i + 1,
m.line_number,
m.line_content.trim()
));
}
if file_matches.len() > 5 {
result_text.push_str(&format!(
" ... and {} more matches\n",
file_matches.len() - 5
));
}
result_text.push('\n');
}
if matches.len() > 20 {
result_text.push_str(&format!(
"... and {} more files with matches\n",
matches.len() - 20
));
}
let mut files_data = Vec::new();
for (file_path, file_matches) in matches.iter() {
files_data.push(serde_json::json!({
"file": file_path.to_string_lossy(),
"match_count": file_matches.len(),
"matches": file_matches.iter().take(10).map(|m| {
serde_json::json!({
"line_number": m.line_number,
"line_content": m.line_content,
"match_start": m.match_start,
"match_end": m.match_end
})
}).collect::<Vec<_>>()
}));
}
Ok(ToolResult::success_with_data(
result_text.trim().to_string(),
serde_json::json!({
"search_term": search_term,
"search_dir": search_dir,
"total_matches": total_matches,
"file_count": matches.len(),
"files": files_data
}),
))
}
fn get_parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"search_term": {
"type": "string",
"description": "The term to search for (supports regex patterns)"
},
"dir": {
"type": "string",
"description": "The directory to search in (if not provided, searches in the current directory)",
"default": "./"
}
},
"required": ["search_term"]
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
let file_path = dir.path().join(name);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut file = fs::File::create(&file_path).unwrap();
write!(file, "{}", content).unwrap();
file_path
}
#[test]
fn test_find_file_tool() {
let temp_dir = TempDir::new().unwrap();
create_test_file(&temp_dir, "test.txt", "content");
create_test_file(&temp_dir, "test.rs", "rust content");
create_test_file(&temp_dir, "subdir/test.py", "python content");
let mut tool = FindFileTool::new();
let state = Arc::new(Mutex::new(ToolState::new()));
let args = ToolArgs::from_args(&["*.txt", temp_dir.path().to_str().unwrap()]);
let result = tool.execute(&args, &state).unwrap();
assert!(result.success);
assert!(result.message.contains("test.txt"));
let args = ToolArgs::from_args(&["*.py", temp_dir.path().to_str().unwrap()]);
let result = tool.execute(&args, &state).unwrap();
assert!(result.success);
assert!(result.message.contains("test.py"));
}
#[test]
fn test_search_file_tool() {
let temp_dir = TempDir::new().unwrap();
let content = "line 1\ntest content here\nline 3 with test\nfinal line";
let file_path = create_test_file(&temp_dir, "search_test.txt", content);
let mut tool = SearchFileTool::new();
let state = Arc::new(Mutex::new(ToolState::new()));
let args = ToolArgs::from_args(&["test", file_path.to_str().unwrap()]);
let result = tool.execute(&args, &state).unwrap();
assert!(result.success);
assert!(result.message.contains("2 matches"));
assert!(result.message.contains("Line 2"));
assert!(result.message.contains("Line 3"));
}
#[test]
fn test_search_dir_tool() {
let temp_dir = TempDir::new().unwrap();
create_test_file(&temp_dir, "file1.txt", "this has foo in it");
create_test_file(&temp_dir, "file2.rs", "no matches here");
create_test_file(&temp_dir, "subdir/file3.py", "foo is here too");
let mut tool = SearchDirTool::new();
let state = Arc::new(Mutex::new(ToolState::new()));
let args = ToolArgs::from_args(&["foo", temp_dir.path().to_str().unwrap()]);
let result = tool.execute(&args, &state).unwrap();
assert!(result.success);
assert!(result.message.contains("2 matches"));
assert!(result.message.contains("2 files"));
}
#[test]
fn test_glob_to_regex() {
let regex = FindFileTool::glob_to_regex("*.rs").unwrap();
assert!(regex.is_match("test.rs"));
assert!(!regex.is_match("test.txt"));
let regex = FindFileTool::glob_to_regex("**/*.py").unwrap();
assert!(regex.is_match("subdir/test.py"));
assert!(regex.is_match("test.py"));
let regex = FindFileTool::glob_to_regex("test?.txt").unwrap();
assert!(regex.is_match("test1.txt"));
assert!(regex.is_match("testa.txt"));
assert!(!regex.is_match("test12.txt"));
}
#[test]
fn test_find_file_respects_exclude_dirs() {
let temp_dir = TempDir::new().unwrap();
create_test_file(&temp_dir, "node_modules/hidden.txt", "secret");
create_test_file(&temp_dir, "visible.txt", "visible content");
let mut tool = FindFileTool::new();
let state = Arc::new(Mutex::new(ToolState::new()));
let args = ToolArgs::from_args(&["*.txt", temp_dir.path().to_str().unwrap()]);
let result = tool.execute(&args, &state).unwrap();
assert!(result.success);
assert!(result.message.contains("visible.txt"));
assert!(!result.message.contains("node_modules/hidden.txt"));
}
#[test]
fn test_search_dir_skips_excluded_extensions() {
let temp_dir = TempDir::new().unwrap();
create_test_file(&temp_dir, "binary.exe", "secret_term");
create_test_file(&temp_dir, "file.txt", "secret_term");
let mut tool = SearchDirTool::new();
let state = Arc::new(Mutex::new(ToolState::new()));
let args = ToolArgs::from_args(&["secret_term", temp_dir.path().to_str().unwrap()]);
let result = tool.execute(&args, &state).unwrap();
assert!(result.success);
assert!(result.message.contains("file.txt"));
assert!(!result.message.contains("binary.exe"));
}
}