From cb88ee0e1de0efbb71bf562d7881df04be02e986 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Tue, 19 May 2026 11:29:00 -0400 Subject: [PATCH 1/3] Add glob tool for ergonomic file matching (Phase 7) Supports standard glob patterns (**/*.rs, src/**/*.tsx) with * and ? wildcards. Respects .gitignore via ignore crate. Sorted by modification time. Handles nested directory matching. Complements find_files (regex-based). --- Cargo.lock | 1 + Cargo.toml | 1 + src/agent/builder.rs | 1 + src/agent/tools/glob.rs | 220 ++++++++++++++++++++++++++++++++++++++++ src/agent/tools/mod.rs | 2 + src/ui/mod.rs | 2 +- 6 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 src/agent/tools/glob.rs diff --git a/Cargo.lock b/Cargo.lock index 4c2970c3..14ab7bba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2683,6 +2683,7 @@ dependencies = [ "crossterm", "dirs", "futures", + "glob", "html2text", "http 1.4.0", "ignore", diff --git a/Cargo.toml b/Cargo.toml index a424413c..2faa5e9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ base64 = "0.22" streaming-iterator = { version = "0.1", optional = true } janetrs = { version = "0.8", optional = true } html2text = "0.17" +glob = "0.3.3" [profile.release] opt-level = "z" 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..63740edc --- /dev/null +++ b/src/agent/tools/glob.rs @@ -0,0 +1,220 @@ +use ignore::WalkBuilder; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::Deserialize; +use std::path::Path; + +use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm}; +use crate::agent::tools::{MAX_FIND_RESULTS, is_skip_dir}; + +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. Returns matching file paths sorted by modification time. 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 = 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 path = entry.path(); + let relative = path + .strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .into_owned(); + + // Skip ignored dirs + if let Some(parent) = path.parent() { + if parent + .file_name() + .map_or(false, |n| is_skip_dir(&n.to_string_lossy())) + { + continue; + } + } + + if re.is_match(&relative) { + matches.push(relative); + } + + if matches.len() >= MAX_FIND_RESULTS { + break; + } + } + + // Sort by modification time (newest first), fall back to alphabetical + matches.sort_by(|a, b| { + let ma = std::fs::metadata(a) + .ok() + .and_then(|m| m.modified().ok()); + let mb = std::fs::metadata(b) + .ok() + .and_then(|m| m.modified().ok()); + match (ma, mb) { + (Some(a), Some(b)) => b.cmp(&a), + _ => a.cmp(b), + } + }); + + if matches.is_empty() { + Ok("no files matched".to_string()) + } else { + Ok(matches.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"], From 222abeff3647c68d387c49f97135aa37d4ea090f Mon Sep 17 00:00:00 2001 From: Yogthos Date: Tue, 19 May 2026 12:01:28 -0400 Subject: [PATCH 2/3] Fix glob-tool: remove unused dep, fix mtime sort, drop noise - Remove unused 'glob' crate dependency from Cargo.toml - Fix mtime sort: store absolute paths for metadata lookups so sort works correctly when path param is not CWD - Return empty string on no matches (consistent with other tools) - Remove redundant is_skip_dir parent check (WalkBuilder handles .gitignore) --- Cargo.lock | 1 - Cargo.toml | 1 - src/agent/tools/glob.rs | 39 +++++++++++++++------------------------ 3 files changed, 15 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 14ab7bba..4c2970c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2683,7 +2683,6 @@ dependencies = [ "crossterm", "dirs", "futures", - "glob", "html2text", "http 1.4.0", "ignore", diff --git a/Cargo.toml b/Cargo.toml index 2faa5e9c..a424413c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,6 @@ base64 = "0.22" streaming-iterator = { version = "0.1", optional = true } janetrs = { version = "0.8", optional = true } html2text = "0.17" -glob = "0.3.3" [profile.release] opt-level = "z" diff --git a/src/agent/tools/glob.rs b/src/agent/tools/glob.rs index 63740edc..e2449ed3 100644 --- a/src/agent/tools/glob.rs +++ b/src/agent/tools/glob.rs @@ -5,7 +5,7 @@ use serde::Deserialize; use std::path::Path; use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm}; -use crate::agent::tools::{MAX_FIND_RESULTS, is_skip_dir}; +use crate::agent::tools::MAX_FIND_RESULTS; pub struct GlobTool { pub permission: Option, @@ -70,7 +70,7 @@ impl Tool for GlobTool { 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. Returns matching file paths sorted by modification time. Use this for natural path pattern matching instead of regex-based find_files." + 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", @@ -108,7 +108,7 @@ impl Tool for GlobTool { .filter(|p| p.is_dir()) .unwrap_or_else(|| Path::new(".")); - let mut matches: Vec = Vec::new(); + let mut matches: Vec<(String, std::path::PathBuf)> = Vec::new(); let walker = WalkBuilder::new(root) .hidden(false) @@ -123,25 +123,15 @@ impl Tool for GlobTool { continue; } - let path = entry.path(); - let relative = path + let abs_path = entry.path().to_path_buf(); + let relative = abs_path .strip_prefix(root) - .unwrap_or(path) + .unwrap_or(&abs_path) .to_string_lossy() .into_owned(); - // Skip ignored dirs - if let Some(parent) = path.parent() { - if parent - .file_name() - .map_or(false, |n| is_skip_dir(&n.to_string_lossy())) - { - continue; - } - } - if re.is_match(&relative) { - matches.push(relative); + matches.push((relative, abs_path)); } if matches.len() >= MAX_FIND_RESULTS { @@ -150,23 +140,24 @@ impl Tool for GlobTool { } // Sort by modification time (newest first), fall back to alphabetical - matches.sort_by(|a, b| { - let ma = std::fs::metadata(a) + 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(b) + let mb = std::fs::metadata(abs_b) .ok() .and_then(|m| m.modified().ok()); match (ma, mb) { (Some(a), Some(b)) => b.cmp(&a), - _ => a.cmp(b), + _ => abs_a.cmp(abs_b), } }); - if matches.is_empty() { - Ok("no files matched".to_string()) + let results: Vec = matches.into_iter().map(|(rel, _)| rel).collect(); + if results.is_empty() { + Ok(String::new()) } else { - Ok(matches.join("\n")) + Ok(results.join("\n")) } } } From 9b755225cd58952fc9fcd9686f9de0513f487f32 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Tue, 19 May 2026 12:48:48 -0400 Subject: [PATCH 3/3] Run cargo fmt --- src/agent/tools/glob.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/agent/tools/glob.rs b/src/agent/tools/glob.rs index e2449ed3..37308364 100644 --- a/src/agent/tools/glob.rs +++ b/src/agent/tools/glob.rs @@ -4,8 +4,8 @@ use rig::tool::Tool; use serde::Deserialize; use std::path::Path; -use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm}; use crate::agent::tools::MAX_FIND_RESULTS; +use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm}; pub struct GlobTool { pub permission: Option, @@ -14,10 +14,7 @@ pub struct GlobTool { impl GlobTool { pub fn new(permission: Option, ask_tx: Option) -> Self { - Self { - permission, - ask_tx, - } + Self { permission, ask_tx } } } @@ -98,8 +95,7 @@ impl Tool for GlobTool { ) .await?; - let re = glob_to_regex(&args.pattern) - .map_err(|e| ToolError::Msg(e))?; + let re = glob_to_regex(&args.pattern).map_err(|e| ToolError::Msg(e))?; let root = args .path