diff --git a/src/agent/builder.rs b/src/agent/builder.rs index 63f75e54..7e9a2730 100644 --- a/src/agent/builder.rs +++ b/src/agent/builder.rs @@ -159,6 +159,7 @@ pub async fn build_agent_inner( ask_tx.clone(), cache.clone(), )), + Box::new(tools::GlobTool::new(permission.clone(), ask_tx.clone())), Box::new(tools::ListDirTool::with_cache( permission.clone(), ask_tx.clone(), diff --git a/src/agent/tools/glob.rs b/src/agent/tools/glob.rs new file mode 100644 index 00000000..37308364 --- /dev/null +++ b/src/agent/tools/glob.rs @@ -0,0 +1,207 @@ +use ignore::WalkBuilder; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::Deserialize; +use std::path::Path; + +use crate::agent::tools::MAX_FIND_RESULTS; +use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm}; + +pub struct GlobTool { + pub permission: Option, + pub ask_tx: Option, +} + +impl GlobTool { + pub fn new(permission: Option, ask_tx: Option) -> Self { + Self { permission, ask_tx } + } +} + +#[derive(Deserialize)] +pub struct GlobArgs { + pub pattern: String, + pub path: Option, +} + +fn glob_to_regex(pattern: &str) -> Result { + let mut regex_str = String::from("^"); + let chars: Vec = pattern.chars().collect(); + let mut i = 0; + while i < chars.len() { + if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' { + // ** — match any depth + if i + 2 < chars.len() && chars[i + 2] == '/' { + regex_str.push_str("(?:.*/)?"); + i += 3; + continue; + } else { + regex_str.push_str(".*"); + i += 2; + continue; + } + } else if chars[i] == '*' { + regex_str.push_str("[^/]*"); + } else if chars[i] == '?' { + regex_str.push_str("[^/]"); + } else { + let c = chars[i]; + if ".+()[]{}^$|\\".contains(c) { + regex_str.push('\\'); + } + regex_str.push(c); + } + i += 1; + } + regex_str.push('$'); + regex::Regex::new(®ex_str).map_err(|e| format!("invalid glob pattern: {}", e)) +} + +impl Tool for GlobTool { + const NAME: &'static str = "glob"; + + type Error = ToolError; + type Args = GlobArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: "glob".to_string(), + description: "Find files matching a glob pattern (e.g., '**/*.rs', 'src/**/*.tsx'). Respects .gitignore via ignore crate. Returns matching relative file paths sorted by modification time (newest first). Returns empty string when no files match. Use this for natural path pattern matching instead of regex-based find_files." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match file paths (e.g. '**/*.rs', 'src/agent/**/*.rs')" + }, + "path": { + "type": "string", + "description": "Root directory to search in (default: current working directory)" + } + }, + "required": ["pattern"] + }), + } + } + + async fn call(&self, args: GlobArgs) -> Result { + check_perm( + &self.permission, + &self.ask_tx, + "glob", + &format!("pattern:{}", args.pattern), + ) + .await?; + + let re = glob_to_regex(&args.pattern).map_err(|e| ToolError::Msg(e))?; + + let root = args + .path + .as_deref() + .map(Path::new) + .filter(|p| p.is_dir()) + .unwrap_or_else(|| Path::new(".")); + + let mut matches: Vec<(String, std::path::PathBuf)> = Vec::new(); + + let walker = WalkBuilder::new(root) + .hidden(false) + .git_global(false) + .git_ignore(true) + .git_exclude(true) + .build(); + + for entry in walker { + let entry = entry.map_err(|e| ToolError::Msg(e.to_string()))?; + if !entry.file_type().map_or(false, |ft| ft.is_file()) { + continue; + } + + let abs_path = entry.path().to_path_buf(); + let relative = abs_path + .strip_prefix(root) + .unwrap_or(&abs_path) + .to_string_lossy() + .into_owned(); + + if re.is_match(&relative) { + matches.push((relative, abs_path)); + } + + if matches.len() >= MAX_FIND_RESULTS { + break; + } + } + + // Sort by modification time (newest first), fall back to alphabetical + matches.sort_by(|(_, abs_a), (_, abs_b)| { + let ma = std::fs::metadata(abs_a) + .ok() + .and_then(|m| m.modified().ok()); + let mb = std::fs::metadata(abs_b) + .ok() + .and_then(|m| m.modified().ok()); + match (ma, mb) { + (Some(a), Some(b)) => b.cmp(&a), + _ => abs_a.cmp(abs_b), + } + }); + + let results: Vec = matches.into_iter().map(|(rel, _)| rel).collect(); + if results.is_empty() { + Ok(String::new()) + } else { + Ok(results.join("\n")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_glob_to_regex_basic() { + let re = glob_to_regex("*.rs").unwrap(); + assert!(re.is_match("main.rs")); + assert!(re.is_match("lib.rs")); + assert!(!re.is_match("main.py")); + assert!(!re.is_match("src/main.rs")); + } + + #[test] + fn test_glob_to_regex_recursive() { + let re = glob_to_regex("**/*.rs").unwrap(); + assert!(re.is_match("main.rs")); + assert!(re.is_match("src/main.rs")); + assert!(re.is_match("src/agent/tools/foo.rs")); + assert!(!re.is_match("main.py")); + } + + #[test] + fn test_glob_to_regex_nested_dir() { + let re = glob_to_regex("src/**/*.rs").unwrap(); + assert!(!re.is_match("main.rs")); + assert!(re.is_match("src/main.rs")); + assert!(re.is_match("src/agent/tools/foo.rs")); + assert!(!re.is_match("lib/main.rs")); + } + + #[test] + fn test_glob_to_regex_question_mark() { + let re = glob_to_regex("file.??").unwrap(); + assert!(re.is_match("file.rs")); + assert!(re.is_match("file.py")); + assert!(!re.is_match("file.cpp")); + assert!(!re.is_match("file.r")); + } + + #[tokio::test] + async fn test_definition_has_correct_name() { + let tool = GlobTool::new(None, None); + let def = tool.definition(String::new()).await; + assert_eq!(def.name, "glob"); + } +} diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index b7ae36ad..40130b99 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -4,6 +4,7 @@ mod bash; pub(crate) mod cache; pub(crate) mod edit; mod find_files; +mod glob; mod grep; mod list_dir; mod memory; @@ -25,6 +26,7 @@ pub use bash::BashTool; pub use cache::ToolCache; pub use edit::EditTool; pub use find_files::FindFilesTool; +pub use glob::GlobTool; pub use grep::GrepTool; pub use list_dir::ListDirTool; pub use memory::MemoryTool; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 347e205e..defa4f50 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -73,7 +73,7 @@ fn format_tool_call_summary(name: &str, args: &serde_json::Value) -> String { let primary_keys: &[&str] = match name { "read" | "write" | "edit" | "list_dir" => &["path"], "grep" => &["pattern", "path"], - "find_files" => &["pattern"], + "find_files" | "glob" => &["pattern"], "bash" => &["command"], "question" => &["questions"], "task" | "task_status" => &["prompt", "task_id"],