llm_coding_tools_core/operations/
glob.rs1use 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#[derive(Debug, Serialize)]
14pub struct GlobOutput {
15 pub files: Vec<String>,
17 #[serde(skip_serializing_if = "std::ops::Not::not")]
19 pub truncated: bool,
20}
21
22pub 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 #[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 let dir = create_test_tree();
186 let resolver = AbsolutePathResolver;
187 let result = glob_files(&resolver, "**/*.rs", dir.path().to_str().unwrap()).unwrap();
188
189 assert_eq!(result.files.len(), 1);
191 assert!(result.files[0].ends_with("lib.rs"));
192
193 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}