use super::resolve_read_path;
use crate::providers::ToolDefinition;
use anyhow::Result;
use koda_sandbox::fs::FileSystem;
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,
fs: &dyn FileSystem,
) -> Result<String> {
let raw_pattern = args["pattern"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'pattern' argument"))?;
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 escaped = regex::escape(raw_pattern);
let regex_pattern = if case_insensitive {
format!("(?i){escaped}")
} else {
escaped
};
let all_matches = fs
.grep(®ex_pattern, &search_root, None)
.await
.map_err(|e| anyhow::anyhow!("Grep error: {e}"))?;
if all_matches.is_empty() {
return Ok(format!("No matches found for '{raw_pattern}'"));
}
let capped = all_matches.len() > max_matches;
let shown = &all_matches[..all_matches.len().min(max_matches)];
let files_with_matches = {
let mut seen = std::collections::HashSet::new();
for m in shown {
seen.insert(&m.path);
}
seen.len()
};
let mut lines: Vec<String> = shown
.iter()
.map(|m| {
let rel = m.path.strip_prefix(project_root).unwrap_or(&m.path);
format!(
"{}:{}:{}",
rel.display(),
m.line,
truncate_line(&m.text, 200)
)
})
.collect();
if capped {
lines.push(format!(
"\n... [CAPPED at {max_matches} matches. \
Narrow your search pattern.]"
));
}
Ok(format!(
"{} matches ({} file{}):\n{}",
shown.len(),
files_with_matches,
if files_with_matches == 1 { "" } else { "s" },
lines.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 koda_sandbox::fs::LocalFileSystem;
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, &LocalFileSystem::new())
.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, &LocalFileSystem::new())
.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, &LocalFileSystem::new())
.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, &LocalFileSystem::new())
.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, &LocalFileSystem::new())
.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, &LocalFileSystem::new())
.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, &LocalFileSystem::new())
.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, &LocalFileSystem::new())
.await
.unwrap();
assert!(
result.contains("code.rs"),
"literal paren should be matched without regex error"
);
}
}