//! Text search tools: `grep` (line matching) and `glob` (filename pattern matching). use std::fs; use serde_json::{json, Value}; use anyhow::{Result, anyhow}; use ignore::Walk; use globset::{GlobBuilder, GlobSetBuilder}; use super::Tool; use super::ToolCtx; use super::resolve_path; /// Tool that recursively searches text files under a directory for a literal substring. 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"] }) } /// Recursively walk the resolved directory and collect matching lines. /// /// Flow: extract `pattern` + `path` → `resolve_path` (workspace-scoped) → bail if /// missing/not a dir → `ignore::Walk` the tree → for each file, `read_to_string` /// and substring-match each line → emit `::` rows. /// /// Why: `ignore::Walk` respects `.gitignore` and skips heavy dirs (e.g. `.git/`) /// which is what the agent expects when running in real repos. /// /// Return: "no matches found" if empty, else a header + `path:line:text` rows. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let pattern = args.get("pattern") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: pattern"))? .to_string(); let rel = args.get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))? .to_string(); 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)) } } /// Tool that lists files under a directory matching a glob pattern. 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"] }) } /// Walk the resolved directory and collect entries matching the glob pattern. /// /// Flow: extract pattern + path → `resolve_path` → build a `GlobSet` from the /// joined absolute pattern → `ignore::Walk` the tree → keep entries that /// match → sort → join with newlines, appending `/` for directories. /// /// Why: joining the workspace-relative pattern onto the resolved root lets users /// supply familiar glob shapes (`**/*.rs`) while the sandbox still controls the /// boundary. /// /// Return: sorted newline-joined matches; "no files match" sentinel if empty. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let pat_str = args.get("pattern") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: pattern"))? .to_string(); let rel = args.get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: path"))? .to_string(); 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!("invalid glob pattern '{pat_str}': {e}"))?); let glob_set = builder.build() .map_err(|e| anyhow!("failed to build glob set: {e}"))?; 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")) } }