Skip to main content

atomcode_core/tool/
glob.rs

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        // Parse pattern: split into (search_dir, name_pattern).
88        // Handles all forms:
89        //   "**/*.java"                          → (base_dir, "*.java")
90        //   "src/views/**/*.vue"                 → (base_dir/src/views, "*.vue")
91        //   "/absolute/path/**/*.java"           → (/absolute/path, "*.java")
92        //   "/absolute/path/**/*Auth*.java"      → (/absolute/path, "*Auth*.java")
93        //   "*.vue"                              → (base_dir, "*.vue")
94        //   "**/config.ts"                       → (base_dir, "config.ts")
95        let search_dir = derive_search_dir(&base_dir, &parsed.pattern);
96        let name_pattern = derive_name_pattern(&parsed.pattern);
97
98        // Verify search directory exists. If not, walk the workspace to find
99        // directories with the same basename so the agent can self-correct
100        // without a round of manual `ls`. 2026-04-22: added for P0 #4 after
101        // 426-atom 2026-04-21 session where agent spent 5 turns listing
102        // directories because `/426-atom/index.html` was actually at
103        // `/426-atom/presentation/index.html`.
104        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        // Use the `ignore` crate (ripgrep's walker) for cross-platform file
171        // search. This replaces the previous `Command::new("find")` approach
172        // which failed on Windows because Windows' `find.exe` is a string-
173        // search utility (like grep), not a file-search utility. It also
174        // correctly handles non-ASCII filenames (Chinese, Japanese, etc.)
175        // on all platforms without encoding issues.
176        //
177        // Issue #350: https://gitcode.com/atomgit_atomcode/atomcode/issues/350
178        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            // Skip directories — we only want files
190            if !path.is_file() {
191                continue;
192            }
193            // Skip directories that should be excluded (node_modules, .git, etc.)
194            // The ignore crate handles .gitignore already, but we also need to
195            // filter out SKIP_DIRS and SKIP_DIR_PREFIXES for consistency with
196            // the rest of the tool suite.
197            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
230/// Check if any path component matches SKIP_DIRS (exact) or SKIP_DIR_PREFIXES
231/// (prefix). Used by the `ignore` crate walker to filter out build artifacts,
232/// caches, VCS directories, etc. — consistent with the skip logic used by
233/// `list_dir`, `grep`, and other tools.
234fn 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
246/// Simple glob match supporting `*` (match any non-separator chars) and
247/// literal characters. This covers the patterns produced by
248/// `derive_name_pattern`:
249///   - `"*.txt"`     → matches any file ending in `.txt`
250///   - `"测试.txt"`  → exact match
251///   - `"*测试*"`    → matches any name containing `测试`
252///   - `"测试*.log"` → matches names starting with `测试` and ending in `.log`
253///
254/// Does NOT support `?`, `[...]`, or `**` — those are resolved upstream by
255/// `derive_name_pattern` which strips directory segments and `**`.
256fn simple_glob_match(name: &str, pattern: &str) -> bool {
257    let pat_parts: Vec<&str> = pattern.split('*').collect();
258
259    // No wildcard — exact match
260    if pat_parts.len() == 1 {
261        return name == pattern;
262    }
263
264    // Leading wildcard means first part can match anywhere
265    let leading_wildcard = pattern.starts_with('*');
266    // Trailing wildcard means last part doesn't need to match the end
267    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                // First literal part must match at the start (unless leading *)
279                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    // Last literal part must match at the end (unless trailing *)
289    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    /// P0 #4: when a glob's search dir doesn't exist, workspace-walk for dirs
349    /// with the same basename and surface top-3 by path-prefix similarity.
350    /// Regression for 426-atom 2026-04-21 session where agent burned 5
351    /// turns of `ls` to locate `/426-atom/presentation/` after asking glob
352    /// under `/426-atom/frontend/` (wrong segment).
353    #[tokio::test]
354    async fn glob_suggests_similar_directory_when_search_dir_missing() {
355        let dir = TempDir::new().unwrap();
356        // Set up a workspace with a `presentation/` dir that agent will miss.
357        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        // Agent asks for `.vue` files under the WRONG path — `hermes/frontend/presentation`
368        // doesn't exist, but `hermes/presentation` does.
369        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        // Both `presentation/` dirs exist under wd; the hermes one shares
385        // more path prefix with what the agent asked for, so it must be
386        // listed first.
387        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    // ========================================================================
419    // Issue #350: Glob 工具在 Windows 环境下对包含中文字符的文件名匹配失效
420    // https://gitcode.com/atomgit_atomcode/atomcode/issues/350
421    //
422    // 根因 (已修复): glob.rs 之前使用 Unix `find` 命令搜索文件。
423    //   Windows 的 `find.exe` 是字符串搜索工具(类似 grep),
424    //   不是文件搜索工具,导致在 Windows 上完全不工作。
425    //   此外,Windows 路径编码 (GBK/UTF-16) 也可能导致中文文件名匹配失败。
426    //
427    // 修复: 使用 `ignore` crate (ripgrep 底层库) 替代 `Command::new("find")`,
428    //   实现跨平台文件搜索,正确处理所有 Unicode 文件名。
429    //
430    // 以下测试覆盖:
431    //   1. derive_search_dir / derive_name_pattern 对中文路径的处理
432    //   2. simple_glob_match 匹配逻辑
433    //   3. 使用 ignore crate 搜索中文文件名(跨平台,含 Windows)
434    //   4. should_skip_path 目录过滤逻辑
435    //   5. 实际 Issue #350 场景: 子目录中的中文文件名 + 中文目录名
436    // ========================================================================
437
438    // --- 纯函数测试: derive_search_dir / derive_name_pattern ---
439
440    #[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    // --- simple_glob_match 单元测试 ---
501
502    #[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        // `*测试*` should match any name containing `测试`
547        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    // --- should_skip_path 单元测试 ---
554
555    #[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    // --- 跨平台中文文件名搜索测试 (使用 ignore crate) ---
582    // 以下测试在所有平台(macOS / Linux / Windows)上均可通过
583
584    #[tokio::test]
585    async fn glob_finds_chinese_filename() {
586        // Issue #350 核心场景: 在工作目录创建中英文文件,glob 应匹配两者
587        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        // 递归搜索子目录中的中文文件名
613        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        // 搜索中文目录名下的文件
639        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        // 搜索指定中文名文件: glob(pattern="**/测试.txt")
666        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        // 搜索指定中文子目录下的文件: glob(pattern="文档/**/*.md")
686        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    // --- Issue #350 实际场景: 子目录中的中文文件名 + 中文目录名 ---
717    // 复现用户报告的 Windows 场景:
718    //   C:\111\TE微型直线导轨 滑块标准型[TE7C1R-129-G]\测试.txt
719
720    #[tokio::test]
721    async fn glob_finds_chinese_file_in_chinese_subdirectory() {
722        // 完整复现 Issue #350 的实际场景:
723        // 文件在包含中文和特殊字符的子目录中
724        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        // 搜索所有 .txt 文件(递归)
737        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        // 搜索指定中文名文件
746        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        // 不带 ** 的 pattern 如 "*.txt" 只搜索 search_dir 根目录的文件
758        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        // "*.txt" → name_pattern="*.txt", search_dir=base_dir
767        // The ignore crate walker is recursive, but the name_pattern "*.txt"
768        // will match any .txt at any depth. This is consistent with how
769        // Unix `find $dir -name "*.txt"` works (also recursive).
770        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        // The ignore walker traverses recursively, so nested .txt files
778        // are also found — this is the same behavior as `find . -name "*.txt"`.
779        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        // 混合中英文文件名的完整场景
789        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        // 扩展测试: 日文、韩文、emoji 文件名
812        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    // --- 回归: 确认不再使用 Windows find.exe ---
827
828    #[test]
829    fn glob_uses_ignore_crate_not_find_command() {
830        // 之前 glob 工具使用 Unix `find` 命令搜索文件,
831        // 在 Windows 上 `find.exe` 是字符串搜索工具(类似 grep),
832        // 导致 Issue #350。现在已替换为 Rust 原生 `ignore` crate,
833        // 在所有平台上行为一致。
834        //
835        // 此测试确认 glob.rs 导入了 ignore crate 并使用了 WalkBuilder。
836        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    // --- skip_dirs 过滤一致性测试 ---
844
845    #[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        // Write a real file to target/debug (not named 'app' which may not exist)
854        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}