use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs;
use crate::integration::HostIntegration;
use crate::tools::{Tool, ToolError, ToolResponse, Permission};
pub struct GlobTool;
#[derive(Debug, Deserialize)]
struct GlobParams {
pattern: String,
path: Option<PathBuf>,
limit: Option<usize>,
include_hidden: Option<bool>,
follow_symlinks: Option<bool>,
file_type: Option<String>,
}
#[derive(Debug, Serialize)]
struct GlobMetadata {
pattern: String,
search_path: String,
total_matches: usize,
truncated: bool,
search_time_ms: u64,
file_type_filter: String,
}
#[derive(Debug, Serialize)]
struct FileMatch {
path: String,
file_type: String,
size: Option<u64>,
modified: Option<u64>,
}
impl GlobTool {
pub fn new() -> Self {
Self
}
async fn find_matches(&self, params: &GlobParams) -> Result<(Vec<FileMatch>, GlobMetadata), ToolError> {
let start_time = std::time::Instant::now();
if params.pattern.trim().is_empty() {
return Err(ToolError::InvalidParameters("pattern is required".to_string()));
}
let search_path = params.path.as_ref()
.map(|p| p.clone())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
if !search_path.exists() {
return Err(ToolError::ExecutionFailed(format!(
"Search path does not exist: {}", search_path.display()
)));
}
let limit = params.limit.unwrap_or(100);
let include_hidden = params.include_hidden.unwrap_or(false);
let follow_symlinks = params.follow_symlinks.unwrap_or(false);
let file_type_filter = params.file_type.as_deref().unwrap_or("any");
if !["file", "dir", "any"].contains(&file_type_filter) {
return Err(ToolError::InvalidParameters(format!(
"Invalid file_type '{}'. Must be 'file', 'dir', or 'any'", file_type_filter
)));
}
let glob_pattern = glob::Pattern::new(¶ms.pattern)
.map_err(|e| ToolError::InvalidParameters(format!("Invalid glob pattern: {}", e)))?;
let mut matches = Vec::new();
let mut total_found = 0;
self.search_directory(
&search_path,
&search_path,
&glob_pattern,
&mut matches,
&mut total_found,
limit,
include_hidden,
follow_symlinks,
file_type_filter,
).await?;
matches.sort_by(|a, b| {
b.modified.unwrap_or(0).cmp(&a.modified.unwrap_or(0))
});
let search_time = start_time.elapsed();
let truncated = total_found > limit;
let metadata = GlobMetadata {
pattern: params.pattern.clone(),
search_path: search_path.display().to_string(),
total_matches: total_found,
truncated,
search_time_ms: search_time.as_millis() as u64,
file_type_filter: file_type_filter.to_string(),
};
Ok((matches, metadata))
}
#[async_recursion::async_recursion]
async fn search_directory(
&self,
current_dir: &Path,
base_dir: &Path,
pattern: &glob::Pattern,
matches: &mut Vec<FileMatch>,
total_found: &mut usize,
limit: usize,
include_hidden: bool,
follow_symlinks: bool,
file_type_filter: &str,
) -> Result<(), ToolError> {
if matches.len() >= limit {
return Ok(());
}
let mut entries = fs::read_dir(current_dir).await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read directory {}: {}", current_dir.display(), e)))?;
while let Some(entry) = entries.next_entry().await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read directory entry: {}", e)))? {
if matches.len() >= limit {
break;
}
let path = entry.path();
let file_name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if !include_hidden && file_name.starts_with('.') {
continue;
}
let metadata = if follow_symlinks {
fs::metadata(&path).await
} else {
fs::symlink_metadata(&path).await
};
let metadata = match metadata {
Ok(m) => m,
Err(_) => continue, };
let is_dir = metadata.is_dir();
let is_file = metadata.is_file();
let matches_filter = match file_type_filter {
"file" => is_file,
"dir" => is_dir,
"any" => true,
_ => true,
};
if !matches_filter {
if is_dir {
self.search_directory(
&path,
base_dir,
pattern,
matches,
total_found,
limit,
include_hidden,
follow_symlinks,
file_type_filter,
).await?;
}
continue;
}
let relative_path = path.strip_prefix(base_dir)
.unwrap_or(&path)
.to_string_lossy();
if pattern.matches(&relative_path) {
*total_found += 1;
if matches.len() < limit {
let file_match = FileMatch {
path: relative_path.to_string(),
file_type: if is_dir { "directory" } else { "file" }.to_string(),
size: if is_file { Some(metadata.len()) } else { None },
modified: metadata.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs()),
};
matches.push(file_match);
}
}
if is_dir {
self.search_directory(
&path,
base_dir,
pattern,
matches,
total_found,
limit,
include_hidden,
follow_symlinks,
file_type_filter,
).await?;
}
}
Ok(())
}
fn format_response(&self, matches: &[FileMatch], metadata: &GlobMetadata) -> String {
let mut response = String::new();
response.push_str(&format!("Found {} file(s) matching pattern '{}'\n",
metadata.total_matches, metadata.pattern));
response.push_str(&format!("Search path: {}\n", metadata.search_path));
response.push_str(&format!("Search time: {}ms\n", metadata.search_time_ms));
if metadata.truncated {
response.push_str(&format!("Results truncated to {} items\n", matches.len()));
}
if !matches.is_empty() {
response.push_str("\nMatches (sorted by modification time, newest first):\n");
for (i, file_match) in matches.iter().enumerate() {
response.push_str(&format!("{}. {} ({})",
i + 1, file_match.path, file_match.file_type));
if let Some(size) = file_match.size {
response.push_str(&format!(", {} bytes", size));
}
if let Some(modified) = file_match.modified {
let datetime = chrono::DateTime::from_timestamp(modified as i64, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "unknown".to_string());
response.push_str(&format!(", modified: {}", datetime));
}
response.push('\n');
}
} else {
response.push_str("\nNo matches found.\n");
}
if matches.is_empty() {
response.push_str("\nGlob pattern syntax:\n");
response.push_str(" * - matches any sequence of characters\n");
response.push_str(" ? - matches any single character\n");
response.push_str(" [abc] - matches any character in brackets\n");
response.push_str(" **/ - matches directories recursively\n");
response.push_str(" Examples: *.rs, **/*.py, src/**/test_*.rs\n");
}
response
}
}
impl Default for GlobTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for GlobTool {
async fn execute(
&self,
parameters: serde_json::Value,
_host: &dyn HostIntegration,
) -> Result<ToolResponse, ToolError> {
let params: GlobParams = serde_json::from_value(parameters)
.map_err(|e| ToolError::InvalidParameters(format!("Invalid parameters: {}", e)))?;
let (matches, metadata) = self.find_matches(¶ms).await?;
let response_content = self.format_response(&matches, &metadata);
let metadata_json = serde_json::to_value(&metadata)
.unwrap_or(serde_json::Value::Null);
Ok(ToolResponse {
content: response_content,
success: true,
metadata: metadata_json,
affected_files: Vec::new(), })
}
fn name(&self) -> &str {
"glob"
}
fn description(&self) -> &str {
"Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first)."
}
fn requires_permission(&self) -> Permission {
Permission::ReadFile(PathBuf::from(".")) }
fn parameter_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match against file paths (e.g., '*.rs', '**/*.py', 'src/**/test_*.rs')"
},
"path": {
"type": "string",
"description": "Starting directory for search (default: current working directory)"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return (default: 100)",
"default": 100,
"minimum": 1,
"maximum": 1000
},
"include_hidden": {
"type": "boolean",
"description": "Whether to include hidden files (starting with '.') (default: false)",
"default": false
},
"follow_symlinks": {
"type": "boolean",
"description": "Whether to follow symbolic links (default: false)",
"default": false
},
"file_type": {
"type": "string",
"description": "File type filter: 'file', 'dir', or 'any' (default: 'any')",
"enum": ["file", "dir", "any"],
"default": "any"
}
},
"required": ["pattern"]
})
}
fn clone_box(&self) -> Box<dyn Tool> {
Box::new(Self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tokio::fs;
#[tokio::test]
async fn test_glob_tool_creation() {
let tool = GlobTool::new();
assert_eq!(tool.name(), "glob");
assert!(!tool.description().is_empty());
}
#[tokio::test]
async fn test_glob_pattern_matching() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::write(base_path.join("test.rs"), "// Rust file").await.unwrap();
fs::write(base_path.join("test.py"), "# Python file").await.unwrap();
fs::write(base_path.join("README.md"), "# Readme").await.unwrap();
let tool = GlobTool::new();
let params = serde_json::json!({
"pattern": "*.rs",
"path": base_path
});
let result = tool.execute(params, &crate::integration::MockHost).await.unwrap();
assert!(result.success);
assert!(result.content.contains("test.rs"));
assert!(!result.content.contains("test.py"));
}
#[tokio::test]
async fn test_glob_recursive_pattern() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let src_dir = base_path.join("src");
fs::create_dir(&src_dir).await.unwrap();
fs::write(src_dir.join("main.rs"), "fn main() {}").await.unwrap();
let tests_dir = base_path.join("tests");
fs::create_dir(&tests_dir).await.unwrap();
fs::write(tests_dir.join("test_main.rs"), "#[test] fn test() {}").await.unwrap();
let tool = GlobTool::new();
let params = serde_json::json!({
"pattern": "**/*.rs",
"path": base_path
});
let result = tool.execute(params, &crate::integration::MockHost).await.unwrap();
assert!(result.success);
assert!(result.content.contains("main.rs"), "Expected to find main.rs in: {}", result.content);
assert!(result.content.contains("test_main.rs"), "Expected to find test_main.rs in: {}", result.content);
}
#[tokio::test]
async fn test_glob_file_type_filter() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
fs::write(base_path.join("file.txt"), "content").await.unwrap();
fs::create_dir(base_path.join("subdir")).await.unwrap();
let tool = GlobTool::new();
let params = serde_json::json!({
"pattern": "*",
"path": base_path,
"file_type": "file"
});
let result = tool.execute(params, &crate::integration::MockHost).await.unwrap();
assert!(result.success);
assert!(result.content.contains("file.txt"));
assert!(!result.content.contains("subdir"));
let params = serde_json::json!({
"pattern": "*",
"path": base_path,
"file_type": "dir"
});
let result = tool.execute(params, &crate::integration::MockHost).await.unwrap();
assert!(result.success);
assert!(!result.content.contains("file.txt"));
assert!(result.content.contains("subdir"));
}
}