use crate::tools::{Tool, ToolContext, ToolResult};
use anyhow::Result;
use async_trait::async_trait;
use globset::Glob;
use std::time::SystemTime;
use walkdir::WalkDir;
pub struct GlobSearchTool;
#[async_trait]
impl Tool for GlobSearchTool {
fn name(&self) -> &str {
"glob_search"
}
fn description(&self) -> &str {
"Search for files matching a glob pattern (e.g., '**/*.rs', 'src/**/*.toml'). \
Results are sorted by modification time (newest first), max 100 files. \
Returns absolute paths, one per line."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match files (e.g., '**/*.rs')"
},
"path": {
"type": "string",
"description": "Root directory to search from (default: working directory)"
}
},
"required": ["pattern"]
})
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> Result<ToolResult> {
let pattern = match args["pattern"].as_str() {
Some(p) => p.to_string(),
None => {
return Ok(ToolResult {
output: "Missing required argument: pattern".to_string(),
is_error: true,
});
}
};
let root = if let Some(p) = args["path"].as_str() {
std::path::PathBuf::from(p)
} else {
ctx.working_dir.clone()
};
let glob = match Glob::new(&pattern) {
Ok(g) => g.compile_matcher(),
Err(e) => {
return Ok(ToolResult {
output: format!("Invalid glob pattern '{}': {}", pattern, e),
is_error: true,
});
}
};
let mut matches: Vec<(SystemTime, std::path::PathBuf)> = Vec::new();
for entry in WalkDir::new(&root)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
if !entry.file_type().is_file() {
continue;
}
let abs_path = entry.path();
let rel_path = abs_path.strip_prefix(&root).unwrap_or(abs_path);
let rel_str = rel_path.to_string_lossy();
if glob.is_match(rel_str.as_ref()) {
let mtime = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH);
matches.push((mtime, abs_path.to_path_buf()));
}
}
if matches.is_empty() {
return Ok(ToolResult {
output: format!("No files found matching pattern: {}", pattern),
is_error: false,
});
}
matches.sort_by(|a, b| b.0.cmp(&a.0));
matches.truncate(100);
let lines: Vec<String> = matches
.into_iter()
.map(|(_, path)| path.to_string_lossy().to_string())
.collect();
Ok(ToolResult {
output: lines.join("\n"),
is_error: false,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn ctx_with_dir(dir: &TempDir) -> ToolContext {
ToolContext {
working_dir: dir.path().to_path_buf(),
sandbox_enabled: false,
io: std::sync::Arc::new(crate::io::NullIO),
compact_mode: false,
lsp_client: std::sync::Arc::new(tokio::sync::Mutex::new(None)),
mcp_client: None,
nesting_depth: 0,
llm: std::sync::Arc::new(crate::llm::NullLlmProvider),
tools: std::sync::Arc::new(crate::tools::ToolRegistry::new()),
permissions: vec![],
formatters: std::collections::HashMap::new(),
}
}
#[tokio::test]
async fn test_glob_search_finds_files() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("hello.rs"), "fn main() {}").unwrap();
fs::write(tmp.path().join("world.rs"), "fn other() {}").unwrap();
fs::write(tmp.path().join("readme.md"), "# readme").unwrap();
let tool = GlobSearchTool;
let args = serde_json::json!({ "pattern": "**/*.rs" });
let result = tool.execute(args, &ctx_with_dir(&tmp)).await.unwrap();
assert!(!result.is_error);
assert!(result.output.contains("hello.rs") || result.output.contains("world.rs"));
assert!(!result.output.contains("readme.md"));
}
#[tokio::test]
async fn test_glob_search_no_matches() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("hello.txt"), "text").unwrap();
let tool = GlobSearchTool;
let args = serde_json::json!({ "pattern": "**/*.rs" });
let result = tool.execute(args, &ctx_with_dir(&tmp)).await.unwrap();
assert!(!result.is_error);
assert!(result.output.contains("No files found"));
}
#[tokio::test]
async fn test_glob_search_missing_pattern() {
let tmp = TempDir::new().unwrap();
let tool = GlobSearchTool;
let args = serde_json::json!({});
let result = tool.execute(args, &ctx_with_dir(&tmp)).await.unwrap();
assert!(result.is_error);
assert!(result.output.contains("Missing required argument"));
}
#[tokio::test]
async fn test_glob_search_invalid_pattern() {
let tmp = TempDir::new().unwrap();
let tool = GlobSearchTool;
let args = serde_json::json!({ "pattern": "[invalid" });
let result = tool.execute(args, &ctx_with_dir(&tmp)).await.unwrap();
assert!(result.is_error);
assert!(result.output.contains("Invalid glob pattern"));
}
#[tokio::test]
async fn test_glob_search_custom_path() {
let tmp = TempDir::new().unwrap();
let sub = tmp.path().join("sub");
fs::create_dir(&sub).unwrap();
fs::write(sub.join("file.toml"), "[package]").unwrap();
let tool = GlobSearchTool;
let args = serde_json::json!({
"pattern": "**/*.toml",
"path": sub.to_str().unwrap()
});
let ctx = ToolContext {
working_dir: PathBuf::from("/tmp"),
sandbox_enabled: false,
io: std::sync::Arc::new(crate::io::NullIO),
compact_mode: false,
lsp_client: std::sync::Arc::new(tokio::sync::Mutex::new(None)),
mcp_client: None,
nesting_depth: 0,
llm: std::sync::Arc::new(crate::llm::NullLlmProvider),
tools: std::sync::Arc::new(crate::tools::ToolRegistry::new()),
permissions: vec![],
formatters: std::collections::HashMap::new(),
};
let result = tool.execute(args, &ctx).await.unwrap();
assert!(!result.is_error);
assert!(result.output.contains("file.toml"));
}
}