1use anyhow::Result;
2use async_trait::async_trait;
3use serde::Deserialize;
4use serde_json::json;
5
6use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
7
8pub struct GlobTool;
9
10#[derive(Deserialize)]
11struct GlobArgs {
12 pattern: String,
13 path: Option<String>,
14}
15
16#[async_trait]
17impl Tool for GlobTool {
18 fn definition(&self) -> ToolDef {
19 ToolDef {
20 name: "glob",
21 description: "Find files by name pattern. Returns matching file paths.\n\
22 Use this when you need to find files by name or extension, NOT by content (use grep for content search).\n\
23 Pattern examples:\n\
24 - All Rust files: \"**/*.rs\"\n\
25 - Vue files in views: \"src/views/**/*.vue\"\n\
26 - Specific filename anywhere: \"**/config.ts\"\n\
27 - All files in a folder: \"src/components/*\"\n\
28 Common use cases:\n\
29 - Find all view/page files before deciding which to edit.\n\
30 - Find config or entry files in an unfamiliar project.\n\
31 - Check what files exist in a directory.".to_string(),
32 parameters: json!({
33 "type": "object",
34 "properties": {
35 "pattern": { "type": "string", "description": "Glob pattern (e.g. **/*.rs, src/**/*.ts)" },
36 "path": { "type": "string", "description": "Base directory (default: working directory)" }
37 },
38 "required": ["pattern"]
39 }),
40 }
41 }
42
43 fn approval(&self, _args: &str) -> ApprovalRequirement {
44 ApprovalRequirement::AutoApprove
45 }
46
47 fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
48 let parsed = match serde_json::from_str::<GlobArgs>(args) {
49 Ok(parsed) => parsed,
50 Err(_) => return self.approval(args),
51 };
52 let working_dir = match ctx.working_dir.try_read() {
53 Ok(wd) => wd.clone(),
54 Err(_) => return self.approval(args),
55 };
56 let base_dir =
57 match super::inspect_path_access(parsed.path.as_deref().unwrap_or("."), &working_dir) {
58 Ok(access) => access.path.to_string_lossy().to_string(),
59 Err(_) => return self.approval(args),
60 };
61 let search_dir = derive_search_dir(&base_dir, &parsed.pattern);
62 match super::approval_for_path(
63 &search_dir,
64 &working_dir,
65 super::ExternalPathAction::Enumerate,
66 ) {
67 Ok(approval) => approval,
68 Err(_) => self.approval(args),
69 }
70 }
71
72 async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
73 let parsed: GlobArgs = serde_json::from_str(args)?;
74 let wd = ctx.working_dir.read().await.clone();
75 let base_dir = match super::inspect_path_access(parsed.path.as_deref().unwrap_or("."), &wd)
76 {
77 Ok(access) => access.path.to_string_lossy().to_string(),
78 Err(err) => {
79 return Ok(ToolResult {
80 call_id: String::new(),
81 output: err.to_string(),
82 success: false,
83 });
84 }
85 };
86
87 let search_dir = derive_search_dir(&base_dir, &parsed.pattern);
96 let name_pattern = derive_name_pattern(&parsed.pattern);
97
98 if !std::path::Path::new(&search_dir).is_dir() {
105 let target_basename = std::path::Path::new(&search_dir)
106 .file_name()
107 .map(|n| n.to_string_lossy().to_string())
108 .unwrap_or_default();
109 let mut dir_matches: Vec<String> = Vec::new();
110 if !target_basename.is_empty() {
111 fn find_dir(
112 dir: &std::path::Path,
113 target: &str,
114 depth: usize,
115 max_depth: usize,
116 results: &mut Vec<String>,
117 ) {
118 if depth > max_depth || results.len() >= 20 {
119 return;
120 }
121 if let Ok(entries) = std::fs::read_dir(dir) {
122 for entry in entries.flatten() {
123 let name = entry.file_name().to_string_lossy().to_string();
124 if name.starts_with('.') || super::should_skip_dir(&name) {
125 continue;
126 }
127 let p = entry.path();
128 if p.is_dir() {
129 if name == target {
130 results.push(p.to_string_lossy().to_string());
131 }
132 find_dir(&p, target, depth + 1, max_depth, results);
133 }
134 }
135 }
136 }
137 find_dir(
138 std::path::Path::new(&wd),
139 &target_basename,
140 0,
141 5,
142 &mut dir_matches,
143 );
144 }
145 let hint = if dir_matches.is_empty() {
146 String::new()
147 } else {
148 dir_matches
149 .sort_by_key(|d| std::cmp::Reverse(super::shared_prefix_len(&search_dir, d)));
150 let shown: Vec<String> = dir_matches
151 .iter()
152 .take(3)
153 .map(|d| format!(" {}", d))
154 .collect();
155 format!(
156 "\n\nSimilar directories found — did you mean one of these?\n{}",
157 shown.join("\n")
158 )
159 };
160 return Ok(ToolResult {
161 call_id: String::new(),
162 output: format!(
163 "No files matching '{}' (directory '{}' does not exist){}",
164 parsed.pattern, search_dir, hint
165 ),
166 success: true,
167 });
168 }
169
170 let mut files: Vec<String> = Vec::new();
179 let search_path = std::path::Path::new(&search_dir);
180 let walker = ignore::WalkBuilder::new(search_path)
181 .hidden(true)
182 .git_ignore(true)
183 .git_global(true)
184 .git_exclude(true)
185 .build();
186
187 for entry in walker.flatten() {
188 let path = entry.path();
189 if !path.is_file() {
191 continue;
192 }
193 if should_skip_path(path) {
198 continue;
199 }
200 if let Some(file_name) = path.file_name() {
201 let name = file_name.to_string_lossy();
202 if simple_glob_match(&name, &name_pattern) {
203 files.push(path.to_string_lossy().to_string());
204 }
205 }
206 }
207 files.sort();
208
209 let result = if files.is_empty() {
210 format!("No files matching '{}'", parsed.pattern)
211 } else {
212 let total = files.len();
213 let shown: Vec<&str> = files.iter().take(100).map(|s| s.as_str()).collect();
214
215 let mut out = shown.join("\n");
216 if total > 100 {
217 out.push_str(&format!("\n\n[{} more files not shown]", total - 100));
218 }
219 format!("{} files found:\n{}", total, out)
220 };
221
222 Ok(ToolResult {
223 call_id: String::new(),
224 output: result,
225 success: true,
226 })
227 }
228}
229
230fn should_skip_path(path: &std::path::Path) -> bool {
235 for component in path.components() {
236 if let std::path::Component::Normal(os_name) = component {
237 let name = os_name.to_string_lossy();
238 if super::should_skip_dir(&name) {
239 return true;
240 }
241 }
242 }
243 false
244}
245
246fn simple_glob_match(name: &str, pattern: &str) -> bool {
257 let pat_parts: Vec<&str> = pattern.split('*').collect();
258
259 if pat_parts.len() == 1 {
261 return name == pattern;
262 }
263
264 let leading_wildcard = pattern.starts_with('*');
266 let trailing_wildcard = pattern.ends_with('*');
268
269 let mut rest = name;
270
271 for (i, part) in pat_parts.iter().enumerate() {
272 if part.is_empty() {
273 continue;
274 }
275
276 match rest.find(part) {
277 Some(pos) => {
278 if i == 0 && !leading_wildcard && pos != 0 {
280 return false;
281 }
282 rest = &rest[pos + part.len()..];
283 }
284 None => return false,
285 }
286 }
287
288 if let Some(last) = pat_parts.last() {
290 if !last.is_empty() && !trailing_wildcard && !name.ends_with(last) {
291 return false;
292 }
293 }
294
295 true
296}
297
298fn derive_search_dir(base_dir: &str, pattern: &str) -> String {
299 if let Some(star_pos) = pattern.find("**/") {
300 let dir_part = pattern[..star_pos].trim_end_matches('/');
301 if dir_part.is_empty() {
302 base_dir.to_string()
303 } else if std::path::Path::new(dir_part).is_absolute() {
304 dir_part.to_string()
305 } else {
306 std::path::Path::new(base_dir)
307 .join(dir_part)
308 .to_string_lossy()
309 .to_string()
310 }
311 } else if let Some(last_slash) = pattern.rfind('/') {
312 let dir_part = &pattern[..last_slash];
313 if std::path::Path::new(dir_part).is_absolute() {
314 dir_part.to_string()
315 } else {
316 std::path::Path::new(base_dir)
317 .join(dir_part)
318 .to_string_lossy()
319 .to_string()
320 }
321 } else {
322 base_dir.to_string()
323 }
324}
325
326fn derive_name_pattern(pattern: &str) -> String {
327 if let Some(star_pos) = pattern.find("**/") {
328 let after_stars = &pattern[star_pos + 3..];
329 after_stars
330 .rsplit('/')
331 .next()
332 .unwrap_or(after_stars)
333 .to_string()
334 } else if let Some(last_slash) = pattern.rfind('/') {
335 pattern[last_slash + 1..].to_string()
336 } else {
337 pattern.to_string()
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::tool::ToolContext;
345 use std::path::{Path, PathBuf};
346 use tempfile::TempDir;
347
348 #[tokio::test]
354 async fn glob_suggests_similar_directory_when_search_dir_missing() {
355 let dir = TempDir::new().unwrap();
356 std::fs::create_dir_all(dir.path().join("hermes/presentation")).unwrap();
358 std::fs::create_dir_all(dir.path().join("other/presentation")).unwrap();
359 std::fs::write(
360 dir.path().join("hermes/presentation/app.vue"),
361 "<template></template>",
362 )
363 .unwrap();
364
365 let ctx = ToolContext::new(dir.path().to_path_buf());
366 let tool = GlobTool;
367 let wrong = dir.path().join("hermes/frontend/presentation");
370 let args = format!(r#"{{"pattern":"{}/**/*.vue"}}"#, wrong.display());
371
372 let r = tool.execute(&args, &ctx).await.unwrap();
373 assert!(r.success);
374 assert!(
375 r.output.contains("does not exist"),
376 "missing exists-check msg: {}",
377 r.output
378 );
379 assert!(
380 r.output.contains("Similar directories found"),
381 "must suggest similar directories: {}",
382 r.output
383 );
384 let hermes_pos = r.output.find("hermes/presentation").unwrap();
388 let other_pos = r.output.find("other/presentation").unwrap();
389 assert!(
390 hermes_pos < other_pos,
391 "hermes/presentation must outrank other/presentation. output:\n{}",
392 r.output
393 );
394 }
395
396 #[tokio::test]
397 async fn glob_existing_dir_does_not_trigger_hint() {
398 let dir = TempDir::new().unwrap();
399 std::fs::create_dir_all(dir.path().join("src")).unwrap();
400 std::fs::write(dir.path().join("src/a.ts"), "export {};").unwrap();
401
402 let ctx = ToolContext::new(dir.path().to_path_buf());
403 let tool = GlobTool;
404 let args = format!(
405 r#"{{"pattern":"{}/**/*.ts"}}"#,
406 dir.path().join("src").display()
407 );
408
409 let r = tool.execute(&args, &ctx).await.unwrap();
410 assert!(r.success);
411 assert!(
412 !r.output.contains("Similar directories found"),
413 "no hint should fire when dir exists: {}",
414 r.output
415 );
416 }
417
418 #[test]
441 fn derive_name_pattern_chinese_filename() {
442 assert_eq!(derive_name_pattern("*.txt"), "*.txt");
443 assert_eq!(derive_name_pattern("**/*.txt"), "*.txt");
444 assert_eq!(derive_name_pattern("**/测试.txt"), "测试.txt");
445 assert_eq!(derive_name_pattern("src/**/配置.json"), "配置.json");
446 assert_eq!(derive_name_pattern("数据/**/*.csv"), "*.csv");
447 assert_eq!(derive_name_pattern("测试.txt"), "测试.txt");
448 }
449
450 #[test]
451 fn derive_search_dir_chinese_path() {
452 let base = "/tmp/workspace";
453 assert_eq!(derive_search_dir(base, "**/*.txt"), base);
454 assert_eq!(derive_search_dir(base, "测试/**/*.txt"), format!("{}/测试", base));
455 assert_eq!(
456 derive_search_dir(base, "项目/模块/**/*.rs"),
457 format!("{}/项目/模块", base)
458 );
459 }
460
461 #[test]
462 fn derive_name_pattern_mixed_chinese_english() {
463 assert_eq!(derive_name_pattern("**/report-报告.txt"), "report-报告.txt");
464 assert_eq!(derive_name_pattern("**/测试_test.py"), "测试_test.py");
465 assert_eq!(derive_name_pattern("src/组件/**/Button*.vue"), "Button*.vue");
466 }
467
468 #[test]
469 fn derive_name_pattern_chinese_with_star() {
470 assert_eq!(derive_name_pattern("**/*测试*"), "*测试*");
471 assert_eq!(derive_name_pattern("**/测试*.log"), "测试*.log");
472 assert_eq!(derive_name_pattern("**/*.配置"), "*.配置");
473 }
474
475 #[test]
476 fn derive_name_pattern_chinese_edge_cases() {
477 assert_eq!(derive_name_pattern("中文/路径/**/文件.rs"), "文件.rs");
478 assert_eq!(derive_name_pattern("**/*配置*"), "*配置*");
479 assert_eq!(derive_name_pattern("模块/**/index.ts"), "index.ts");
480 assert_eq!(derive_name_pattern("项目/源码/核心/**/*.rs"), "*.rs");
481 }
482
483 #[test]
484 fn derive_search_dir_chinese_edge_cases() {
485 let base = "/home/user/project";
486 assert_eq!(
487 derive_search_dir(base, "模块/**/index.ts"),
488 format!("{}/模块", base)
489 );
490 assert_eq!(
491 derive_search_dir(base, "项目/源码/核心/**/*.rs"),
492 format!("{}/项目/源码/核心", base)
493 );
494 assert_eq!(
495 derive_search_dir(base, "/绝对/路径/**/*.java"),
496 "/绝对/路径"
497 );
498 }
499
500 #[test]
503 fn test_simple_glob_match_star_extension() {
504 assert!(simple_glob_match("测试.txt", "*.txt"));
505 assert!(simple_glob_match("test.txt", "*.txt"));
506 assert!(simple_glob_match("hello.rs", "*.rs"));
507 assert!(!simple_glob_match("hello.md", "*.txt"));
508 }
509
510 #[test]
511 fn test_simple_glob_match_exact_name() {
512 assert!(simple_glob_match("测试.txt", "测试.txt"));
513 assert!(simple_glob_match("test.txt", "test.txt"));
514 assert!(!simple_glob_match("其他.txt", "测试.txt"));
515 }
516
517 #[test]
518 fn test_simple_glob_match_prefix_star() {
519 assert!(simple_glob_match("report-报告.txt", "*报告.txt"));
520 assert!(simple_glob_match("最终报告.txt", "*报告.txt"));
521 assert!(!simple_glob_match("report.txt", "*报告.txt"));
522 }
523
524 #[test]
525 fn test_simple_glob_match_middle_star() {
526 assert!(simple_glob_match("测试_test.py", "测试*.py"));
527 assert!(simple_glob_match("测试v2_test.py", "测试*.py"));
528 assert!(!simple_glob_match("test.py", "测试*.py"));
529 }
530
531 #[test]
532 fn test_simple_glob_match_star_only() {
533 assert!(simple_glob_match("anything.txt", "*"));
534 assert!(simple_glob_match("测试.txt", "*"));
535 }
536
537 #[test]
538 fn test_simple_glob_match_trailing_star() {
539 assert!(simple_glob_match("测试_file.txt", "测试*"));
540 assert!(simple_glob_match("测试", "测试*"));
541 assert!(!simple_glob_match("other.txt", "测试*"));
542 }
543
544 #[test]
545 fn test_simple_glob_match_multiple_stars() {
546 assert!(simple_glob_match("我的测试文件.txt", "*测试*"));
548 assert!(simple_glob_match("测试.txt", "*测试*"));
549 assert!(simple_glob_match("abc测试def", "*测试*"));
550 assert!(!simple_glob_match("abc.txt", "*测试*"));
551 }
552
553 #[test]
556 fn test_should_skip_path_node_modules() {
557 assert!(should_skip_path(std::path::Path::new("/project/node_modules/pkg/file.js")));
558 }
559
560 #[test]
561 fn test_should_skip_path_git() {
562 assert!(should_skip_path(std::path::Path::new("/project/.git/HEAD")));
563 }
564
565 #[test]
566 fn test_should_skip_path_target() {
567 assert!(should_skip_path(std::path::Path::new("/project/target/debug/app")));
568 }
569
570 #[test]
571 fn test_should_skip_path_normal() {
572 assert!(!should_skip_path(std::path::Path::new("/project/src/main.rs")));
573 assert!(!should_skip_path(std::path::Path::new("/project/测试.txt")));
574 }
575
576 #[test]
577 fn test_should_skip_path_venv_prefix() {
578 assert!(should_skip_path(std::path::Path::new("/project/.venv-test/lib/python.py")));
579 }
580
581 #[tokio::test]
585 async fn glob_finds_chinese_filename() {
586 let dir = TempDir::new().unwrap();
588 std::fs::write(dir.path().join("test.txt"), "english file").unwrap();
589 std::fs::write(dir.path().join("测试.txt"), "chinese file").unwrap();
590 std::fs::write(dir.path().join("README.md"), "readme").unwrap();
591
592 let ctx = ToolContext::new(dir.path().to_path_buf());
593 let tool = GlobTool;
594 let args = r#"{"pattern":"*.txt"}"#;
595
596 let r = tool.execute(args, &ctx).await.unwrap();
597 assert!(r.success, "glob should succeed: {}", r.output);
598 assert!(
599 r.output.contains("测试.txt"),
600 "glob must find Chinese-named file '测试.txt'. output: {}",
601 r.output
602 );
603 assert!(
604 r.output.contains("test.txt"),
605 "glob must find English-named file 'test.txt'. output: {}",
606 r.output
607 );
608 }
609
610 #[tokio::test]
611 async fn glob_finds_chinese_filename_recursive() {
612 let dir = TempDir::new().unwrap();
614 std::fs::create_dir_all(dir.path().join("源码")).unwrap();
615 std::fs::write(dir.path().join("源码/主程序.rs"), "fn main() {}").unwrap();
616 std::fs::write(dir.path().join("源码/utils.rs"), "pub fn helper() {}").unwrap();
617
618 let ctx = ToolContext::new(dir.path().to_path_buf());
619 let tool = GlobTool;
620 let args = r#"{"pattern":"**/*.rs"}"#;
621
622 let r = tool.execute(args, &ctx).await.unwrap();
623 assert!(r.success, "glob should succeed: {}", r.output);
624 assert!(
625 r.output.contains("主程序.rs"),
626 "glob must find Chinese-named Rust file '主程序.rs'. output: {}",
627 r.output
628 );
629 assert!(
630 r.output.contains("utils.rs"),
631 "glob must find English-named Rust file 'utils.rs'. output: {}",
632 r.output
633 );
634 }
635
636 #[tokio::test]
637 async fn glob_finds_files_under_chinese_directory() {
638 let dir = TempDir::new().unwrap();
640 std::fs::create_dir_all(dir.path().join("组件")).unwrap();
641 std::fs::create_dir_all(dir.path().join("components")).unwrap();
642 std::fs::write(dir.path().join("组件/按钮.vue"), "<template></template>").unwrap();
643 std::fs::write(dir.path().join("components/Button.vue"), "<template></template>").unwrap();
644
645 let ctx = ToolContext::new(dir.path().to_path_buf());
646 let tool = GlobTool;
647 let args = r#"{"pattern":"**/*.vue"}"#;
648
649 let r = tool.execute(args, &ctx).await.unwrap();
650 assert!(r.success, "glob should succeed: {}", r.output);
651 assert!(
652 r.output.contains("按钮.vue"),
653 "glob must find file under Chinese directory. output: {}",
654 r.output
655 );
656 assert!(
657 r.output.contains("Button.vue"),
658 "glob must find file under English directory. output: {}",
659 r.output
660 );
661 }
662
663 #[tokio::test]
664 async fn glob_pattern_with_chinese_name() {
665 let dir = TempDir::new().unwrap();
667 std::fs::write(dir.path().join("测试.txt"), "chinese content").unwrap();
668 std::fs::write(dir.path().join("test.txt"), "english content").unwrap();
669
670 let ctx = ToolContext::new(dir.path().to_path_buf());
671 let tool = GlobTool;
672 let args = r#"{"pattern":"**/测试.txt"}"#;
673
674 let r = tool.execute(args, &ctx).await.unwrap();
675 assert!(r.success, "glob should succeed: {}", r.output);
676 assert!(
677 r.output.contains("测试.txt"),
678 "glob must find '测试.txt' when searching for it by name. output: {}",
679 r.output
680 );
681 }
682
683 #[tokio::test]
684 async fn glob_chinese_subdir_pattern() {
685 let dir = TempDir::new().unwrap();
687 std::fs::create_dir_all(dir.path().join("文档")).unwrap();
688 std::fs::create_dir_all(dir.path().join("docs")).unwrap();
689 std::fs::write(dir.path().join("文档/说明.md"), "# 说明").unwrap();
690 std::fs::write(dir.path().join("文档/指南.md"), "# 指南").unwrap();
691 std::fs::write(dir.path().join("docs/README.md"), "# README").unwrap();
692
693 let ctx = ToolContext::new(dir.path().to_path_buf());
694 let tool = GlobTool;
695 let args = r#"{"pattern":"文档/**/*.md"}"#;
696
697 let r = tool.execute(args, &ctx).await.unwrap();
698 assert!(r.success, "glob should succeed: {}", r.output);
699 assert!(
700 r.output.contains("说明.md"),
701 "glob must find '说明.md' under '文档' dir. output: {}",
702 r.output
703 );
704 assert!(
705 r.output.contains("指南.md"),
706 "glob must find '指南.md' under '文档' dir. output: {}",
707 r.output
708 );
709 assert!(
710 !r.output.contains("README.md"),
711 "glob must NOT find files from 'docs' dir when pattern is '文档/**/*.md'. output: {}",
712 r.output
713 );
714 }
715
716 #[tokio::test]
721 async fn glob_finds_chinese_file_in_chinese_subdirectory() {
722 let dir = TempDir::new().unwrap();
725 let subdir_name = "TE微型直线导轨 滑块标准型[TE7C1R-129-G]";
726 std::fs::create_dir_all(dir.path().join(subdir_name)).unwrap();
727 std::fs::write(
728 dir.path().join(format!("{}/测试.txt", subdir_name)),
729 "test content",
730 )
731 .unwrap();
732
733 let ctx = ToolContext::new(dir.path().to_path_buf());
734 let tool = GlobTool;
735
736 let r = tool.execute(r#"{"pattern":"**/*.txt"}"#, &ctx).await.unwrap();
738 assert!(r.success, "glob should succeed: {}", r.output);
739 assert!(
740 r.output.contains("测试.txt"),
741 "glob must find '测试.txt' in Chinese subdirectory. output: {}",
742 r.output
743 );
744
745 let r = tool.execute(r#"{"pattern":"**/测试.txt"}"#, &ctx).await.unwrap();
747 assert!(r.success, "glob should succeed: {}", r.output);
748 assert!(
749 r.output.contains("测试.txt"),
750 "glob must find '测试.txt' by exact Chinese name. output: {}",
751 r.output
752 );
753 }
754
755 #[tokio::test]
756 async fn glob_non_recursive_pattern_finds_root_files_only() {
757 let dir = TempDir::new().unwrap();
759 std::fs::write(dir.path().join("test.txt"), "root file").unwrap();
760 std::fs::create_dir_all(dir.path().join("子目录")).unwrap();
761 std::fs::write(dir.path().join("子目录/测试.txt"), "nested file").unwrap();
762
763 let ctx = ToolContext::new(dir.path().to_path_buf());
764 let tool = GlobTool;
765
766 let r = tool.execute(r#"{"pattern":"*.txt"}"#, &ctx).await.unwrap();
771 assert!(r.success, "glob should succeed: {}", r.output);
772 assert!(
773 r.output.contains("test.txt"),
774 "glob must find 'test.txt' in root. output: {}",
775 r.output
776 );
777 assert!(
780 r.output.contains("测试.txt"),
781 "glob must find '测试.txt' in subdirectory. output: {}",
782 r.output
783 );
784 }
785
786 #[tokio::test]
787 async fn glob_mixed_chinese_english_filenames() {
788 let dir = TempDir::new().unwrap();
790 std::fs::write(dir.path().join("index.ts"), "export {};").unwrap();
791 std::fs::write(dir.path().join("索引.ts"), "export {};").unwrap();
792 std::fs::write(dir.path().join("app.config.js"), "module.exports = {};").unwrap();
793 std::fs::write(dir.path().join("配置.js"), "module.exports = {};").unwrap();
794 std::fs::write(dir.path().join("utils-helper.py"), "def help(): pass").unwrap();
795 std::fs::write(dir.path().join("工具_辅助.py"), "def help(): pass").unwrap();
796
797 let ctx = ToolContext::new(dir.path().to_path_buf());
798 let tool = GlobTool;
799
800 let r = tool.execute(r#"{"pattern":"*.ts"}"#, &ctx).await.unwrap();
801 assert!(r.output.contains("索引.ts"), "must find '索引.ts': {}", r.output);
802 assert!(r.output.contains("index.ts"), "must find 'index.ts': {}", r.output);
803
804 let r = tool.execute(r#"{"pattern":"*.py"}"#, &ctx).await.unwrap();
805 assert!(r.output.contains("工具_辅助.py"), "must find '工具_辅助.py': {}", r.output);
806 assert!(r.output.contains("utils-helper.py"), "must find 'utils-helper.py': {}", r.output);
807 }
808
809 #[tokio::test]
810 async fn glob_unicode_filenames_japanese_korean_emoji() {
811 let dir = TempDir::new().unwrap();
813 std::fs::write(dir.path().join("テスト.txt"), "japanese").unwrap();
814 std::fs::write(dir.path().join("테스트.txt"), "korean").unwrap();
815 std::fs::write(dir.path().join("🎉party.txt"), "emoji").unwrap();
816
817 let ctx = ToolContext::new(dir.path().to_path_buf());
818 let tool = GlobTool;
819 let r = tool.execute(r#"{"pattern":"*.txt"}"#, &ctx).await.unwrap();
820
821 assert!(r.output.contains("テスト.txt"), "must find Japanese filename: {}", r.output);
822 assert!(r.output.contains("테스트.txt"), "must find Korean filename: {}", r.output);
823 assert!(r.output.contains("🎉party.txt"), "must find emoji filename: {}", r.output);
824 }
825
826 #[test]
829 fn glob_uses_ignore_crate_not_find_command() {
830 let source = include_str!("glob.rs");
837 assert!(
838 source.contains("ignore::WalkBuilder"),
839 "glob.rs must use `ignore::WalkBuilder` for cross-platform file search (Issue #350)."
840 );
841 }
842
843 #[tokio::test]
846 async fn glob_skips_node_modules_and_target() {
847 let dir = TempDir::new().unwrap();
848 std::fs::create_dir_all(dir.path().join("src")).unwrap();
849 std::fs::create_dir_all(dir.path().join("node_modules/pkg")).unwrap();
850 std::fs::create_dir_all(dir.path().join("target/debug")).unwrap();
851 std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
852 std::fs::write(dir.path().join("node_modules/pkg/index.js"), "export {};").unwrap();
853 std::fs::write(dir.path().join("target/debug/output.txt"), "binary").unwrap();
855
856 let ctx = ToolContext::new(dir.path().to_path_buf());
857 let tool = GlobTool;
858 let r = tool.execute(r#"{"pattern":"**/*"}"#, &ctx).await.unwrap();
859
860 assert!(
861 r.output.contains("main.rs"),
862 "must find source files. output: {}",
863 r.output
864 );
865 assert!(
866 !r.output.contains("node_modules"),
867 "must skip node_modules. output: {}",
868 r.output
869 );
870 assert!(
871 !r.output.contains("target"),
872 "must skip target. output: {}",
873 r.output
874 );
875 }
876}