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