2026-07-12 11:28:39 +07:00
|
|
|
//! Text search tools: `grep` (line matching) and `glob` (filename pattern matching).
|
2026-07-19 17:05:27 +07:00
|
|
|
//!
|
|
|
|
|
//! Both tools use `ignore::Walk` under the hood, which respects `.gitignore` and skips
|
|
|
|
|
//! heavy directories (`.git/`, `node_modules/`, etc.), matching what agents expect
|
|
|
|
|
//! when searching real codebases.
|
2026-07-16 07:42:03 +07:00
|
|
|
use super::resolve_path;
|
2026-07-11 13:16:10 +07:00
|
|
|
use super::Tool;
|
|
|
|
|
use super::ToolCtx;
|
2026-07-16 07:42:03 +07:00
|
|
|
use anyhow::{anyhow, Result};
|
|
|
|
|
use globset::{GlobBuilder, GlobSetBuilder};
|
|
|
|
|
use ignore::Walk;
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use std::fs;
|
2026-07-19 17:05:27 +07:00
|
|
|
use tracing;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Tool that recursively searches text files under a directory for a literal substring.
|
2026-07-11 13:16:10 +07:00
|
|
|
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"]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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 `<rel_path>:<line_no>:<text>` 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.
|
2026-07-11 13:16:10 +07:00
|
|
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
2026-07-17 06:44:31 +07:00
|
|
|
let pattern = crate::tool::arg_str(args, "pattern")?;
|
|
|
|
|
let rel = crate::tool::arg_str(args, "path")?;
|
2026-07-11 13:16:10 +07:00
|
|
|
let path = resolve_path(&ctx.workspaces, &rel)?;
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::debug!(pattern = %pattern, path = %rel, "Grep::run invoked");
|
2026-07-11 13:16:10 +07:00
|
|
|
if !path.exists() {
|
2026-07-13 08:12:02 +07:00
|
|
|
anyhow::bail!("path '{rel}' does not exist");
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
if !path.is_dir() {
|
2026-07-13 08:12:02 +07:00
|
|
|
anyhow::bail!("path '{rel}' is not a directory");
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
2026-07-19 17:05:27 +07:00
|
|
|
let mut results: Vec<(String, usize, String)> = Vec::new(); // (relative_path, line_no, text)
|
2026-07-11 13:16:10 +07:00
|
|
|
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) {
|
2026-07-16 07:42:03 +07:00
|
|
|
let rel_path = file_path
|
|
|
|
|
.strip_prefix(&path)
|
2026-07-11 13:16:10 +07:00
|
|
|
.unwrap_or(file_path)
|
|
|
|
|
.display()
|
|
|
|
|
.to_string();
|
|
|
|
|
results.push((rel_path, i + 1, line.to_string()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if results.is_empty() {
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::debug!(pattern = %pattern, "Grep: no matches found");
|
2026-07-13 08:12:02 +07:00
|
|
|
return Ok(format!("no matches found for '{pattern}' in {rel}"));
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
2026-07-16 07:42:03 +07:00
|
|
|
let output = results
|
|
|
|
|
.iter()
|
2026-07-13 08:12:02 +07:00
|
|
|
.map(|(f, line, text)| format!("{f}:{line}:{text}"))
|
2026-07-11 13:16:10 +07:00
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n");
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::debug!(count = results.len(), "Grep: matches found");
|
2026-07-11 13:16:10 +07:00
|
|
|
Ok(format!("found {} matches:\n{}", results.len(), output))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Tool that lists files under a directory matching a glob pattern.
|
2026-07-11 13:16:10 +07:00
|
|
|
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"]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 13:16:10 +07:00
|
|
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
2026-07-17 06:44:31 +07:00
|
|
|
let pat_str = crate::tool::arg_str(args, "pattern")?;
|
|
|
|
|
let rel = crate::tool::arg_str(args, "path")?;
|
2026-07-11 13:16:10 +07:00
|
|
|
let root = resolve_path(&ctx.workspaces, &rel)?;
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::debug!(pattern = %pat_str, root = %rel, "Glob::run invoked");
|
2026-07-11 13:16:10 +07:00
|
|
|
if !root.exists() || !root.is_dir() {
|
2026-07-13 08:12:02 +07:00
|
|
|
anyhow::bail!("path '{rel}' is not a valid directory");
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
let mut builder = GlobSetBuilder::new();
|
|
|
|
|
let full_pattern = root.join(&pat_str).display().to_string();
|
2026-07-16 07:42:03 +07:00
|
|
|
builder.add(
|
|
|
|
|
GlobBuilder::new(&full_pattern)
|
|
|
|
|
.build()
|
|
|
|
|
.map_err(|e| anyhow!("invalid glob pattern '{pat_str}': {e}"))?,
|
|
|
|
|
);
|
|
|
|
|
let glob_set = builder
|
|
|
|
|
.build()
|
2026-07-13 08:12:02 +07:00
|
|
|
.map_err(|e| anyhow!("failed to build glob set: {e}"))?;
|
2026-07-11 13:16:10 +07:00
|
|
|
let mut matches: Vec<String> = Vec::new();
|
|
|
|
|
for entry in Walk::new(&root).flatten() {
|
|
|
|
|
let p = entry.path();
|
|
|
|
|
if glob_set.is_match(p) {
|
2026-07-16 07:42:03 +07:00
|
|
|
let rel_path = p.strip_prefix(&root).unwrap_or(p).display().to_string();
|
2026-07-11 13:16:10 +07:00
|
|
|
matches.push(format!("{}{}", rel_path, if p.is_dir() { "/" } else { "" }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
matches.sort();
|
|
|
|
|
if matches.is_empty() {
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::debug!(pattern = %pat_str, "Glob: no files match");
|
2026-07-13 08:12:02 +07:00
|
|
|
return Ok(format!("no files match '{pat_str}' in {rel}"));
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
2026-07-19 17:05:27 +07:00
|
|
|
tracing::debug!(count = matches.len(), "Glob: files matched");
|
2026-07-11 13:16:10 +07:00
|
|
|
Ok(matches.join("\n"))
|
|
|
|
|
}
|
|
|
|
|
}
|