//! Text search tools: Grep (line matching) and Glob (filename pattern matching). //! //! `Grep` searches file contents recursively with regex or literal fallback. //! `Glob` lists files matching a given glob pattern under a directory. use crate::tools::{resolve_path, Tool, ToolCtx}; use anyhow::Result; use globset::{GlobBuilder, GlobSetBuilder}; use ignore::Walk; use serde_json::{json, Value}; use std::fs; use tracing::{debug, info, instrument, warn}; /// Search for a regex (or literal) pattern in file contents under a directory. /// /// Flow: resolve path → walk files → regex-match each line → collect results. /// Falls back to substring search when the pattern is not a valid regex. pub struct Grep; impl Tool for Grep { fn name(&self) -> &'static str { "grep" } fn description(&self) -> &'static str { "Search for a pattern in files using recursive text search" } fn parameters(&self) -> Value { json!({ "type": "object", "properties": { "pattern": { "type": "string", "description": "Text pattern to search for" }, "path": { "type": "string", "description": "Path to search in (relative to workspace root)" } }, "required": ["pattern", "path"] }) } #[instrument(skip(self, ctx, args))] fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let pattern = crate::tools::arg_str(args, "pattern")?; let rel = crate::tools::arg_str(args, "path")?; let path = resolve_path(&ctx.workspaces, &rel)?; if !path.exists() { warn!(rel = %rel, "grep path does not exist"); anyhow::bail!("path '{rel}' does not exist"); } if !path.is_dir() { warn!(rel = %rel, "grep path is not a directory"); anyhow::bail!("path '{rel}' is not a directory"); } info!(pattern = %pattern, root = %rel, "grep search starting"); let mut results: Vec<(String, usize, String)> = Vec::new(); for entry in Walk::new(&path).flatten() { let file_path = entry.path(); if !file_path.is_file() { continue; } if let Ok(content) = fs::read_to_string(file_path) { for (i, line) in content.lines().enumerate() { let is_match = if let Ok(re) = regex::Regex::new(&pattern) { re.is_match(line) } else { // Fall back to literal substring search when the // pattern is not a valid regex. line.contains(&pattern) }; if is_match { let rel_path = file_path .strip_prefix(&path) .unwrap_or(file_path) .display() .to_string(); results.push((rel_path, i + 1, line.to_string())); } } } } if results.is_empty() { debug!(pattern = %pattern, "grep found no matches"); return Ok(format!("no matches found for '{pattern}' in {rel}")); } info!(match_count = results.len(), "grep search completed"); let output = results .iter() .map(|(f, line, text)| format!("{f}:{line}:{text}")) .collect::>() .join("\n"); Ok(format!("found {} matches:\n{}", results.len(), output)) } } /// List files matching a glob pattern under a directory root. /// /// Flow: resolve root → build glob set from pattern → walk files → filter by /// glob set → sort results. pub struct Glob; impl Tool for Glob { fn name(&self) -> &'static str { "glob" } fn description(&self) -> &'static str { "List files matching a glob pattern" } fn parameters(&self) -> Value { json!({ "type": "object", "properties": { "pattern": { "type": "string", "description": "Glob pattern to match files (e.g. '**/*.rs')" }, "path": { "type": "string", "description": "Root path to search from (relative to workspace root)" } }, "required": ["pattern", "path"] }) } #[instrument(skip(self, ctx, args))] fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let pat_str = crate::tools::arg_str(args, "pattern")?; let rel = crate::tools::arg_str(args, "path")?; let root = resolve_path(&ctx.workspaces, &rel)?; if !root.exists() || !root.is_dir() { warn!(rel = %rel, "glob root is not a valid directory"); anyhow::bail!("path '{rel}' is not a valid directory"); } info!(pattern = %pat_str, root = %rel, "glob search starting"); let mut builder = GlobSetBuilder::new(); let full_pattern = root.join(&pat_str).display().to_string(); builder.add( GlobBuilder::new(&full_pattern) .build() .map_err(|e| anyhow::anyhow!("invalid glob pattern '{pat_str}': {e}"))?, ); let glob_set = builder.build()?; let mut matches: Vec = Vec::new(); for entry in Walk::new(&root).flatten() { let p = entry.path(); if glob_set.is_match(p) { let rel_path = p.strip_prefix(&root).unwrap_or(p).display().to_string(); matches.push(format!("{}{}", rel_path, if p.is_dir() { "/" } else { "" })); } } matches.sort(); if matches.is_empty() { debug!(pattern = %pat_str, "glob found no matches"); return Ok(format!("no files match '{pat_str}' in {rel}")); } info!(match_count = matches.len(), "glob search completed"); Ok(matches.join("\n")) } }