#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use globset::Glob;
use ignore::WalkBuilder;
use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
use regex::RegexBuilder;
use serde_json::{json, Value};
use crate::tools::ls::resolve_in_cwd;
use crate::tools::ToolCtx;
const MAX_OUTPUT_BYTES: usize = 30 * 1024;
const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
pub struct GrepTool {
ctx: Arc<ToolCtx>,
}
impl GrepTool {
pub fn new(ctx: Arc<ToolCtx>) -> Self {
Self { ctx }
}
}
impl Tool for GrepTool {
fn def(&self) -> ToolDef {
ToolDef {
name: "grep".to_string(),
description: "Search file contents with a regex. Respects .gitignore, skips binary and oversized files. Returns `path:line:text` matches.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Regular expression to search for." },
"path": { "type": "string", "description": "Root directory (absolute or cwd-relative). Defaults to cwd." },
"glob": { "type": "string", "description": "Optional filename glob filter, e.g. `*.rs`." },
"case_insensitive": { "type": "boolean", "description": "Case-insensitive match. Default false." }
},
"required": ["pattern"]
}),
}
}
fn call(
&self,
args: Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
let ctx = Arc::clone(&self.ctx);
Box::pin(async move {
let pattern = match args.get("pattern").and_then(|v| v.as_str()) {
Some(p) => p.to_string(),
None => return ToolResult::error("missing 'pattern' argument"),
};
let case_insensitive = args
.get("case_insensitive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let re = match RegexBuilder::new(&pattern)
.case_insensitive(case_insensitive)
.build()
{
Ok(r) => r,
Err(e) => return ToolResult::error(format!("invalid regex `{pattern}`: {e}")),
};
let rel = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let root = resolve_in_cwd(&ctx.cwd, rel);
if !root.starts_with(&ctx.cwd) {
return ToolResult::error(format!(
"path {} is outside the working directory",
root.display()
));
}
let glob = match args.get("glob").and_then(|v| v.as_str()) {
Some(g) => match Glob::new(g) {
Ok(g) => Some(g.compile_matcher()),
Err(e) => return ToolResult::error(format!("invalid glob `{g}`: {e}")),
},
None => None,
};
let cwd = ctx.cwd.clone();
let search = tokio::task::spawn_blocking(move || {
let mut out = String::new();
let mut truncated = false;
'walk: for entry in WalkBuilder::new(&root).build().flatten() {
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
if let Some(m) = &glob {
if !m.is_match(path.file_name().unwrap_or_default()) {
continue;
}
}
let meta = match std::fs::metadata(path) {
Ok(m) => m,
Err(_) => continue,
};
if meta.len() > MAX_FILE_BYTES {
continue;
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(_) => continue,
};
let text = match String::from_utf8(bytes) {
Ok(t) => t,
Err(_) => continue, };
let rel = path.strip_prefix(&cwd).unwrap_or(path);
for (lineno, line) in text.lines().enumerate() {
if re.is_match(line) {
let entry = format!("{}:{}:{}\n", rel.display(), lineno + 1, line);
if out.len() + entry.len() > MAX_OUTPUT_BYTES {
truncated = true;
break 'walk;
}
out.push_str(&entry);
}
}
}
if truncated {
out.push_str("... (output truncated at 30 KB)\n");
}
out
})
.await;
match search {
Ok(out) if out.is_empty() => ToolResult::text("(no matches)"),
Ok(out) => ToolResult::text(out),
Err(e) => ToolResult::error(format!("grep failed: {e}")),
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::NoOpPermissionGate;
use tempfile::tempdir;
use tokio::sync::mpsc;
fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
let (tx, _rx) = mpsc::channel(8);
Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
}
#[tokio::test]
async fn finds_matching_lines() {
let dir = tempdir().expect("tempdir");
tokio::fs::write(dir.path().join("a.txt"), "alpha\nbeta\ngamma\n")
.await
.unwrap();
let tool = GrepTool::new(test_ctx(dir.path()));
let result = tool
.call(json!({ "pattern": "be.a" }), &ToolContext::default())
.await;
let text = result.as_text().unwrap_or_default();
assert!(text.contains("a.txt:2:beta"), "got: {text}");
assert!(!text.contains("alpha"));
}
#[tokio::test]
async fn no_matches_reports_cleanly() {
let dir = tempdir().expect("tempdir");
tokio::fs::write(dir.path().join("a.txt"), "alpha\n")
.await
.unwrap();
let tool = GrepTool::new(test_ctx(dir.path()));
let result = tool
.call(json!({ "pattern": "zzz" }), &ToolContext::default())
.await;
assert_eq!(result.as_text().unwrap_or_default(), "(no matches)");
}
#[tokio::test]
async fn case_insensitive_flag_works() {
let dir = tempdir().expect("tempdir");
tokio::fs::write(dir.path().join("a.txt"), "Hello\n")
.await
.unwrap();
let tool = GrepTool::new(test_ctx(dir.path()));
let result = tool
.call(
json!({ "pattern": "hello", "case_insensitive": true }),
&ToolContext::default(),
)
.await;
assert!(result.as_text().unwrap_or_default().contains("Hello"));
}
#[tokio::test]
async fn glob_filter_restricts_files() {
let dir = tempdir().expect("tempdir");
tokio::fs::write(dir.path().join("a.rs"), "match\n")
.await
.unwrap();
tokio::fs::write(dir.path().join("b.txt"), "match\n")
.await
.unwrap();
let tool = GrepTool::new(test_ctx(dir.path()));
let result = tool
.call(
json!({ "pattern": "match", "glob": "*.rs" }),
&ToolContext::default(),
)
.await;
let text = result.as_text().unwrap_or_default();
assert!(text.contains("a.rs"));
assert!(!text.contains("b.txt"));
}
#[tokio::test]
async fn skips_binary_files() {
let dir = tempdir().expect("tempdir");
tokio::fs::write(dir.path().join("bin"), [0xff_u8, 0x00, 0xfe])
.await
.unwrap();
tokio::fs::write(dir.path().join("txt.txt"), "needle\n")
.await
.unwrap();
let tool = GrepTool::new(test_ctx(dir.path()));
let result = tool
.call(json!({ "pattern": "needle" }), &ToolContext::default())
.await;
assert!(result.as_text().unwrap_or_default().contains("txt.txt"));
}
#[tokio::test]
async fn invalid_regex_errors() {
let dir = tempdir().expect("tempdir");
let tool = GrepTool::new(test_ctx(dir.path()));
let result = tool
.call(json!({ "pattern": "(" }), &ToolContext::default())
.await;
assert!(result.is_error);
}
}