Skip to content

Commit 6144361

Browse files
Yogthosyogthos
authored andcommitted
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).
1 parent 52b2aa9 commit 6144361

6 files changed

Lines changed: 226 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ base64 = "0.22"
6565
streaming-iterator = { version = "0.1", optional = true }
6666
janetrs = { version = "0.8", optional = true }
6767
html2text = "0.17"
68+
glob = "0.3.3"
6869

6970
[profile.release]
7071
opt-level = "z"

src/agent/builder.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
159159
ask_tx.clone(),
160160
cache.clone(),
161161
)),
162+
Box::new(tools::GlobTool::new(permission.clone(), ask_tx.clone())),
162163
Box::new(tools::ListDirTool::with_cache(
163164
permission.clone(),
164165
ask_tx.clone(),

src/agent/tools/glob.rs

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
use ignore::WalkBuilder;
2+
use rig::completion::ToolDefinition;
3+
use rig::tool::Tool;
4+
use serde::Deserialize;
5+
use std::path::Path;
6+
7+
use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm};
8+
use crate::agent::tools::{MAX_FIND_RESULTS, is_skip_dir};
9+
10+
pub struct GlobTool {
11+
pub permission: Option<PermCheck>,
12+
pub ask_tx: Option<AskSender>,
13+
}
14+
15+
impl GlobTool {
16+
pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>) -> Self {
17+
Self {
18+
permission,
19+
ask_tx,
20+
}
21+
}
22+
}
23+
24+
#[derive(Deserialize)]
25+
pub struct GlobArgs {
26+
pub pattern: String,
27+
pub path: Option<String>,
28+
}
29+
30+
fn glob_to_regex(pattern: &str) -> Result<regex::Regex, String> {
31+
let mut regex_str = String::from("^");
32+
let chars: Vec<char> = pattern.chars().collect();
33+
let mut i = 0;
34+
while i < chars.len() {
35+
if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
36+
// ** — match any depth
37+
if i + 2 < chars.len() && chars[i + 2] == '/' {
38+
regex_str.push_str("(?:.*/)?");
39+
i += 3;
40+
continue;
41+
} else {
42+
regex_str.push_str(".*");
43+
i += 2;
44+
continue;
45+
}
46+
} else if chars[i] == '*' {
47+
regex_str.push_str("[^/]*");
48+
} else if chars[i] == '?' {
49+
regex_str.push_str("[^/]");
50+
} else {
51+
let c = chars[i];
52+
if ".+()[]{}^$|\\".contains(c) {
53+
regex_str.push('\\');
54+
}
55+
regex_str.push(c);
56+
}
57+
i += 1;
58+
}
59+
regex_str.push('$');
60+
regex::Regex::new(&regex_str).map_err(|e| format!("invalid glob pattern: {}", e))
61+
}
62+
63+
impl Tool for GlobTool {
64+
const NAME: &'static str = "glob";
65+
66+
type Error = ToolError;
67+
type Args = GlobArgs;
68+
type Output = String;
69+
70+
async fn definition(&self, _prompt: String) -> ToolDefinition {
71+
ToolDefinition {
72+
name: "glob".to_string(),
73+
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."
74+
.to_string(),
75+
parameters: serde_json::json!({
76+
"type": "object",
77+
"properties": {
78+
"pattern": {
79+
"type": "string",
80+
"description": "Glob pattern to match file paths (e.g. '**/*.rs', 'src/agent/**/*.rs')"
81+
},
82+
"path": {
83+
"type": "string",
84+
"description": "Root directory to search in (default: current working directory)"
85+
}
86+
},
87+
"required": ["pattern"]
88+
}),
89+
}
90+
}
91+
92+
async fn call(&self, args: GlobArgs) -> Result<String, ToolError> {
93+
check_perm(
94+
&self.permission,
95+
&self.ask_tx,
96+
"glob",
97+
&format!("pattern:{}", args.pattern),
98+
)
99+
.await?;
100+
101+
let re = glob_to_regex(&args.pattern)
102+
.map_err(|e| ToolError::Msg(e))?;
103+
104+
let root = args
105+
.path
106+
.as_deref()
107+
.map(Path::new)
108+
.filter(|p| p.is_dir())
109+
.unwrap_or_else(|| Path::new("."));
110+
111+
let mut matches: Vec<String> = Vec::new();
112+
113+
let walker = WalkBuilder::new(root)
114+
.hidden(false)
115+
.git_global(false)
116+
.git_ignore(true)
117+
.git_exclude(true)
118+
.build();
119+
120+
for entry in walker {
121+
let entry = entry.map_err(|e| ToolError::Msg(e.to_string()))?;
122+
if !entry.file_type().map_or(false, |ft| ft.is_file()) {
123+
continue;
124+
}
125+
126+
let path = entry.path();
127+
let relative = path
128+
.strip_prefix(root)
129+
.unwrap_or(path)
130+
.to_string_lossy()
131+
.into_owned();
132+
133+
// Skip ignored dirs
134+
if let Some(parent) = path.parent() {
135+
if parent
136+
.file_name()
137+
.map_or(false, |n| is_skip_dir(&n.to_string_lossy()))
138+
{
139+
continue;
140+
}
141+
}
142+
143+
if re.is_match(&relative) {
144+
matches.push(relative);
145+
}
146+
147+
if matches.len() >= MAX_FIND_RESULTS {
148+
break;
149+
}
150+
}
151+
152+
// Sort by modification time (newest first), fall back to alphabetical
153+
matches.sort_by(|a, b| {
154+
let ma = std::fs::metadata(a)
155+
.ok()
156+
.and_then(|m| m.modified().ok());
157+
let mb = std::fs::metadata(b)
158+
.ok()
159+
.and_then(|m| m.modified().ok());
160+
match (ma, mb) {
161+
(Some(a), Some(b)) => b.cmp(&a),
162+
_ => a.cmp(b),
163+
}
164+
});
165+
166+
if matches.is_empty() {
167+
Ok("no files matched".to_string())
168+
} else {
169+
Ok(matches.join("\n"))
170+
}
171+
}
172+
}
173+
174+
#[cfg(test)]
175+
mod tests {
176+
use super::*;
177+
178+
#[test]
179+
fn test_glob_to_regex_basic() {
180+
let re = glob_to_regex("*.rs").unwrap();
181+
assert!(re.is_match("main.rs"));
182+
assert!(re.is_match("lib.rs"));
183+
assert!(!re.is_match("main.py"));
184+
assert!(!re.is_match("src/main.rs"));
185+
}
186+
187+
#[test]
188+
fn test_glob_to_regex_recursive() {
189+
let re = glob_to_regex("**/*.rs").unwrap();
190+
assert!(re.is_match("main.rs"));
191+
assert!(re.is_match("src/main.rs"));
192+
assert!(re.is_match("src/agent/tools/foo.rs"));
193+
assert!(!re.is_match("main.py"));
194+
}
195+
196+
#[test]
197+
fn test_glob_to_regex_nested_dir() {
198+
let re = glob_to_regex("src/**/*.rs").unwrap();
199+
assert!(!re.is_match("main.rs"));
200+
assert!(re.is_match("src/main.rs"));
201+
assert!(re.is_match("src/agent/tools/foo.rs"));
202+
assert!(!re.is_match("lib/main.rs"));
203+
}
204+
205+
#[test]
206+
fn test_glob_to_regex_question_mark() {
207+
let re = glob_to_regex("file.??").unwrap();
208+
assert!(re.is_match("file.rs"));
209+
assert!(re.is_match("file.py"));
210+
assert!(!re.is_match("file.cpp"));
211+
assert!(!re.is_match("file.r"));
212+
}
213+
214+
#[tokio::test]
215+
async fn test_definition_has_correct_name() {
216+
let tool = GlobTool::new(None, None);
217+
let def = tool.definition(String::new()).await;
218+
assert_eq!(def.name, "glob");
219+
}
220+
}

src/agent/tools/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod bash;
44
pub(crate) mod cache;
55
pub(crate) mod edit;
66
mod find_files;
7+
mod glob;
78
mod grep;
89
mod list_dir;
910
mod memory;
@@ -25,6 +26,7 @@ pub use bash::BashTool;
2526
pub use cache::ToolCache;
2627
pub use edit::EditTool;
2728
pub use find_files::FindFilesTool;
29+
pub use glob::GlobTool;
2830
pub use grep::GrepTool;
2931
pub use list_dir::ListDirTool;
3032
pub use memory::MemoryTool;

src/ui/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ fn format_tool_call_summary(name: &str, args: &serde_json::Value) -> String {
7373
let primary_keys: &[&str] = match name {
7474
"read" | "write" | "edit" | "list_dir" => &["path"],
7575
"grep" => &["pattern", "path"],
76-
"find_files" => &["pattern"],
76+
"find_files" | "glob" => &["pattern"],
7777
"bash" => &["command"],
7878
"question" => &["questions"],
7979
"task" | "task_status" => &["prompt", "task_id"],

0 commit comments

Comments
 (0)