use super::Tool;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::path::PathBuf;
pub struct GlobSearchTool {
workspace_root: PathBuf,
}
impl GlobSearchTool {
pub fn new(workspace_root: PathBuf) -> Self {
Self { workspace_root }
}
}
#[async_trait]
impl Tool for GlobSearchTool {
fn name(&self) -> &str {
"glob_search"
}
fn description(&self) -> &str {
"Find files matching a glob pattern. Respects .gitignore. \
Examples: '**/*.rs', 'src/**/*.toml', 'Cargo.*'"
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match files"
},
"path": {
"type": "string",
"description": "Directory to search in (optional, defaults to workspace root)"
},
"max_results": {
"type": "integer",
"description": "Maximum number of results (default: 100)"
}
},
"required": ["pattern"]
})
}
fn thulp_definition(&self) -> thulp_core::ToolDefinition {
use thulp_core::{Parameter, ParameterType};
thulp_core::ToolDefinition::builder("glob_search")
.description(self.description())
.parameter(Parameter::builder("pattern").param_type(ParameterType::String).required(true)
.description("Glob pattern to match files").build())
.parameter(Parameter::builder("path").param_type(ParameterType::String).required(false)
.description("Directory to search in (optional, defaults to workspace root)").build())
.parameter(Parameter::builder("max_results").param_type(ParameterType::Integer).required(false)
.description("Maximum number of results (default: 100)").build())
.build()
}
async fn execute(&self, args: Value) -> crate::Result<Value> {
let pattern = args["pattern"]
.as_str()
.ok_or_else(|| crate::PawanError::Tool("pattern is required".into()))?;
let base_path = args["path"]
.as_str()
.map(|p| self.workspace_root.join(p))
.unwrap_or_else(|| self.workspace_root.clone());
let max_results = args["max_results"].as_u64().unwrap_or(100) as usize;
let mut builder = ignore::WalkBuilder::new(&base_path);
builder.hidden(false);
let mut matches = Vec::new();
let glob_matcher = glob::Pattern::new(pattern)
.map_err(|e| crate::PawanError::Tool(format!("Invalid glob pattern: {}", e)))?;
for result in builder.build() {
if matches.len() >= max_results {
break;
}
if let Ok(entry) = result {
let path = entry.path();
if path.is_file() {
let relative = path.strip_prefix(&self.workspace_root).unwrap_or(path);
let relative_str = relative.to_string_lossy();
if glob_matcher.matches(&relative_str) {
let metadata = path.metadata().ok();
let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
let modified = metadata.and_then(|m| m.modified().ok()).map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
});
matches.push(json!({
"path": relative_str,
"size": size,
"modified": modified
}));
}
}
}
}
matches.sort_by(|a, b| {
let a_mod = a["modified"].as_u64().unwrap_or(0);
let b_mod = b["modified"].as_u64().unwrap_or(0);
b_mod.cmp(&a_mod)
});
Ok(json!({
"pattern": pattern,
"matches": matches,
"count": matches.len(),
"truncated": matches.len() >= max_results
}))
}
}
pub struct GrepSearchTool {
workspace_root: PathBuf,
}
impl GrepSearchTool {
pub fn new(workspace_root: PathBuf) -> Self {
Self { workspace_root }
}
}
#[async_trait]
impl Tool for GrepSearchTool {
fn name(&self) -> &str {
"grep_search"
}
fn description(&self) -> &str {
"Search file contents for a pattern. Supports regex. \
Returns file paths and line numbers with matches."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Pattern to search for (supports regex)"
},
"path": {
"type": "string",
"description": "Directory to search in (optional, defaults to workspace root)"
},
"include": {
"type": "string",
"description": "File pattern to include (e.g., '*.rs', '*.{ts,tsx}')"
},
"max_results": {
"type": "integer",
"description": "Maximum number of matching files (default: 50)"
},
"context_lines": {
"type": "integer",
"description": "Lines of context around matches (default: 0)"
}
},
"required": ["pattern"]
})
}
fn thulp_definition(&self) -> thulp_core::ToolDefinition {
use thulp_core::{Parameter, ParameterType};
thulp_core::ToolDefinition::builder("grep_search")
.description(self.description())
.parameter(Parameter::builder("pattern").param_type(ParameterType::String).required(true)
.description("Pattern to search for (supports regex)").build())
.parameter(Parameter::builder("path").param_type(ParameterType::String).required(false)
.description("Directory to search in (optional, defaults to workspace root)").build())
.parameter(Parameter::builder("include").param_type(ParameterType::String).required(false)
.description("File pattern to include (e.g., '*.rs', '*.{ts,tsx}')").build())
.parameter(Parameter::builder("max_results").param_type(ParameterType::Integer).required(false)
.description("Maximum number of matching files (default: 50)").build())
.parameter(Parameter::builder("context_lines").param_type(ParameterType::Integer).required(false)
.description("Lines of context around matches (default: 0)").build())
.build()
}
async fn execute(&self, args: Value) -> crate::Result<Value> {
let pattern = args["pattern"]
.as_str()
.ok_or_else(|| crate::PawanError::Tool("pattern is required".into()))?;
let base_path = args["path"]
.as_str()
.map(|p| self.workspace_root.join(p))
.unwrap_or_else(|| self.workspace_root.clone());
let include = args["include"].as_str();
let max_results = args["max_results"].as_u64().unwrap_or(50) as usize;
let context_lines = args["context_lines"].as_u64().unwrap_or(0) as usize;
let regex = regex::Regex::new(pattern)
.map_err(|e| crate::PawanError::Tool(format!("Invalid regex: {}", e)))?;
let include_matcher = include
.map(glob::Pattern::new)
.transpose()
.map_err(|e| crate::PawanError::Tool(format!("Invalid include pattern: {}", e)))?;
let mut file_matches = Vec::new();
let mut builder = ignore::WalkBuilder::new(&base_path);
builder.hidden(false);
for result in builder.build() {
if file_matches.len() >= max_results {
break;
}
if let Ok(entry) = result {
let path = entry.path();
if !path.is_file() {
continue;
}
let relative = path.strip_prefix(&self.workspace_root).unwrap_or(path);
let relative_str = relative.to_string_lossy();
if let Some(ref matcher) = include_matcher {
let filename = path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
if !matcher.matches(&filename) && !matcher.matches(&relative_str) {
continue;
}
}
if let Ok(content) = std::fs::read_to_string(path) {
let mut line_matches = Vec::new();
let lines: Vec<&str> = content.lines().collect();
for (line_num, line) in lines.iter().enumerate() {
if regex.is_match(line) {
let mut match_info = json!({
"line": line_num + 1,
"content": line.chars().take(200).collect::<String>()
});
if context_lines > 0 {
let start = line_num.saturating_sub(context_lines);
let end = (line_num + context_lines + 1).min(lines.len());
let context: Vec<String> = lines[start..end]
.iter()
.enumerate()
.map(|(i, l)| format!("{}: {}", start + i + 1, l))
.collect();
match_info["context"] = json!(context);
}
line_matches.push(match_info);
}
}
if !line_matches.is_empty() {
file_matches.push(json!({
"path": relative_str,
"matches": line_matches,
"match_count": line_matches.len()
}));
}
}
}
}
file_matches.sort_by(|a, b| {
let a_count = a["match_count"].as_u64().unwrap_or(0);
let b_count = b["match_count"].as_u64().unwrap_or(0);
b_count.cmp(&a_count)
});
let total_matches: u64 = file_matches
.iter()
.map(|f| f["match_count"].as_u64().unwrap_or(0))
.sum();
Ok(json!({
"pattern": pattern,
"files": file_matches,
"file_count": file_matches.len(),
"total_matches": total_matches,
"truncated": file_matches.len() >= max_results
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_glob_search() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join("file1.rs"), "rust code").unwrap();
std::fs::write(temp_dir.path().join("file2.rs"), "more rust").unwrap();
std::fs::write(temp_dir.path().join("file3.txt"), "text file").unwrap();
let tool = GlobSearchTool::new(temp_dir.path().to_path_buf());
let result = tool.execute(json!({"pattern": "*.rs"})).await.unwrap();
assert_eq!(result["count"], 2);
}
#[tokio::test]
async fn test_grep_search() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(
temp_dir.path().join("test.rs"),
"fn main() {\n println!(\"hello\");\n}",
)
.unwrap();
let tool = GrepSearchTool::new(temp_dir.path().to_path_buf());
let result = tool
.execute(json!({
"pattern": "println",
"include": "*.rs"
}))
.await
.unwrap();
assert_eq!(result["file_count"], 1);
assert_eq!(result["total_matches"], 1);
}
#[tokio::test]
async fn test_glob_no_matches() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("file.txt"), "text").unwrap();
let tool = GlobSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "*.rs"})).await.unwrap();
assert_eq!(result["count"], 0);
assert_eq!(result["truncated"], false);
}
#[tokio::test]
async fn test_glob_invalid_pattern() {
let tmp = TempDir::new().unwrap();
let tool = GlobSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "[invalid"})).await;
assert!(result.is_err(), "Invalid glob should error");
}
#[tokio::test]
async fn test_glob_missing_pattern() {
let tmp = TempDir::new().unwrap();
let tool = GlobSearchTool::new(tmp.path().into());
let result = tool.execute(json!({})).await;
assert!(result.is_err(), "Missing pattern should error");
}
#[tokio::test]
async fn test_glob_max_results() {
let tmp = TempDir::new().unwrap();
for i in 0..10 {
std::fs::write(tmp.path().join(format!("f{}.rs", i)), "code").unwrap();
}
let tool = GlobSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "*.rs", "max_results": 3})).await.unwrap();
assert_eq!(result["count"], 3);
assert_eq!(result["truncated"], true);
}
#[tokio::test]
async fn test_glob_subdirectory() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("sub")).unwrap();
std::fs::write(tmp.path().join("sub/a.rs"), "code").unwrap();
std::fs::write(tmp.path().join("b.rs"), "code").unwrap();
let tool = GlobSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "*.rs", "path": "sub"})).await.unwrap();
assert_eq!(result["count"], 1);
}
#[tokio::test]
async fn test_glob_result_has_metadata() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.rs"), "hello world").unwrap();
let tool = GlobSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "*.rs"})).await.unwrap();
let first = &result["matches"][0];
assert!(first["path"].as_str().is_some());
assert!(first["size"].as_u64().unwrap() > 0);
assert!(first["modified"].as_u64().is_some());
}
#[tokio::test]
async fn test_grep_no_matches() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.rs"), "fn main() {}").unwrap();
let tool = GrepSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "nonexistent_string_xyz"})).await.unwrap();
assert_eq!(result["file_count"], 0);
assert_eq!(result["total_matches"], 0);
}
#[tokio::test]
async fn test_grep_regex() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.rs"), "fn foo() {}\nfn bar() {}\nfn baz() {}").unwrap();
let tool = GrepSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "fn \\w+\\(\\)"})).await.unwrap();
assert_eq!(result["total_matches"], 3);
}
#[tokio::test]
async fn test_grep_invalid_regex() {
let tmp = TempDir::new().unwrap();
let tool = GrepSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "[invalid"})).await;
assert!(result.is_err(), "Invalid regex should error");
}
#[tokio::test]
async fn test_grep_missing_pattern() {
let tmp = TempDir::new().unwrap();
let tool = GrepSearchTool::new(tmp.path().into());
let result = tool.execute(json!({})).await;
assert!(result.is_err(), "Missing pattern should error");
}
#[tokio::test]
async fn test_grep_include_filter() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.rs"), "hello").unwrap();
std::fs::write(tmp.path().join("b.txt"), "hello").unwrap();
let tool = GrepSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "hello", "include": "*.rs"})).await.unwrap();
assert_eq!(result["file_count"], 1);
let path = result["files"][0]["path"].as_str().unwrap();
assert!(path.ends_with(".rs"));
}
#[tokio::test]
async fn test_grep_context_lines() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.rs"), "line1\nline2\nTARGET\nline4\nline5").unwrap();
let tool = GrepSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "TARGET", "context_lines": 1})).await.unwrap();
let matches = result["files"][0]["matches"].as_array().unwrap();
assert!(matches[0]["context"].is_array());
let ctx = matches[0]["context"].as_array().unwrap();
assert_eq!(ctx.len(), 3); }
#[tokio::test]
async fn test_grep_max_results() {
let tmp = TempDir::new().unwrap();
for i in 0..10 {
std::fs::write(tmp.path().join(format!("f{}.rs", i)), "match_me").unwrap();
}
let tool = GrepSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "match_me", "max_results": 3})).await.unwrap();
assert_eq!(result["file_count"], 3);
assert_eq!(result["truncated"], true);
}
#[tokio::test]
async fn test_grep_multiple_matches_in_file() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.rs"), "foo\nbar\nfoo\nbaz\nfoo").unwrap();
let tool = GrepSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "foo"})).await.unwrap();
assert_eq!(result["files"][0]["match_count"], 3);
assert_eq!(result["total_matches"], 3);
}
#[tokio::test]
async fn test_grep_line_truncation() {
let tmp = TempDir::new().unwrap();
let long_line = "x".repeat(500);
std::fs::write(tmp.path().join("f.rs"), &long_line).unwrap();
let tool = GrepSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "x+"})).await.unwrap();
let content = result["files"][0]["matches"][0]["content"].as_str().unwrap();
assert_eq!(content.len(), 200, "Line content should be truncated to 200 chars");
}
#[tokio::test]
async fn test_grep_sorted_by_match_count() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("few.rs"), "x").unwrap();
std::fs::write(tmp.path().join("many.rs"), "x\nx\nx\nx\nx").unwrap();
let tool = GrepSearchTool::new(tmp.path().into());
let result = tool.execute(json!({"pattern": "x"})).await.unwrap();
let files = result["files"].as_array().unwrap();
assert!(files.len() == 2);
let first_count = files[0]["match_count"].as_u64().unwrap();
let second_count = files[1]["match_count"].as_u64().unwrap();
assert!(first_count >= second_count, "Results should be sorted by match count desc");
}
}