use super::resolve_read_path;
use crate::providers::ToolDefinition;
use anyhow::Result;
use serde_json::{Value, json};
use std::path::Path;
pub fn definitions() -> Vec<ToolDefinition> {
vec![ToolDefinition {
name: "Grep".to_string(),
description: "Recursively search for text patterns across files (respects .gitignore). \
Returns matching file paths with line numbers and content. \
Use plain text for exact matches or regex for complex patterns. \
Set case_insensitive=true for case-agnostic search. \
Prefer this over Bash + rg/grep. Results are capped at 100 matches."
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "The text pattern to search for (plain text or regex)"
},
"file_path": {
"type": "string",
"description": "Directory to search in (default: project root)"
},
"case_insensitive": {
"type": "boolean",
"description": "Whether to ignore case (default: false)"
}
},
"required": ["pattern"]
}),
}]
}
pub async fn grep(project_root: &Path, args: &Value, max_matches: usize) -> Result<String> {
let pattern = args["pattern"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'pattern' argument"))?
.to_string();
let path_str = args["file_path"]
.as_str()
.or_else(|| args["path"].as_str())
.unwrap_or(".");
let case_insensitive = args["case_insensitive"].as_bool().unwrap_or(false);
let search_root = resolve_read_path(project_root, path_str)?;
let project_root = project_root.to_path_buf();
tokio::task::spawn_blocking(move || {
grep_blocking(
&project_root,
&search_root,
&pattern,
case_insensitive,
max_matches,
)
})
.await?
}
fn grep_blocking(
project_root: &Path,
search_root: &Path,
pattern: &str,
case_insensitive: bool,
max_matches: usize,
) -> Result<String> {
let regex = if case_insensitive {
regex::RegexBuilder::new(®ex::escape(pattern))
.case_insensitive(true)
.build()?
} else {
regex::Regex::new(®ex::escape(pattern))?
};
let walker = ignore::WalkBuilder::new(search_root)
.hidden(true)
.git_ignore(true)
.build();
let mut matches = Vec::new();
let mut files_searched = 0u64;
for entry in walker.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let content = match std::fs::read(path) {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(s) => s,
Err(_) => continue,
},
Err(_) => continue,
};
files_searched += 1;
let relative = path.strip_prefix(project_root).unwrap_or(path);
for (line_num, line) in content.lines().enumerate() {
if regex.is_match(line) {
matches.push(format!(
"{}:{}:{}",
relative.display(),
line_num + 1,
truncate_line(line, 200)
));
if matches.len() >= max_matches {
matches.push(format!(
"\n... [CAPPED at {max_matches} matches. \
Narrow your search pattern.]"
));
return Ok(format_output(&matches, files_searched));
}
}
}
}
if matches.is_empty() {
Ok(format!(
"No matches found for \'{pattern}\' (searched {files_searched} files)"
))
} else {
Ok(format_output(&matches, files_searched))
}
}
fn format_output(matches: &[String], files_searched: u64) -> String {
format!(
"{} matches (searched {} files):\n{}",
matches.len(),
files_searched,
matches.join("\n")
)
}
fn truncate_line(line: &str, max_bytes: usize) -> &str {
if line.len() <= max_bytes {
line
} else if max_bytes == 0 {
""
} else {
let mut end = max_bytes;
while end > 0 && !line.is_char_boundary(end) {
end -= 1;
}
&line[..end]
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_test_dir() -> TempDir {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("hello.rs"),
"fn main() {\n println!(\"hello\");\n}\n",
)
.unwrap();
std::fs::write(
tmp.path().join("lib.rs"),
"pub fn greet() {\n println!(\"hello world\");\n}\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("nested")).unwrap();
std::fs::write(
tmp.path().join("nested/deep.rs"),
"// no match here\nfn nope() {}\n",
)
.unwrap();
tmp
}
#[tokio::test]
async fn test_grep_finds_matches() {
let tmp = setup_test_dir();
let args = json!({ "pattern": "hello" });
let result = grep(tmp.path(), &args, 100).await.unwrap();
assert!(result.contains("hello.rs"));
assert!(result.contains("lib.rs"));
}
#[tokio::test]
async fn test_grep_no_matches() {
let tmp = setup_test_dir();
let args = json!({ "pattern": "zzzznotfound" });
let result = grep(tmp.path(), &args, 100).await.unwrap();
assert!(result.contains("No matches"));
}
#[tokio::test]
async fn test_grep_case_insensitive() {
let tmp = setup_test_dir();
let args = json!({ "pattern": "HELLO", "case_insensitive": true });
let result = grep(tmp.path(), &args, 100).await.unwrap();
assert!(result.contains("hello.rs"));
}
#[test]
fn test_truncate_ascii() {
assert_eq!(truncate_line("hello world", 5), "hello");
assert_eq!(truncate_line("hi", 10), "hi");
assert_eq!(truncate_line("", 5), "");
}
#[test]
fn test_truncate_multibyte_boundary() {
let line = "aää🦀b"; assert_eq!(truncate_line(line, 10), line); assert_eq!(truncate_line(line, 9), "aää🦀"); assert_eq!(truncate_line(line, 8), "aää"); assert_eq!(truncate_line(line, 6), "aää"); assert_eq!(truncate_line(line, 5), "aää"); assert_eq!(truncate_line(line, 4), "aä"); assert_eq!(truncate_line(line, 3), "aä"); assert_eq!(truncate_line(line, 2), "a"); assert_eq!(truncate_line(line, 1), "a");
assert_eq!(truncate_line(line, 0), "");
}
#[test]
fn test_truncate_never_overshoots() {
let line = "hello 🌍 world"; let truncated = truncate_line(line, 7);
assert!(truncated.len() <= 7, "got {} bytes", truncated.len());
assert_eq!(truncated, "hello "); }
#[tokio::test]
async fn test_grep_scoped_to_subdirectory() {
let tmp = setup_test_dir();
let args = json!({ "pattern": "nope", "path": "nested" });
let result = grep(tmp.path(), &args, 100).await.unwrap();
assert!(result.contains("deep.rs"));
}
#[tokio::test]
async fn test_grep_skips_binary_files() {
let tmp = TempDir::new().unwrap();
let binary: Vec<u8> = vec![0xFF, 0xFE, b'h', b'e', b'l', b'l', b'o', 0x00];
std::fs::write(tmp.path().join("data.bin"), &binary).unwrap();
std::fs::write(tmp.path().join("text.rs"), "hello world").unwrap();
let args = json!({ "pattern": "hello" });
let result = grep(tmp.path(), &args, 100).await.unwrap();
assert!(result.contains("text.rs"));
assert!(!result.contains("data.bin"));
}
#[tokio::test]
async fn test_grep_match_count_capped() {
let tmp = TempDir::new().unwrap();
for i in 0..20 {
std::fs::write(
tmp.path().join(format!("file{i}.rs")),
"needle haystack needle",
)
.unwrap();
}
let args = json!({ "pattern": "needle" });
let result = grep(tmp.path(), &args, 5).await.unwrap();
assert!(
result.contains("CAPPED"),
"expected CAPPED hint in output when cap is exceeded: {result}"
);
}
#[tokio::test]
async fn test_grep_file_path_param_alias() {
let tmp = setup_test_dir();
let args = json!({ "pattern": "nope", "file_path": "nested" });
let result = grep(tmp.path(), &args, 100).await.unwrap();
assert!(result.contains("deep.rs"));
}
#[tokio::test]
async fn test_grep_regex_special_chars_treated_literally() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("code.rs"), "fn (invalid syntax").unwrap();
let args = json!({ "pattern": "fn (" });
let result = grep(tmp.path(), &args, 100).await.unwrap();
assert!(
result.contains("code.rs"),
"literal paren should be matched without regex error"
);
}
}