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 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    // Broad-root guard (#356 class): with cwd == $HOME a defaulted `path`
39    // would walk the whole home dir and trip macOS TCC privacy prompts.
40    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    // Support both simple (`*.rs`) and recursive (`**/*.ts`) patterns.
47    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    // Vendor dirs (node_modules, …) follow the gitignore toggle: explicitly
56    // disabling gitignore is the escape hatch to look inside them (#400).
57    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        // Skip directories; only files are matchable results.
79        if entry.file_type().is_none_or(|ft| ft.is_dir()) {
80            continue;
81        }
82        // Skip symlinks — never follow them out of the search root.
83        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        // Never surface secret-like paths (.env, keys, …) unless the active role
91        // explicitly allows it.
92        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    // Deterministic output ordering (the walk is already path-ordered; this also
114    // normalises the shortened-path representation).
115    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    // A plain file list carries no compression overhead, so the original token
127    // budget equals what we send.
128    (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        // #356 class: never walk the whole home dir (macOS TCC prompts).
159        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        // The `ignore` crate only honours .gitignore inside a git repo (its
200        // `require_git` default); mark the tempdir as a repo root so the test
201        // exercises real-world behaviour without shelling out to `git`.
202        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        // With gitignore disabled, the ignored file reappears.
215        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}