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
39    let max = max_results.min(MAX_RESULTS);
40
41    // Support both simple (`*.rs`) and recursive (`**/*.ts`) patterns.
42    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        // Skip directories; only files are matchable results.
65        if entry.file_type().is_none_or(|ft| ft.is_dir()) {
66            continue;
67        }
68        // Skip symlinks — never follow them out of the search root.
69        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        // Never surface secret-like paths (.env, keys, …) unless the active role
77        // explicitly allows it.
78        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    // Deterministic output ordering (the walk is already path-ordered; this also
100    // normalises the shortened-path representation).
101    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    // A plain file list carries no compression overhead, so the original token
113    // budget equals what we send.
114    (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        // The `ignore` crate only honours .gitignore inside a git repo (its
174        // `require_git` default); mark the tempdir as a repo root so the test
175        // exercises real-world behaviour without shelling out to `git`.
176        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        // With gitignore disabled, the ignored file reappears.
189        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}