use async_trait::async_trait;
use ignore::WalkBuilder;
use regex::RegexBuilder;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use super::paths::resolve_in_workspace;
use super::Tool;
const MAX_RESULTS: usize = 200;
const MAX_MATCH_LINE: usize = 240;
fn walk(root: &Path) -> ignore::Walk {
WalkBuilder::new(root)
.git_ignore(true)
.hidden(true)
.build()
}
fn relative(path: &Path, root: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
}
fn glob_to_regex(pattern: &str) -> String {
let mut out = String::from("^");
let bytes: Vec<char> = pattern.chars().collect();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
'*' => {
if bytes.get(i + 1) == Some(&'*') {
if bytes.get(i + 2) == Some(&'/') {
out.push_str("(?:.*/)?");
i += 3;
continue;
}
out.push_str(".*");
i += 2;
continue;
}
out.push_str("[^/]*");
i += 1;
}
'?' => {
out.push_str("[^/]");
i += 1;
}
c => {
out.push_str(®ex::escape(&c.to_string()));
i += 1;
}
}
}
out.push('$');
out
}
pub struct ListDirTool;
#[async_trait]
impl Tool for ListDirTool {
fn name(&self) -> &str {
"list_dir"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"List the entries of a directory in the workspace (one level, not recursive)"
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory to list, relative to the workspace (default: .)"
}
},
"required": []
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let requested = input.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let dir = resolve_in_workspace(requested)?;
let mut entries = tokio::fs::read_dir(&dir)
.await
.map_err(|e| format!("Failed to read {}: {}", requested, e))?;
let mut dirs = Vec::new();
let mut files = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| format!("Failed to read {}: {}", requested, e))?
{
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
dirs.push(format!("{}/", name));
} else {
files.push(name);
}
}
dirs.sort();
files.sort();
if dirs.is_empty() && files.is_empty() {
return Ok(format!("{} is empty", requested));
}
let mut out = format!("{}:\n", requested);
for entry in dirs.into_iter().chain(files) {
out.push_str(&format!(" {}\n", entry));
}
Ok(out)
}
}
pub struct GlobTool;
#[async_trait]
impl Tool for GlobTool {
fn name(&self) -> &str {
"glob"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"Find files in the workspace by glob pattern, e.g. 'contracts/**/*.rs' or '**/Cargo.toml'"
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern matched against workspace-relative paths. Supports * and **"
},
"path": {
"type": "string",
"description": "Directory to search under, relative to the workspace (default: .)"
}
},
"required": ["pattern"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let pattern = input
.get("pattern")
.and_then(|v| v.as_str())
.ok_or("Missing 'pattern' parameter")?;
let requested = input.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let root = resolve_in_workspace(requested)?;
let regex = RegexBuilder::new(&glob_to_regex(pattern))
.build()
.map_err(|e| format!("Invalid pattern '{}': {}", pattern, e))?;
let (matches, truncated) = tokio::task::spawn_blocking(move || {
let mut found: Vec<String> = Vec::new();
let mut truncated = false;
for entry in walk(&root).flatten() {
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let rel = relative(entry.path(), &root);
if regex.is_match(&rel) {
if found.len() >= MAX_RESULTS {
truncated = true;
break;
}
found.push(rel);
}
}
found.sort();
(found, truncated)
})
.await
.map_err(|e| format!("Search failed: {}", e))?;
if matches.is_empty() {
return Ok(format!("No files match '{}' under {}", pattern, requested));
}
let mut out = format!("{} file(s) matching '{}':\n", matches.len(), pattern);
for path in matches {
out.push_str(&format!(" {}\n", path));
}
if truncated {
out.push_str(&format!("(stopped at {} results)\n", MAX_RESULTS));
}
Ok(out)
}
}
pub struct GrepTool;
#[async_trait]
impl Tool for GrepTool {
fn name(&self) -> &str {
"grep"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"Search file contents in the workspace with a regular expression"
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regular expression to search for"
},
"path": {
"type": "string",
"description": "Directory to search under, relative to the workspace (default: .)"
},
"glob": {
"type": "string",
"description": "Optional glob restricting which files are searched, e.g. '**/*.rs'"
},
"case_sensitive": {
"type": "boolean",
"description": "Match case-sensitively (default: false)"
}
},
"required": ["pattern"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let pattern = input
.get("pattern")
.and_then(|v| v.as_str())
.ok_or("Missing 'pattern' parameter")?;
let requested = input.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let root = resolve_in_workspace(requested)?;
let case_sensitive = input
.get("case_sensitive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let regex = RegexBuilder::new(pattern)
.case_insensitive(!case_sensitive)
.build()
.map_err(|e| format!("Invalid pattern '{}': {}", pattern, e))?;
let file_filter = match input.get("glob").and_then(|v| v.as_str()) {
Some(glob) => Some(
RegexBuilder::new(&glob_to_regex(glob))
.build()
.map_err(|e| format!("Invalid glob '{}': {}", glob, e))?,
),
None => None,
};
let (hits, truncated) =
tokio::task::spawn_blocking(move || search_files(&root, ®ex, file_filter.as_ref()))
.await
.map_err(|e| format!("Search failed: {}", e))?;
if hits.is_empty() {
return Ok(format!("No matches for '{}' under {}", pattern, requested));
}
let mut out = format!("{} match(es) for '{}':\n", hits.len(), pattern);
for hit in hits {
out.push_str(&hit);
out.push('\n');
}
if truncated {
out.push_str(&format!("(stopped at {} matches)\n", MAX_RESULTS));
}
Ok(out)
}
}
fn search_files(
root: &Path,
regex: ®ex::Regex,
file_filter: Option<®ex::Regex>,
) -> (Vec<String>, bool) {
let mut hits = Vec::new();
for entry in walk(root).flatten() {
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let path: PathBuf = entry.path().to_path_buf();
let rel = relative(&path, root);
if let Some(filter) = file_filter {
if !filter.is_match(&rel) {
continue;
}
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
for (number, line) in content.lines().enumerate() {
if !regex.is_match(line) {
continue;
}
if hits.len() >= MAX_RESULTS {
return (hits, true);
}
let shown: String = if line.chars().count() > MAX_MATCH_LINE {
line.chars().take(MAX_MATCH_LINE).collect::<String>() + "…"
} else {
line.to_string()
};
hits.push(format!(" {}:{}: {}", rel, number + 1, shown.trim_end()));
}
}
(hits, false)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn glob_translates_star_within_a_segment() {
let re = RegexBuilder::new(&glob_to_regex("*.rs")).build().unwrap();
assert!(re.is_match("main.rs"));
assert!(!re.is_match("src/main.rs"), "* must not cross a separator");
}
#[test]
fn glob_translates_double_star_across_segments() {
let re = RegexBuilder::new(&glob_to_regex("**/*.rs"))
.build()
.unwrap();
assert!(re.is_match("src/tools/search.rs"));
assert!(
re.is_match("main.rs"),
"**/ must also match zero directories"
);
}
#[test]
fn glob_anchors_the_whole_path() {
let re = RegexBuilder::new(&glob_to_regex("src/*.rs"))
.build()
.unwrap();
assert!(re.is_match("src/app.rs"));
assert!(!re.is_match("other/src/app.rs"));
}
#[test]
fn glob_escapes_regex_metacharacters() {
let re = RegexBuilder::new(&glob_to_regex("Cargo.toml"))
.build()
.unwrap();
assert!(re.is_match("Cargo.toml"));
assert!(!re.is_match("CargoXtoml"), "the dot must be literal");
}
#[tokio::test]
async fn glob_finds_this_crates_sources() {
let out = GlobTool
.execute(json!({"pattern": "src/tools/*.rs"}))
.await
.unwrap();
assert!(out.contains("src/tools/search.rs"), "got {}", out);
assert!(
!out.contains("src/main.rs"),
"pattern was too loose: {}",
out
);
}
#[tokio::test]
async fn glob_skips_gitignored_paths() {
let out = GlobTool
.execute(json!({"pattern": "**/*.rs"}))
.await
.unwrap();
assert!(
!out.contains("target/"),
"target/ is gitignored and must not be walked: {}",
out
);
}
#[tokio::test]
async fn grep_finds_a_known_symbol_with_line_numbers() {
let out = GrepTool
.execute(json!({"pattern": "fn resolve_in_workspace", "glob": "**/*.rs"}))
.await
.unwrap();
assert!(out.contains("src/tools/paths.rs:"), "got {}", out);
}
#[tokio::test]
async fn grep_rejects_an_invalid_regex() {
let err = GrepTool.execute(json!({"pattern": "("})).await.unwrap_err();
assert!(err.contains("Invalid pattern"), "got {}", err);
}
#[tokio::test]
async fn search_tools_are_confined_to_the_workspace() {
for path in ["/etc", "../../.."] {
assert!(
ListDirTool.execute(json!({"path": path})).await.is_err(),
"list_dir escaped to {}",
path
);
assert!(
GlobTool
.execute(json!({"pattern": "*", "path": path}))
.await
.is_err(),
"glob escaped to {}",
path
);
assert!(
GrepTool
.execute(json!({"pattern": "x", "path": path}))
.await
.is_err(),
"grep escaped to {}",
path
);
}
}
#[tokio::test]
async fn list_dir_separates_directories_from_files() {
let out = ListDirTool.execute(json!({"path": "src"})).await.unwrap();
assert!(
out.contains("tools/"),
"directories need a trailing slash: {}",
out
);
assert!(out.contains("main.rs"), "got {}", out);
}
}