llm_coding_tools_core/operations/
glob.rs

1//! Glob pattern file matching operation.
2
3use crate::error::{ToolError, ToolResult};
4use crate::path::PathResolver;
5use globset::Glob;
6use ignore::WalkBuilder;
7use serde::Serialize;
8use std::time::SystemTime;
9
10const MAX_RESULTS: usize = 1000;
11
12/// Output from glob file matching.
13#[derive(Debug, Serialize)]
14pub struct GlobOutput {
15    /// Matched file paths relative to search directory, sorted by mtime (newest first).
16    pub files: Vec<String>,
17    /// Whether results were truncated due to limit.
18    #[serde(skip_serializing_if = "std::ops::Not::not")]
19    pub truncated: bool,
20}
21
22/// Finds files matching a glob pattern in the given directory.
23///
24/// Results are sorted by modification time (newest first) and respect `.gitignore`.
25pub fn glob_files<R: PathResolver>(
26    resolver: &R,
27    pattern: &str,
28    search_path: &str,
29) -> ToolResult<GlobOutput> {
30    let path = resolver.resolve(search_path)?;
31
32    if !path.is_dir() {
33        return Err(ToolError::InvalidPath(format!(
34            "path is not a directory: {}",
35            path.display()
36        )));
37    }
38
39    let matcher = Glob::new(pattern)?.compile_matcher();
40
41    let mut files_with_mtime: Vec<(String, SystemTime)> = Vec::new();
42
43    let walker = WalkBuilder::new(&path)
44        .hidden(false)
45        .git_ignore(true)
46        .git_global(true)
47        .git_exclude(true)
48        .build();
49
50    for entry_result in walker {
51        let entry = match entry_result {
52            Ok(e) => e,
53            Err(_) => continue,
54        };
55
56        if let Some(ft) = entry.file_type() {
57            if ft.is_dir() {
58                continue;
59            }
60        } else {
61            continue;
62        }
63
64        let rel_path = match entry.path().strip_prefix(&path) {
65            Ok(p) => p.to_string_lossy().into_owned(),
66            Err(_) => continue,
67        };
68
69        // Normalize Windows backslashes to forward slashes for glob pattern matching
70        #[cfg(windows)]
71        let rel_path = rel_path.replace('\\', "/");
72
73        if rel_path.is_empty() {
74            continue;
75        }
76
77        if !matcher.is_match(&rel_path) {
78            continue;
79        }
80
81        let mtime = entry
82            .metadata()
83            .ok()
84            .and_then(|m| m.modified().ok())
85            .unwrap_or(SystemTime::UNIX_EPOCH);
86
87        files_with_mtime.push((rel_path, mtime));
88    }
89
90    files_with_mtime.sort_by(|a, b| b.1.cmp(&a.1));
91
92    let truncated = files_with_mtime.len() > MAX_RESULTS;
93
94    let files: Vec<String> = files_with_mtime
95        .into_iter()
96        .take(MAX_RESULTS)
97        .map(|(path, _)| path)
98        .collect();
99
100    Ok(GlobOutput { files, truncated })
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::path::AbsolutePathResolver;
107    use std::fs::{self, File, FileTimes};
108    use std::io::Write;
109    use std::time::{Duration, SystemTime};
110    use tempfile::TempDir;
111
112    fn create_test_tree() -> TempDir {
113        let dir = TempDir::new().unwrap();
114        let base = dir.path();
115        fs::create_dir_all(base.join(".git")).unwrap();
116        fs::create_dir_all(base.join("src")).unwrap();
117        File::create(base.join("src/lib.rs")).unwrap();
118        File::create(base.join("Cargo.toml")).unwrap();
119        fs::create_dir_all(base.join("target")).unwrap();
120        File::create(base.join("target/binary")).unwrap();
121        let mut gitignore = File::create(base.join(".gitignore")).unwrap();
122        writeln!(gitignore, "target/").unwrap();
123        dir
124    }
125
126    #[test]
127    fn glob_matches_pattern() {
128        let dir = create_test_tree();
129        let resolver = AbsolutePathResolver;
130        let result = glob_files(&resolver, "**/*.rs", dir.path().to_str().unwrap()).unwrap();
131        assert!(result.files.iter().any(|f| f.ends_with("lib.rs")));
132    }
133
134    #[test]
135    fn glob_respects_gitignore() {
136        let dir = create_test_tree();
137        let resolver = AbsolutePathResolver;
138        let result = glob_files(&resolver, "**/*", dir.path().to_str().unwrap()).unwrap();
139        assert!(!result.files.iter().any(|f| f.contains("target")));
140    }
141
142    #[test]
143    fn glob_sorts_by_mtime_desc() {
144        let dir = TempDir::new().unwrap();
145        let base = dir.path();
146        let resolver = AbsolutePathResolver;
147
148        let older_path = base.join("older.txt");
149        let newer_path = base.join("newer.txt");
150        let older_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1);
151        let newer_time = SystemTime::UNIX_EPOCH + Duration::from_secs(2);
152
153        let older_file = File::create(&older_path).unwrap();
154        older_file
155            .set_times(FileTimes::new().set_modified(older_time))
156            .unwrap();
157        let newer_file = File::create(&newer_path).unwrap();
158        newer_file
159            .set_times(FileTimes::new().set_modified(newer_time))
160            .unwrap();
161
162        let result = glob_files(&resolver, "**/*.txt", base.to_str().unwrap()).unwrap();
163
164        let newer_index = result
165            .files
166            .iter()
167            .position(|path| path.ends_with("newer.txt"))
168            .unwrap();
169        let older_index = result
170            .files
171            .iter()
172            .position(|path| path.ends_with("older.txt"))
173            .unwrap();
174
175        assert!(
176            newer_index < older_index,
177            "expected newer file before older: {:?}",
178            result.files
179        );
180    }
181
182    #[test]
183    fn glob_returns_forward_slash_paths() {
184        // Patterns and returned paths use forward slashes on all platforms
185        let dir = create_test_tree();
186        let resolver = AbsolutePathResolver;
187        let result = glob_files(&resolver, "**/*.rs", dir.path().to_str().unwrap()).unwrap();
188
189        // Verify matching works with forward-slash patterns
190        assert_eq!(result.files.len(), 1);
191        assert!(result.files[0].ends_with("lib.rs"));
192
193        // Verify returned paths use forward slashes (critical for Windows)
194        for path in &result.files {
195            assert!(!path.contains('\\'), "expected forward slashes: {path}");
196        }
197        assert!(result.files.iter().any(|f| f.contains('/')));
198    }
199}