1use std::path::Path;
2
3use ignore::WalkBuilder;
4
5use crate::core::protocol;
6use crate::core::tokens::count_tokens;
7
8const MAX_RESULTS: usize = 500;
11
12pub fn handle(
25 pattern: &str,
26 dir: &str,
27 respect_gitignore: bool,
28 allow_secret_paths: bool,
29 max_results: usize,
30) -> (String, usize) {
31 let requested_root = Path::new(dir);
32 let walk_root = crate::core::walk_filter::explicit_walk_root(requested_root);
33 let root = walk_root.as_path();
34 if !root.exists() {
35 return (format!("ERROR: {dir} does not exist"), 0);
36 }
37 if !root.is_dir() {
38 return (format!("ERROR: {dir} is not a directory"), 0);
39 }
40 if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(dir) {
43 return (err, 0);
44 }
45
46 let max = max_results.min(MAX_RESULTS);
47
48 let glob_matcher = match glob::Pattern::new(pattern) {
50 Ok(m) => m,
51 Err(e) => return (format!("ERROR: invalid glob pattern '{pattern}': {e}"), 0),
52 };
53
54 let mut matches = Vec::new();
55 let mut files_walked = 0u32;
56
57 let walker = WalkBuilder::new(root)
60 .hidden(true)
61 .git_ignore(respect_gitignore)
62 .git_global(respect_gitignore)
63 .git_exclude(respect_gitignore)
64 .require_git(false)
65 .filter_entry(move |e| {
66 if respect_gitignore {
67 crate::core::walk_filter::keep_entry(e)
68 } else {
69 crate::core::cloud_files::keep_entry(e)
70 }
71 })
72 .sort_by_file_path(std::path::Path::cmp)
73 .build();
74
75 for entry in walker.filter_map(std::result::Result::ok) {
76 if matches.len() >= max {
77 break;
78 }
79
80 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
82 continue;
83 }
84 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
86 continue;
87 }
88
89 let path = entry.path();
90 files_walked += 1;
91
92 if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
95 continue;
96 }
97
98 let rel_path = path.strip_prefix(root).unwrap_or(path);
99 let rel_str = rel_path.to_string_lossy();
100
101 if glob_matcher.matches(&rel_str) {
102 let short_path =
103 protocol::shorten_path_relative(&path.to_string_lossy(), &root.to_string_lossy());
104 matches.push(short_path);
105 }
106 }
107
108 if matches.is_empty() {
109 return (
110 format!("0 files matched '{pattern}' in {files_walked} files walked"),
111 0,
112 );
113 }
114
115 matches.sort();
118
119 let output = matches.join("\n");
120 let raw_tokens = count_tokens(&output);
121
122 let footer = format!(
123 "\n\n{} files matched (walked {files_walked})",
124 matches.len()
125 );
126 let full_output = format!("{output}{footer}");
127
128 (full_output, raw_tokens)
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn glob_results_are_deterministically_ordered() {
139 let dir = tempfile::tempdir().unwrap();
140 std::fs::write(dir.path().join("b.txt"), "content").unwrap();
141 std::fs::write(dir.path().join("a.txt"), "content").unwrap();
142 std::fs::write(dir.path().join("c.rs"), "content").unwrap();
143
144 let (out, _) = handle("*.txt", &dir.path().to_string_lossy(), true, true, 100);
145
146 let lines: Vec<&str> = out
147 .lines()
148 .filter(|l| {
149 std::path::Path::new(l)
150 .extension()
151 .is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
152 })
153 .collect();
154 assert_eq!(lines.len(), 2);
155 assert!(lines[0] < lines[1], "results must be sorted: {lines:?}");
156 }
157
158 #[test]
159 fn glob_refuses_home_directory_root() {
160 let home = dirs::home_dir().expect("home dir in test env");
162 let (out, tokens) = handle("*.txt", home.to_string_lossy().as_ref(), true, true, 10);
163 assert!(
164 out.starts_with("ERROR:") && out.contains("refusing to scan"),
165 "home root must be refused: {out}"
166 );
167 assert_eq!(tokens, 0);
168 }
169
170 #[test]
171 fn glob_skips_directories() {
172 let dir = tempfile::tempdir().unwrap();
173 std::fs::create_dir(dir.path().join("subdir")).unwrap();
174 std::fs::write(dir.path().join("file.txt"), "content").unwrap();
175
176 let (out, _) = handle("**/*.txt", &dir.path().to_string_lossy(), true, true, 100);
177
178 assert!(out.contains("file.txt"));
179 assert!(!out.contains("subdir"));
180 }
181
182 #[test]
183 fn glob_recursive_pattern_descends_subdirs() {
184 let dir = tempfile::tempdir().unwrap();
185 std::fs::create_dir(dir.path().join("nested")).unwrap();
186 std::fs::write(dir.path().join("nested").join("deep.rs"), "fn x() {}").unwrap();
187 std::fs::write(dir.path().join("top.rs"), "fn y() {}").unwrap();
188
189 let (out, _) = handle("**/*.rs", &dir.path().to_string_lossy(), true, true, 100);
190
191 assert!(
192 out.contains("deep.rs"),
193 "recursive glob must descend: {out}"
194 );
195 assert!(out.contains("top.rs"));
196 }
197
198 #[test]
199 fn glob_respects_gitignore() {
200 let dir = tempfile::tempdir().unwrap();
201 std::fs::create_dir(dir.path().join(".git")).unwrap();
205 std::fs::write(dir.path().join(".gitignore"), "ignored.rs\n").unwrap();
206 std::fs::write(dir.path().join("ignored.rs"), "fn a() {}").unwrap();
207 std::fs::write(dir.path().join("kept.rs"), "fn b() {}").unwrap();
208
209 let (respected, _) = handle("**/*.rs", &dir.path().to_string_lossy(), true, true, 100);
210 assert!(respected.contains("kept.rs"));
211 assert!(
212 !respected.contains("ignored.rs"),
213 "gitignored file must be skipped: {respected}"
214 );
215
216 let (unrespected, _) = handle("**/*.rs", &dir.path().to_string_lossy(), false, true, 100);
218 assert!(unrespected.contains("ignored.rs"));
219 }
220
221 #[test]
222 fn glob_invalid_pattern_returns_error() {
223 let dir = tempfile::tempdir().unwrap();
224 let (out, _) = handle("[invalid", &dir.path().to_string_lossy(), true, true, 100);
225
226 assert!(out.starts_with("ERROR:"));
227 assert!(out.contains("invalid glob pattern"));
228 }
229
230 #[test]
231 fn glob_nonexistent_dir_returns_error() {
232 let (out, _) = handle("*.txt", "/nonexistent/path", true, true, 100);
233
234 assert!(out.starts_with("ERROR:"));
235 assert!(out.contains("does not exist"));
236 }
237
238 #[test]
239 fn glob_respects_max_results() {
240 let dir = tempfile::tempdir().unwrap();
241 for i in 0..10 {
242 std::fs::write(dir.path().join(format!("file{i}.txt")), "content").unwrap();
243 }
244
245 let (out, _) = handle("*.txt", &dir.path().to_string_lossy(), true, true, 5);
246
247 let file_lines: Vec<&str> = out
248 .lines()
249 .filter(|l| {
250 std::path::Path::new(l)
251 .extension()
252 .is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
253 })
254 .collect();
255 assert!(file_lines.len() <= 5, "should respect max_results");
256 }
257
258 #[cfg(windows)]
259 #[test]
260 fn glob_walks_explicit_directory_reparse_root() {
261 use std::os::windows::fs::symlink_dir;
262
263 let tmp = tempfile::tempdir().expect("tempdir");
264 let target = tmp.path().join("target");
265 let link = tmp.path().join("junction");
266 std::fs::create_dir_all(&target).expect("target");
267 std::fs::write(target.join("visible.rs"), "fn visible() {}\n").expect("fixture");
268 if symlink_dir(&target, &link).is_err() {
269 return;
270 }
271 let (out, _) = handle("**/*.rs", &link.to_string_lossy(), true, true, 20);
272 assert!(
273 out.contains("visible.rs"),
274 "junction root must be traversed: {out}"
275 );
276 }
277}