use super::{default_walker, Tool, ToolContext, ToolError, MAX_TOOL_OUTPUT_BYTES};
use async_trait::async_trait;
use globset::{Glob, GlobSet, GlobSetBuilder};
use grep_regex::RegexMatcherBuilder;
use grep_searcher::{SearcherBuilder, Sink, SinkMatch};
use serde::Deserialize;
const DEFAULT_MAX_MATCHES: usize = 100;
const HARD_MAX_MATCHES: usize = 1000;
#[derive(Debug, Deserialize)]
struct GrepInput {
pattern: String,
#[serde(default)]
path: Option<String>,
#[serde(default)]
glob: Option<String>,
#[serde(default)]
case_insensitive: Option<bool>,
#[serde(default)]
max_matches: Option<usize>,
}
pub(crate) struct GrepTool;
#[async_trait]
impl Tool for GrepTool {
fn name(&self) -> &'static str {
"grep"
}
fn description(&self) -> &'static str {
"Search file contents in the workspace using a regex pattern. Returns matching \
lines as path:line:content. Respects .gitignore. Use this to locate references, \
find symbols, or verify wording before quoting."
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Regex pattern (Rust-style)." },
"path": { "type": "string", "description": "Optional sub-path to scope the search." },
"glob": { "type": "string", "description": "Optional file-name glob filter, e.g. '*.md'." },
"case_insensitive": { "type": "boolean" },
"max_matches": { "type": "integer", "minimum": 1, "maximum": 1000 }
},
"required": ["pattern"],
"additionalProperties": false
})
}
async fn run(&self, ctx: &ToolContext, input: serde_json::Value) -> Result<String, ToolError> {
let args: GrepInput =
serde_json::from_value(input).map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
if args.pattern.is_empty() {
return Err(ToolError::InvalidArgument("empty pattern".into()));
}
let max_matches = args
.max_matches
.unwrap_or(DEFAULT_MAX_MATCHES)
.clamp(1, HARD_MAX_MATCHES);
let case_insensitive = args.case_insensitive.unwrap_or(false);
let start = match args.path.as_deref() {
Some(p) if !p.is_empty() => ctx.resolve(p)?,
_ => ctx.workspace_root.clone(),
};
let matcher = RegexMatcherBuilder::new()
.case_insensitive(case_insensitive)
.build(&args.pattern)
.map_err(|e| ToolError::InvalidArgument(format!("invalid regex: {e}")))?;
let glob_set: Option<GlobSet> = match args.glob.as_deref() {
Some(g) if !g.is_empty() => {
let glob = Glob::new(g)
.map_err(|e| ToolError::InvalidArgument(format!("invalid glob '{g}': {e}")))?;
let set = GlobSetBuilder::new()
.add(glob)
.build()
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
Some(set)
}
_ => None,
};
let walker = default_walker(&start).build();
let mut searcher = SearcherBuilder::new()
.line_number(true)
.multi_line(false)
.build();
let mut hits: Vec<String> = Vec::new();
let mut byte_budget_used: usize = 0;
let mut budget_full = false;
'walk: for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let is_file = entry.file_type().map(|t| t.is_file()).unwrap_or(false);
if !is_file {
continue;
}
let path = entry.path();
let rel = match path.strip_prefix(&ctx.workspace_root) {
Ok(r) => r.to_path_buf(),
Err(_) => continue,
};
if let Some(set) = &glob_set {
if !set.is_match(&rel) {
continue;
}
}
let rel_str = rel_to_forward(&rel);
let remaining = max_matches.saturating_sub(hits.len());
if remaining == 0 {
break;
}
let mut sink = CollectingSink {
rel: rel_str,
hits: &mut hits,
remaining,
bytes_used: &mut byte_budget_used,
bytes_budget: MAX_TOOL_OUTPUT_BYTES,
budget_full: &mut budget_full,
};
let _ = searcher.search_path(&matcher, path, &mut sink);
if hits.len() >= max_matches || budget_full {
break 'walk;
}
}
if hits.is_empty() {
return Ok("no matches".to_string());
}
let mut out = format!(
"Found {} match(es) for pattern \"{}\"\n",
hits.len(),
args.pattern
);
for line in &hits {
if out.len() + line.len() + 1 > MAX_TOOL_OUTPUT_BYTES {
out.push_str("\n... (truncated)");
return Ok(out);
}
out.push_str(line);
out.push('\n');
}
if budget_full {
if out.ends_with('\n') {
out.pop();
}
out.push_str("\n... (truncated)");
}
Ok(out)
}
}
use super::path_to_forward_slash as rel_to_forward;
struct CollectingSink<'a> {
rel: String,
hits: &'a mut Vec<String>,
remaining: usize,
bytes_used: &'a mut usize,
bytes_budget: usize,
budget_full: &'a mut bool,
}
impl<'a> Sink for CollectingSink<'a> {
type Error = std::io::Error;
fn matched(
&mut self,
_searcher: &grep_searcher::Searcher,
mat: &SinkMatch<'_>,
) -> Result<bool, std::io::Error> {
if self.remaining == 0 {
return Ok(false);
}
let line_no = mat.line_number().unwrap_or(0);
let raw = mat.bytes();
let text = trim_trailing_newline(raw);
let line_text = String::from_utf8_lossy(text);
let line = format!("{}:{}:{}", self.rel, line_no, line_text);
let cost = line.len() + 1; if *self.bytes_used + cost > self.bytes_budget {
*self.budget_full = true;
return Ok(false);
}
*self.bytes_used += cost;
self.hits.push(line);
self.remaining = self.remaining.saturating_sub(1);
Ok(self.remaining > 0)
}
}
fn trim_trailing_newline(b: &[u8]) -> &[u8] {
let mut end = b.len();
if end > 0 && b[end - 1] == b'\n' {
end -= 1;
if end > 0 && b[end - 1] == b'\r' {
end -= 1;
}
}
&b[..end]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::tools::ToolContext;
use std::fs;
use tempfile::TempDir;
fn ctx_for(td: &TempDir) -> ToolContext {
ToolContext::new(td.path()).unwrap()
}
#[tokio::test]
async fn finds_matches_with_line_numbers() {
let td = TempDir::new().unwrap();
fs::write(
td.path().join("a.md"),
"alpha line\nTODO fix me\nbeta line\nTODO again\n",
)
.unwrap();
let tool = GrepTool;
let out = tool
.run(&ctx_for(&td), serde_json::json!({ "pattern": "TODO" }))
.await
.unwrap();
assert!(out.starts_with("Found 2 match(es)"), "got: {out}");
assert!(out.contains("a.md:2:TODO fix me"), "got: {out}");
assert!(out.contains("a.md:4:TODO again"), "got: {out}");
}
#[tokio::test]
async fn glob_filters_files() {
let td = TempDir::new().unwrap();
fs::write(td.path().join("notes.md"), "hello world\n").unwrap();
fs::write(td.path().join("code.rs"), "// hello world\n").unwrap();
let tool = GrepTool;
let out = tool
.run(
&ctx_for(&td),
serde_json::json!({ "pattern": "hello", "glob": "*.md" }),
)
.await
.unwrap();
assert!(out.contains("notes.md:1:hello world"), "got: {out}");
assert!(
!out.contains("code.rs"),
"rs file should be filtered: {out}"
);
}
#[tokio::test]
async fn case_insensitive_search() {
let td = TempDir::new().unwrap();
fs::write(td.path().join("a.md"), "Hello\nhello\nHELLO\n").unwrap();
let tool = GrepTool;
let out = tool
.run(
&ctx_for(&td),
serde_json::json!({ "pattern": "hello", "case_insensitive": true }),
)
.await
.unwrap();
assert!(out.contains("a.md:1:Hello"), "got: {out}");
assert!(out.contains("a.md:2:hello"), "got: {out}");
assert!(out.contains("a.md:3:HELLO"), "got: {out}");
}
#[tokio::test]
async fn no_matches_returns_sentinel() {
let td = TempDir::new().unwrap();
fs::write(td.path().join("a.md"), "nothing here\n").unwrap();
let tool = GrepTool;
let out = tool
.run(&ctx_for(&td), serde_json::json!({ "pattern": "absent" }))
.await
.unwrap();
assert_eq!(out, "no matches");
}
}