use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use serde::Deserialize;
use serde_json::Value;
use crate::tools::context::ToolContext;
use crate::tools::definition::{ToolDefinition, ToolParam};
use crate::tools::error::ToolError;
const DEFAULT_LIMIT: usize = 100;
const MAX_LIMIT: usize = 2000;
const SEARCH_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Deserialize)]
struct SearchArgs {
pattern: String,
include: Option<String>,
path: Option<String>,
#[serde(default = "default_limit")]
limit: usize,
}
fn default_limit() -> usize {
DEFAULT_LIMIT
}
#[must_use]
pub fn search_tool() -> ToolDefinition {
ToolDefinition {
name: "search",
description: "Search for files matching a regex pattern using ripgrep. \
Returns file paths containing matches. Requires ripgrep (rg) installed.",
params: vec![
ToolParam {
name: "pattern",
description: "Regex pattern to search for.",
param_type: "string",
items: None,
},
ToolParam {
name: "include",
description: "Glob pattern to filter files (e.g., '*.rs', '*.py').",
param_type: "string",
items: None,
},
ToolParam {
name: "path",
description: "Directory to search in (defaults to working directory).",
param_type: "string",
items: None,
},
ToolParam {
name: "limit",
description: "Maximum file paths to return (default: 100, max: 2000).",
param_type: "number",
items: None,
},
],
required: vec!["pattern"],
executor: Arc::new(execute_search),
}
}
fn execute_search(ctx: &ToolContext, args: Value) -> Result<String, ToolError> {
let search_args: SearchArgs =
serde_json::from_value(args).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
check_ripgrep_available()?;
let search_path = search_args.path.as_deref().unwrap_or(&ctx.working_dir);
let limit = search_args.limit.min(MAX_LIMIT);
let mut cmd = Command::new("rg");
cmd.arg("--files-with-matches")
.arg("--no-messages")
.arg("--color=never");
if let Some(ref glob) = search_args.include {
cmd.arg("--glob").arg(glob);
}
cmd.arg(&search_args.pattern).arg(search_path);
let output = execute_with_timeout(&mut cmd, Duration::from_secs(SEARCH_TIMEOUT_SECS))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let files: Vec<&str> = stdout.lines().take(limit).collect();
if files.is_empty() {
return Ok("No matches found.".to_string());
}
let mut result = format!("Found {} file(s) matching pattern:\n", files.len());
for file in &files {
result.push_str(file);
result.push('\n');
}
if stdout.lines().count() > limit {
result.push_str(&format!("\n(Results truncated to {} files)", limit));
}
Ok(result)
}
fn check_ripgrep_available() -> Result<(), ToolError> {
match Command::new("rg").arg("--version").output() {
Ok(output) if output.status.success() => Ok(()),
_ => Err(ToolError::MissingDependency(
"ripgrep (rg) is not installed. Install with: brew install ripgrep (macOS), \
apt install ripgrep (Ubuntu), or cargo install ripgrep"
.to_string(),
)),
}
}
fn execute_with_timeout(
cmd: &mut Command,
timeout: Duration,
) -> Result<std::process::Output, ToolError> {
use std::process::Stdio;
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| ToolError::Execution(format!("Failed to spawn rg: {e}")))?;
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
return Err(ToolError::Timeout(timeout.as_millis() as u64));
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => return Err(ToolError::Execution(format!("Wait failed: {e}"))),
}
}
child
.wait_with_output()
.map_err(|e| ToolError::Execution(format!("Failed to get output: {e}")))
}