use super::file::{
PATH_ALIASES, SEARCH_CONTENT_ALIASES, SEARCH_CONTENT_PARAMS, apply_param_aliases,
};
use super::spec::{
ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_bool, optional_str,
optional_u64, required_str,
};
use async_trait::async_trait;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{HashSet, VecDeque};
use std::fs;
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio_util::sync::CancellationToken;
const MAX_RESULTS: usize = 100;
const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
const GREP_FILES_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GrepMatch {
pub file: String,
pub line_number: usize,
pub line: String,
pub context_before: Vec<String>,
pub context_after: Vec<String>,
}
pub struct GrepFilesTool;
#[async_trait]
impl ToolSpec for GrepFilesTool {
fn name(&self) -> &'static str {
"grep_files"
}
fn model_visible(&self) -> bool {
true
}
fn description(&self) -> &'static str {
"Search for a regex pattern in workspace files. The pure-Rust search skips common non-code directories by default and returns matching lines with context."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regular expression pattern to search for"
},
"path": {
"type": "string",
"description": "Directory or file to search (relative to workspace, default: .)"
},
"include": {
"type": "array",
"items": {"type": "string"},
"description": "Glob patterns for files to include (e.g., ['*.rs', '*.ts'])"
},
"exclude": {
"type": "array",
"items": {"type": "string"},
"description": "Glob patterns for files to exclude (e.g., ['*.min.js', 'node_modules/*'])"
},
"context_lines": {
"type": "integer",
"description": "Number of context lines before and after each match (default: 2)"
},
"case_insensitive": {
"type": "boolean",
"description": "Whether to perform case-insensitive matching (default: false)"
},
"max_results": {
"type": "integer",
"description": "Maximum number of results to return (default: 100)"
}
},
"required": ["pattern"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
}
fn supports_parallel(&self) -> bool {
true
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let mut input = input;
apply_param_aliases(&mut input, PATH_ALIASES, "File search_content")?;
apply_param_aliases(&mut input, SEARCH_CONTENT_ALIASES, "File search_content")?;
SEARCH_CONTENT_PARAMS.reject_unknown(&input)?;
let pattern_str = required_str(&input, "pattern")?;
let path_str = optional_str(&input, "path")?.unwrap_or(".");
let context_lines = usize::try_from(optional_u64(&input, "context_lines", 2)?)
.unwrap_or(usize::MAX)
.min(1000);
let case_insensitive = optional_bool(&input, "case_insensitive", false)?;
let max_results = usize::try_from(optional_u64(&input, "max_results", MAX_RESULTS as u64)?)
.unwrap_or(MAX_RESULTS);
let include_patterns: Vec<String> = input
.get("include")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let exclude_patterns: Vec<String> =
input.get("exclude").and_then(|v| v.as_array()).map_or_else(
|| {
vec![
"node_modules".to_string(),
"node_modules/*".to_string(),
".git".to_string(),
".git/*".to_string(),
"target".to_string(),
"target/*".to_string(),
"*.min.js".to_string(),
"*.min.css".to_string(),
"dist".to_string(),
"dist/*".to_string(),
"build".to_string(),
"build/*".to_string(),
"__pycache__".to_string(),
"__pycache__/*".to_string(),
".venv".to_string(),
".venv/*".to_string(),
"venv".to_string(),
"venv/*".to_string(),
]
},
|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
},
);
let regex_pattern = if case_insensitive {
format!("(?i){pattern_str}")
} else {
pattern_str.to_string()
};
let regex = Regex::new(®ex_pattern)
.map_err(|e| ToolError::invalid_input(format!("Invalid regex pattern: {e}")))?;
let search_path = context.resolve_path(path_str)?;
let workspace = context.workspace.clone();
let cancel_token = context.cancel_token.clone();
let follow_symlinks = context.follow_symlinks;
let result = run_blocking_grep(GREP_FILES_TIMEOUT, cancel_token.clone(), move || {
let cancel_token = cancel_token.as_ref();
let mut results: Vec<GrepMatch> = Vec::new();
let mut files_searched = 0;
let mut total_matches = 0;
visit_files(
&search_path,
&include_patterns,
&exclude_patterns,
cancel_token,
follow_symlinks,
&mut |file_path| {
if results.len() >= max_results {
return Ok(WalkControl::Stop);
}
check_cancelled(cancel_token)?;
if let Ok(metadata) = fs::metadata(file_path)
&& metadata.len() > MAX_FILE_SIZE
{
return Ok(WalkControl::Continue);
}
let relative_path = file_path
.strip_prefix(&workspace)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
let budget = max_results - results.len();
let Some(file_matches) = search_file_streaming(
file_path,
&relative_path,
®ex,
context_lines,
budget,
cancel_token,
)?
else {
return Ok(WalkControl::Continue); };
files_searched += 1;
total_matches += file_matches.len();
results.extend(file_matches);
Ok(WalkControl::Continue)
},
)?;
let matches_json: Vec<Value> = results
.iter()
.map(|item| grep_match_to_json(item, context_lines))
.collect();
Ok(json!({
"matches": matches_json,
"total_matches": total_matches,
"files_searched": files_searched,
"truncated": total_matches > max_results,
}))
})
.await?;
ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
async fn run_blocking_grep<F>(
timeout: Duration,
cancel_token: Option<CancellationToken>,
search: F,
) -> Result<Value, ToolError>
where
F: FnOnce() -> Result<Value, ToolError> + Send + 'static,
{
if cancel_token
.as_ref()
.is_some_and(CancellationToken::is_cancelled)
{
return Err(grep_cancelled());
}
let task = tokio::task::spawn_blocking(search);
let result = match cancel_token {
Some(token) => {
tokio::select! {
biased;
() = token.cancelled() => return Err(grep_cancelled()),
result = tokio::time::timeout(timeout, task) => result,
}
}
None => tokio::time::timeout(timeout, task).await,
};
let joined = result.map_err(|_| grep_timeout(timeout))?;
joined.map_err(|err| {
ToolError::execution_failed(format!("grep_files worker failed before completion: {err}"))
})?
}
fn grep_cancelled() -> ToolError {
ToolError::cancelled("grep_files cancelled before completion")
}
fn grep_timeout(timeout: Duration) -> ToolError {
ToolError::Timeout {
seconds: timeout.as_secs().max(1),
}
}
fn grep_match_to_json(item: &GrepMatch, context_lines: usize) -> Value {
if context_lines == 1 {
json!({
"file": item.file,
"line_number": item.line_number,
"line": item.line,
"context_before": item.context_before.first().cloned().unwrap_or_default(),
"context_after": item.context_after.first().cloned().unwrap_or_default(),
})
} else {
json!(item)
}
}
fn search_file_streaming(
path: &Path,
relative_path: &str,
regex: &Regex,
context_lines: usize,
budget: usize,
cancel_token: Option<&CancellationToken>,
) -> Result<Option<Vec<GrepMatch>>, ToolError> {
let Ok(file) = fs::File::open(path) else {
return Ok(None);
};
let mut reader = std::io::BufReader::new(file);
let mut raw: Vec<u8> = Vec::new();
let mut before: VecDeque<String> = VecDeque::new();
let mut matches: Vec<GrepMatch> = Vec::new();
let mut pending: VecDeque<(usize, usize)> = VecDeque::new();
let mut line_idx = 0usize;
loop {
raw.clear();
let n = match reader.read_until(b'\n', &mut raw) {
Ok(n) => n,
Err(_) => return Ok(None),
};
if n == 0 {
break;
}
check_cancelled(cancel_token)?;
let mut end = raw.len();
if raw[..end].ends_with(b"\n") {
end -= 1;
if raw[..end].ends_with(b"\r") {
end -= 1;
}
}
let Ok(line) = std::str::from_utf8(&raw[..end]) else {
return Ok(None);
};
for (idx, remaining) in &mut pending {
matches[*idx].context_after.push(line.to_string());
*remaining -= 1;
}
while pending
.front()
.is_some_and(|(_, remaining)| *remaining == 0)
{
pending.pop_front();
}
if matches.len() < budget && regex.is_match(line) {
matches.push(GrepMatch {
file: relative_path.to_string(),
line_number: line_idx + 1,
line: line.to_string(),
context_before: before.iter().cloned().collect(),
context_after: Vec::new(),
});
if context_lines > 0 {
pending.push_back((matches.len() - 1, context_lines));
}
}
if context_lines > 0 {
if before.len() == context_lines {
before.pop_front();
}
before.push_back(line.to_string());
}
line_idx += 1;
}
Ok(Some(matches))
}
enum WalkControl {
Continue,
Stop,
}
fn visit_files(
root: &Path,
include_patterns: &[String],
exclude_patterns: &[String],
cancel_token: Option<&CancellationToken>,
follow_symlinks: bool,
visit: &mut dyn FnMut(&Path) -> Result<WalkControl, ToolError>,
) -> Result<(), ToolError> {
let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
check_cancelled(cancel_token)?;
if root.is_file() {
visit(root)?;
return Ok(());
}
if follow_symlinks && let Ok(canonical_root) = root.canonicalize() {
visited_dirs.insert(canonical_root);
}
visit_files_recursive(
root,
root,
include_patterns,
exclude_patterns,
cancel_token,
&mut visited_dirs,
follow_symlinks,
visit,
)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn visit_files_recursive(
root: &Path,
current: &Path,
include_patterns: &[String],
exclude_patterns: &[String],
cancel_token: Option<&CancellationToken>,
visited_dirs: &mut HashSet<PathBuf>,
follow_symlinks: bool,
visit: &mut dyn FnMut(&Path) -> Result<WalkControl, ToolError>,
) -> Result<WalkControl, ToolError> {
check_cancelled(cancel_token)?;
let entries = fs::read_dir(current).map_err(|e| {
ToolError::execution_failed(format!(
"Failed to read directory {}: {}",
current.display(),
e
))
})?;
for entry in entries {
check_cancelled(cancel_token)?;
let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
let path = entry.path();
let file_type = entry.file_type().map_err(|e| {
ToolError::execution_failed(format!(
"Failed to inspect file type for {}: {}",
path.display(),
e
))
})?;
if file_type.is_symlink() && !follow_symlinks {
continue;
}
let relative = path.strip_prefix(root).unwrap_or(&path);
let relative_str = relative.to_string_lossy();
if should_exclude(&relative_str, exclude_patterns) {
continue;
}
let effective_type = if file_type.is_symlink() && follow_symlinks {
match fs::metadata(&path) {
Ok(meta) => meta.file_type(),
Err(_) => continue,
}
} else {
file_type
};
if effective_type.is_dir() {
if follow_symlinks {
let canonical_dir = match path.canonicalize() {
Ok(canonical) => canonical,
Err(_) => continue,
};
if !visited_dirs.insert(canonical_dir) {
continue;
}
}
if let WalkControl::Stop = visit_files_recursive(
root,
&path,
include_patterns,
exclude_patterns,
cancel_token,
visited_dirs,
follow_symlinks,
visit,
)? {
return Ok(WalkControl::Stop);
}
} else if effective_type.is_file() {
if (include_patterns.is_empty() || should_include(&relative_str, include_patterns))
&& let WalkControl::Stop = visit(&path)?
{
return Ok(WalkControl::Stop);
}
}
}
Ok(WalkControl::Continue)
}
fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
if cancel_token.is_some_and(CancellationToken::is_cancelled) {
return Err(ToolError::cancelled("search cancelled before completion"));
}
Ok(())
}
fn should_exclude(path: &str, patterns: &[String]) -> bool {
for pattern in patterns {
if matches_glob(path, pattern) {
return true;
}
}
false
}
fn should_include(path: &str, patterns: &[String]) -> bool {
for pattern in patterns {
if matches_glob(path, pattern) {
return true;
}
}
false
}
pub(crate) fn matches_glob(path: &str, pattern: &str) -> bool {
if pattern.contains("**") {
let parts: Vec<&str> = pattern.split("**").collect();
if parts.len() == 2 {
let prefix = parts[0].trim_end_matches('/');
let suffix = parts[1].trim_start_matches('/');
if !prefix.is_empty() && !path.starts_with(prefix) {
return false;
}
if !suffix.is_empty() {
return path.ends_with(suffix)
|| path
.split('/')
.any(|part| matches_simple_glob(part, suffix));
}
return path.starts_with(prefix) || prefix.is_empty();
}
}
if pattern.starts_with('*') && !pattern.contains('/') {
let filename = path.rsplit('/').next().unwrap_or(path);
return matches_simple_glob(filename, pattern);
}
if pattern.contains('/') {
return matches_simple_glob(path, pattern);
}
let filename = path.rsplit('/').next().unwrap_or(path);
matches_simple_glob(filename, pattern)
}
fn matches_simple_glob(text: &str, pattern: &str) -> bool {
let mut text_chars = text.chars().peekable();
let mut pattern_chars = pattern.chars().peekable();
while let Some(p) = pattern_chars.next() {
match p {
'*' => {
let next_pattern: String = pattern_chars.collect();
if next_pattern.is_empty() {
return true;
}
let remaining: String = text_chars.collect();
for (i, _) in remaining.char_indices() {
if matches_simple_glob(&remaining[i..], &next_pattern) {
return true;
}
}
if matches_simple_glob("", &next_pattern) {
return true;
}
return false;
}
'?' => {
if text_chars.next().is_none() {
return false;
}
}
c => {
if text_chars.next() != Some(c) {
return false;
}
}
}
}
text_chars.next().is_none()
}
#[cfg(test)]
mod tests;