Files
zesdex/apps/infrastructure/src/tools/search.rs
T
asepharyana 7b0b53671f style: format seluruh workspace dengan cargo fmt
Menyeragamkan format kode sesuai rustfmt (126 file). Sebelumnya
lefthook pre-commit 'cargo fmt --check' akan gagal pada commit apa pun.
2026-08-27 22:10:28 +07:00

173 lines
6.1 KiB
Rust

//! 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<String> {
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::<Vec<_>>()
.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<String> {
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<String> = 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"))
}
}