use crate::types::*;
use async_trait::async_trait;
use std::time::Duration;
use tokio::process::Command;
pub struct SearchTool {
pub root: Option<String>,
pub max_results: usize,
pub timeout: Duration,
}
impl Default for SearchTool {
fn default() -> Self {
Self {
root: None,
max_results: 50,
timeout: Duration::from_secs(30),
}
}
}
impl SearchTool {
pub fn new() -> Self {
Self::default()
}
pub fn with_root(mut self, root: impl Into<String>) -> Self {
self.root = Some(root.into());
self
}
}
#[async_trait]
impl AgentTool for SearchTool {
fn name(&self) -> &str {
"search"
}
fn label(&self) -> &str {
"Search Files"
}
fn description(&self) -> &str {
"Search for a pattern across files using grep. Returns matching lines with file paths and line numbers. Supports regex patterns."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Search pattern (regex supported)"
},
"path": {
"type": "string",
"description": "Directory or file to search in (optional, defaults to working directory)"
},
"include": {
"type": "string",
"description": "File glob pattern to include, e.g. '*.rs' (optional)"
},
"case_sensitive": {
"type": "boolean",
"description": "Case sensitive search (default: false)"
}
},
"required": ["pattern"]
})
}
async fn execute(
&self,
params: serde_json::Value,
ctx: ToolContext,
) -> Result<ToolResult, ToolError> {
let cancel = ctx.cancel;
let pattern = params["pattern"]
.as_str()
.ok_or_else(|| ToolError::InvalidArgs("missing 'pattern' parameter".into()))?;
let search_path = params["path"]
.as_str()
.map(|s| s.to_string())
.or_else(|| self.root.clone())
.unwrap_or_else(|| ".".into());
let include = params["include"].as_str();
let case_sensitive = params["case_sensitive"].as_bool().unwrap_or(false);
if cancel.is_cancelled() {
return Err(ToolError::Cancelled);
}
let (cmd_name, args) = if which_exists("rg") {
build_rg_args(
pattern,
&search_path,
include,
case_sensitive,
self.max_results,
)
} else {
build_grep_args(
pattern,
&search_path,
include,
case_sensitive,
self.max_results,
)
};
let mut cmd = Command::new(&cmd_name);
cmd.args(&args);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let timeout = self.timeout;
let result = tokio::select! {
_ = cancel.cancelled() => {
return Err(ToolError::Cancelled);
}
_ = tokio::time::sleep(timeout) => {
return Err(ToolError::Failed("Search timed out".into()));
}
result = cmd.output() => {
result.map_err(|e| ToolError::Failed(format!("Search failed: {}", e)))?
}
};
let stdout = String::from_utf8_lossy(&result.stdout).to_string();
let stderr = String::from_utf8_lossy(&result.stderr).to_string();
if result.status.code() == Some(2)
|| (!stderr.is_empty() && result.status.code() != Some(1))
{
return Err(ToolError::Failed(format!("Search error: {}", stderr)));
}
if stdout.trim().is_empty() {
return Ok(ToolResult {
content: vec![Content::Text {
text: format!("No matches found for '{}'", pattern),
}],
details: serde_json::json!({ "matches": 0 }),
child_loop_id: None,
});
}
let match_count = stdout.lines().count();
let text = if match_count >= self.max_results {
format!(
"{}\n... (showing first {} matches)",
stdout.trim(),
self.max_results
)
} else {
format!("{}\n({} matches)", stdout.trim(), match_count)
};
Ok(ToolResult {
content: vec![Content::Text { text }],
details: serde_json::json!({ "matches": match_count }),
child_loop_id: None,
})
}
}
fn which_exists(name: &str) -> bool {
std::process::Command::new("which")
.arg(name)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn build_rg_args(
pattern: &str, path: &str, include: Option<&str>, case_sensitive: bool, max_results: usize, ) -> (String, Vec<String>) {
let mut args = vec![
"--line-number".into(),
"--no-heading".into(),
format!("--max-count={}", max_results),
];
if !case_sensitive {
args.push("--ignore-case".into());
}
if let Some(glob) = include {
args.push(format!("--glob={}", glob));
}
args.push(pattern.into());
args.push(path.into());
("rg".into(), args)
}
fn build_grep_args(
pattern: &str, path: &str, include: Option<&str>, case_sensitive: bool, max_results: usize, ) -> (String, Vec<String>) {
let mut args = vec!["-r".into(), "-n".into(), format!("-m{}", max_results)];
if !case_sensitive {
args.push("-i".into());
}
if let Some(glob) = include {
args.push(format!("--include={}", glob));
}
args.push(pattern.into());
args.push(path.into());
("grep".into(), args)
}