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