1use async_trait::async_trait;
11use serde_json::{json, Value};
12use std::path::PathBuf;
13use walkdir::WalkDir;
14
15use super::{resolve_within_any, AccessTier, SharedSandboxRoots, Tool};
16use crate::acp::ToolKind;
17use crate::error::{Error, Result};
18use crate::llm::ToolSpec;
19
20const MAX_RESULTS: usize = 200;
21
22fn match_segment(pattern: &str, text: &str) -> bool {
27 let p: Vec<char> = pattern.chars().collect();
28 let t: Vec<char> = text.chars().collect();
29 match_seg_inner(&p, &t)
30}
31
32fn match_seg_inner(p: &[char], t: &[char]) -> bool {
33 match (p.first(), t.first()) {
34 (None, None) => true,
35 (Some(&'*'), _) => {
36 if match_seg_inner(&p[1..], t) {
39 return true;
40 }
41 if !t.is_empty() {
42 return match_seg_inner(p, &t[1..]);
43 }
44 false
45 }
46 (Some(&'?'), Some(_)) => match_seg_inner(&p[1..], &t[1..]),
47 (Some(pc), Some(tc)) if pc == tc => match_seg_inner(&p[1..], &t[1..]),
48 _ => false,
49 }
50}
51
52fn match_path(pattern_parts: &[&str], path_parts: &[&str]) -> bool {
56 match (pattern_parts.first(), path_parts.first()) {
57 (None, None) => true,
58 (None, _) => false,
59 (Some(&"**"), _) => {
60 if match_path(&pattern_parts[1..], path_parts) {
62 return true;
63 }
64 if !path_parts.is_empty() {
66 return match_path(pattern_parts, &path_parts[1..]);
67 }
68 false
69 }
70 (_, None) => false,
71 (Some(pp), Some(tp)) => {
72 if match_segment(pp, tp) {
73 match_path(&pattern_parts[1..], &path_parts[1..])
74 } else {
75 false
76 }
77 }
78 }
79}
80
81fn glob_matches(pattern: &str, rel_path: &str) -> bool {
83 let rel = rel_path.replace('\\', "/");
85 let pat = pattern.replace('\\', "/");
86 let pattern_parts: Vec<&str> = pat.split('/').collect();
87 let path_parts: Vec<&str> = rel.split('/').collect();
88 match_path(&pattern_parts, &path_parts)
89}
90
91#[derive(Debug, Clone)]
93pub struct GlobTool {
94 pub root: PathBuf,
95 pub extra_roots: Vec<(PathBuf, AccessTier)>,
96 pub session_roots: Option<SharedSandboxRoots>,
97}
98
99impl GlobTool {
100 pub fn new(root: impl Into<PathBuf>) -> Self {
101 Self {
102 root: root.into(),
103 extra_roots: Vec::new(),
104 session_roots: None,
105 }
106 }
107
108 pub fn with_extra_roots(
111 mut self,
112 extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
113 ) -> Self {
114 self.extra_roots.extend(extra);
115 self
116 }
117
118 pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
120 self.session_roots = Some(slot);
121 self
122 }
123
124 pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
126 if let Some(s) = slot {
127 self.session_roots = Some(s);
128 }
129 self
130 }
131
132 fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
133 let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
134 v.push((self.root.clone(), AccessTier::ReadWrite));
135 v.extend(self.extra_roots.iter().cloned());
136 if let Some(slot) = &self.session_roots {
137 if let Ok(roots) = slot.read() {
138 v.extend(roots.iter().cloned());
139 }
140 }
141 v
142 }
143
144 fn relativise(&self, entry_path: &std::path::Path) -> String {
149 if let Ok(rel) = entry_path.strip_prefix(&self.root) {
150 return rel.to_string_lossy().into_owned();
151 }
152 if self
156 .extra_roots
157 .iter()
158 .any(|(extra, _)| entry_path.starts_with(extra))
159 {
160 return entry_path.to_string_lossy().into_owned();
161 }
162 entry_path.to_string_lossy().into_owned()
163 }
164}
165
166#[async_trait]
167impl Tool for GlobTool {
168 fn spec(&self) -> ToolSpec {
169 ToolSpec {
170 name: "Glob".into(),
171 description: "Find files matching a glob pattern (e.g. \"**/*.rs\") inside the workspace. Returns matching paths relative to the workspace root, one per line. Capped at 200 results.".into(),
172 parameters: json!({
173 "type": "object",
174 "properties": {
175 "pattern": {
176 "type": "string",
177 "description": "Glob pattern to match. Supports `*` (within a path component), `**` (any number of components), and `?` (single character). Example: \"src/**/*.rs\""
178 },
179 "path": {
180 "type": "string",
181 "description": "Optional workspace-relative subdirectory to scope the search. Defaults to the workspace root."
182 }
183 },
184 "required": ["pattern"]
185 }),
186 }
187 }
188
189 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
190 crate::tools::ToolSideEffect::ReadOnly
191 }
192
193 fn kind(&self) -> ToolKind {
194 ToolKind::Search
195 }
196
197 async fn execute(&self, args: Value) -> Result<String> {
198 let pattern = args["pattern"].as_str().ok_or_else(|| Error::BadToolArgs {
199 name: "Glob".into(),
200 message: "missing `pattern`".into(),
201 })?;
202
203 if pattern.is_empty() {
204 return Err(Error::BadToolArgs {
205 name: "Glob".into(),
206 message: "`pattern` must not be empty".into(),
207 });
208 }
209
210 let scope = match args.get("path").and_then(|v| v.as_str()) {
211 Some(p) => {
212 resolve_within_any(&self.all_roots(), p, false).map_err(|e| Error::BadToolArgs {
213 name: "Glob".into(),
214 message: format!("path: {e}"),
215 })?
216 }
217 None => self.root.clone(),
218 };
219
220 let mut matches: Vec<String> = Vec::new();
221
222 for entry in WalkDir::new(&scope)
223 .into_iter()
224 .filter_map(|e| e.ok())
225 .filter(|e| e.file_type().is_file())
226 {
227 let rel = self.relativise(entry.path());
228
229 if glob_matches(pattern, &rel) {
230 matches.push(rel);
231 if matches.len() >= MAX_RESULTS {
232 break;
233 }
234 }
235 }
236
237 matches.sort();
238
239 if matches.is_empty() {
240 Ok(format!("no files matching `{pattern}`"))
241 } else {
242 Ok(matches.join("\n"))
243 }
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use tempfile::TempDir;
251
252 fn create_files(dir: &TempDir, names: &[&str]) {
253 for name in names {
254 let path = dir.path().join(name);
255 if let Some(parent) = path.parent() {
256 std::fs::create_dir_all(parent).unwrap();
257 }
258 std::fs::write(path, "x").unwrap();
259 }
260 }
261
262 #[test]
263 fn glob_matches_extension_wildcard() {
264 assert!(glob_matches("**/*.rs", "src/lib.rs"));
265 assert!(glob_matches("**/*.rs", "src/tools/mod.rs"));
266 assert!(!glob_matches("**/*.rs", "src/lib.txt"));
267 }
268
269 #[test]
270 fn glob_matches_single_star() {
271 assert!(glob_matches("src/*.rs", "src/lib.rs"));
272 assert!(!glob_matches("src/*.rs", "src/tools/mod.rs")); }
274
275 #[test]
276 fn glob_matches_question_mark() {
277 assert!(glob_matches("src/?.rs", "src/a.rs"));
278 assert!(!glob_matches("src/?.rs", "src/ab.rs"));
279 }
280
281 #[test]
282 fn glob_matches_double_star_zero_components() {
283 assert!(glob_matches("**", "foo.rs"));
285 assert!(glob_matches("**/*.rs", "lib.rs"));
286 }
287
288 #[tokio::test]
289 async fn finds_rs_files_by_extension() {
290 let tmp = TempDir::new().unwrap();
291 create_files(&tmp, &["src/a.rs", "src/b.rs", "src/c.txt"]);
292 let tool = GlobTool::new(tmp.path());
293 let out = tool.execute(json!({"pattern": "**/*.rs"})).await.unwrap();
294 assert!(out.contains("a.rs"));
295 assert!(out.contains("b.rs"));
296 assert!(!out.contains("c.txt"));
297 }
298
299 #[tokio::test]
300 async fn scope_by_path_restricts_results() {
301 let tmp = TempDir::new().unwrap();
302 create_files(&tmp, &["src/a.rs", "tests/b.rs"]);
303 let tool = GlobTool::new(tmp.path());
304 let out = tool
305 .execute(json!({"pattern": "**/*.rs", "path": "src"}))
306 .await
307 .unwrap();
308 assert!(out.contains("a.rs"));
309 assert!(!out.contains("b.rs"));
310 }
311
312 #[tokio::test]
313 async fn no_matches_returns_informative_message() {
314 let tmp = TempDir::new().unwrap();
315 create_files(&tmp, &["foo.txt"]);
316 let tool = GlobTool::new(tmp.path());
317 let out = tool.execute(json!({"pattern": "**/*.rs"})).await.unwrap();
318 assert!(out.contains("no files matching"));
319 }
320
321 #[tokio::test]
322 async fn cap_at_200_results() {
323 let tmp = TempDir::new().unwrap();
324 for i in 0..210usize {
326 std::fs::write(tmp.path().join(format!("f{i}.rs")), "x").unwrap();
327 }
328 let tool = GlobTool::new(tmp.path());
329 let out = tool.execute(json!({"pattern": "*.rs"})).await.unwrap();
330 assert_eq!(out.lines().count(), 200);
331 }
332
333 #[tokio::test]
334 async fn empty_pattern_returns_error() {
335 let tmp = TempDir::new().unwrap();
336 let tool = GlobTool::new(tmp.path());
337 let err = tool.execute(json!({"pattern": ""})).await.unwrap_err();
338 assert!(matches!(err, crate::error::Error::BadToolArgs { .. }));
339 }
340
341 #[test]
344 fn match_segment_exact_match_true() {
345 assert!(match_segment("foo", "foo"), "exact match must be true");
347 assert!(
348 match_segment("", ""),
349 "empty vs empty must be true (None,None arm)"
350 );
351 }
352
353 #[test]
354 fn match_segment_literal_mismatch_false() {
355 assert!(
357 !match_segment("foo", "bar"),
358 "literal mismatch must be false"
359 );
360 assert!(
361 !match_segment("a", "b"),
362 "single-char mismatch must be false"
363 );
364 }
365
366 #[test]
367 fn match_segment_star_matches_multi_chars() {
368 assert!(
370 match_segment("*", "anything"),
371 "* must match non-empty text"
372 );
373 assert!(
374 match_segment("*", ""),
375 "* must match empty text (zero chars)"
376 );
377 assert!(match_segment("*.rs", "lib.rs"), "*.rs must match lib.rs");
378 assert!(
379 !match_segment("*.rs", "lib.txt"),
380 "*.rs must not match lib.txt"
381 );
382 }
383
384 #[test]
385 fn match_segment_star_consume_chars_delete_not() {
386 assert!(match_segment("*a", "ba"), "*a must match 'ba'");
391 assert!(match_segment("*a", "xxa"), "*a must match 'xxa'");
392 assert!(!match_segment("*a", "bx"), "*a must not match 'bx'");
393 }
394
395 #[test]
396 fn match_segment_question_marks() {
397 assert!(match_segment("?", "a"), "? must match exactly one char");
399 assert!(!match_segment("?", ""), "? must not match empty text");
400 assert!(!match_segment("?", "ab"), "? must not match two chars");
401 assert!(match_segment("a?c", "abc"), "a?c must match 'abc'");
402 }
403
404 #[test]
405 fn match_segment_guard_pc_eq_tc() {
406 assert!(!match_segment("a", "b"), "non-equal chars must not match");
409 assert!(match_segment("az", "az"), "equal chars must match");
410 }
411
412 #[test]
415 fn glob_matches_exact_literal_path() {
416 assert!(
418 glob_matches("src/lib.rs", "src/lib.rs"),
419 "exact path must match"
420 );
421 }
422
423 #[test]
424 fn glob_matches_literal_mismatch_false() {
425 assert!(
427 !glob_matches("src/foo.rs", "src/bar.rs"),
428 "different filename must not match"
429 );
430 }
431
432 #[test]
433 fn match_path_extra_component_false() {
434 assert!(
436 !glob_matches("src/lib.rs", "src/lib.rs/extra"),
437 "exhausted pattern with trailing component must be false"
438 );
439 assert!(
441 !glob_matches("src/lib.rs/extra", "src/lib.rs"),
442 "trailing pattern component must be false"
443 );
444 }
445
446 #[test]
447 fn glob_matches_double_star_consumes_components() {
448 assert!(
451 glob_matches("**/*.rs", "a/b/c/lib.rs"),
452 "** must consume multiple components"
453 );
454 assert!(
455 !glob_matches("**/*.rs", "a/b/c/lib.txt"),
456 "** must not match wrong extension"
457 );
458 }
459
460 #[test]
463 fn relativise_strips_workspace_root() {
464 let tmp = TempDir::new().unwrap();
465 let tool = GlobTool::new(tmp.path());
466 let abs = tmp.path().join("src/lib.rs");
467 let rel = tool.relativise(&abs);
468 assert_eq!(rel, "src/lib.rs", "relativise must strip workspace root");
469 assert!(
470 !rel.starts_with(tmp.path().to_str().unwrap()),
471 "result must not be absolute for workspace files"
472 );
473 }
474
475 #[tokio::test]
478 async fn scope_path_uses_all_roots_correctly() {
479 let tmp = TempDir::new().unwrap();
480 create_files(&tmp, &["sub/a.rs", "sub/b.txt"]);
481 let tool = GlobTool::new(tmp.path());
482 let out = tool
484 .execute(json!({"pattern": "**/*.rs", "path": "sub"}))
485 .await
486 .unwrap();
487 assert!(out.contains("a.rs"), "sub/a.rs must appear in results");
488 assert!(!out.contains("b.txt"), "b.txt must be filtered by pattern");
489 }
490}