// tools/grep.rs — Search file contents // // Prefers ripgrep (rg) for speed, falls back to grep -r if rg // isn't installed. Both produce compatible output. use anyhow::{Context, Result}; use serde_json::json; use std::process::Command; use crate::types::ToolDef; pub fn definition() -> ToolDef { ToolDef::new( "grep", "Search for a pattern in files. Returns matching file paths by default, \ or matching lines with context.", json!({ "type": "object", "properties": { "pattern": { "type": "string", "description": "Regex pattern to search for" }, "path": { "type": "string", "description": "Directory or file to search in (default: current directory)" }, "glob": { "type": "string", "description": "Glob pattern to filter files (e.g. '*.rs', '*.py')" }, "show_content": { "type": "boolean", "description": "Show matching lines instead of just file paths" }, "context_lines": { "type": "integer", "description": "Number of context lines around matches (requires show_content)" } }, "required": ["pattern"] }), ) } /// Check if ripgrep is available (cached after first check). fn has_rg() -> bool { use std::sync::OnceLock; static HAS_RG: OnceLock = OnceLock::new(); *HAS_RG.get_or_init(|| Command::new("rg").arg("--version").output().is_ok()) } pub fn grep(args: &serde_json::Value) -> Result { let pattern = args["pattern"].as_str().context("pattern is required")?; let path = args["path"].as_str().unwrap_or("."); let file_glob = args["glob"].as_str(); let show_content = args["show_content"].as_bool().unwrap_or(false); let context = args["context_lines"].as_u64(); let output = if has_rg() { run_rg(pattern, path, file_glob, show_content, context)? } else { run_grep(pattern, path, file_glob, show_content, context)? }; if output.is_empty() { return Ok("No matches found.".to_string()); } let mut result = output; const MAX_OUTPUT: usize = 30000; if result.len() > MAX_OUTPUT { result.truncate(MAX_OUTPUT); result.push_str("\n... (output truncated)"); } Ok(result) } fn run_rg( pattern: &str, path: &str, file_glob: Option<&str>, show_content: bool, context: Option, ) -> Result { let mut cmd = Command::new("rg"); if show_content { cmd.arg("-n"); if let Some(c) = context { cmd.arg("-C").arg(c.to_string()); } } else { cmd.arg("--files-with-matches"); } if let Some(g) = file_glob { cmd.arg("--glob").arg(g); } cmd.arg(pattern).arg(path); let output = cmd.output().context("Failed to run rg")?; Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn run_grep( pattern: &str, path: &str, file_glob: Option<&str>, show_content: bool, context: Option, ) -> Result { let mut cmd = Command::new("grep"); cmd.arg("-r"); // recursive if show_content { cmd.arg("-n"); // line numbers if let Some(c) = context { cmd.arg("-C").arg(c.to_string()); } } else { cmd.arg("-l"); // files-with-matches } if let Some(g) = file_glob { cmd.arg("--include").arg(g); } cmd.arg("-E"); // extended regex cmd.arg(pattern).arg(path); let output = cmd.output().context("Failed to run grep")?; Ok(String::from_utf8_lossy(&output.stdout).to_string()) }