Skip to main content

lean_ctx/tools/
ctx_glob.rs

1use std::path::Path;
2
3use ignore::WalkBuilder;
4
5use crate::core::protocol;
6use crate::core::tokens::count_tokens;
7
8/// Hard ceiling on the number of files returned from a single glob search,
9/// independent of the caller-supplied `max_results`.
10const MAX_RESULTS: usize = 500;
11
12/// Finds files matching a glob `pattern` under `dir` with compressed output.
13///
14/// Unlike `ctx_search` which matches file *content*, this matches file *paths*.
15/// Uses the `ignore` crate for gitignore-aware, hidden-aware walking and matches
16/// against the standard `glob` crate's pattern syntax (`*.rs`, `**/*.ts`, …).
17///
18/// The walk is ordered by path (`sort_by_file_path`) so that — even when the
19/// result set is truncated to `max_results` — the *set* of returned files, not
20/// just their printed order, is deterministic across runs.
21///
22/// Returns `(output, original_tokens)`. On error the output starts with
23/// `"ERROR:"` and `original_tokens` is `0`.
24pub 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    // Broad-root guard (#356 class): with cwd == $HOME a defaulted `path`
41    // would walk the whole home dir and trip macOS TCC privacy prompts.
42    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    // Support both simple (`*.rs`) and recursive (`**/*.ts`) patterns.
49    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    // Vendor dirs (node_modules, …) follow the gitignore toggle: explicitly
58    // disabling gitignore is the escape hatch to look inside them (#400).
59    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        // Skip directories; only files are matchable results.
81        if entry.file_type().is_none_or(|ft| ft.is_dir()) {
82            continue;
83        }
84        // Skip symlinks — never follow them out of the search root.
85        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        // Never surface secret-like paths (.env, keys, …) unless the active role
93        // explicitly allows it.
94        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    // Deterministic output ordering (the walk is already path-ordered; this also
116    // normalises the shortened-path representation).
117    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    // A plain file list carries no compression overhead, so the original token
129    // budget equals what we send.
130    (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        // #356 class: never walk the whole home dir (macOS TCC prompts).
161        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        // The `ignore` crate only honours .gitignore inside a git repo (its
202        // `require_git` default); mark the tempdir as a repo root so the test
203        // exercises real-world behaviour without shelling out to `git`.
204        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        // With gitignore disabled, the ignored file reappears.
217        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}