//! Text search tools: Grep (line matching) and Glob (filename pattern matching). 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; 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"] }) } 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() { anyhow::bail!("path '{rel}' does not exist"); } if !path.is_dir() { anyhow::bail!("path '{rel}' is not a directory"); } 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() { if line.contains(&pattern) { 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() { return Ok(format!("no matches found for '{pattern}' in {rel}")); } let output = results .iter() .map(|(f, line, text)| format!("{f}:{line}:{text}")) .collect::>() .join("\n"); Ok(format!("found {} matches:\n{}", results.len(), output)) } } 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"] }) } 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() { anyhow::bail!("path '{rel}' is not a valid directory"); } 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() { return Ok(format!("no files match '{pat_str}' in {rel}")); } Ok(matches.join("\n")) } }