Skip to main content

ck_search/
path_utils.rs

1use anyhow::Result;
2use ck_core::IncludePattern;
3use glob::glob;
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use std::path::{Component, Path, PathBuf};
6
7/// Expand user-provided glob patterns, mimicking shell behaviour while tolerating
8/// unmatched globs by keeping the original pattern. Bare filename globs (e.g.
9/// `*.rs`) automatically get a recursive fallback to align with the CLI UX.
10pub fn expand_glob_patterns(
11    paths: &[PathBuf],
12    exclude_patterns: &[String],
13) -> Result<Vec<PathBuf>> {
14    expand_glob_patterns_internal(paths, exclude_patterns, None)
15}
16
17pub fn expand_glob_patterns_with_base(
18    base_dir: &Path,
19    paths: &[PathBuf],
20    exclude_patterns: &[String],
21) -> Result<Vec<PathBuf>> {
22    expand_glob_patterns_internal(paths, exclude_patterns, Some(base_dir))
23}
24
25/// Build IncludePattern structures from filesystem paths, canonicalising where
26/// possible and marking whether the path points to a directory.
27pub fn build_include_patterns(paths: &[PathBuf]) -> Vec<IncludePattern> {
28    let mut includes: Vec<IncludePattern> = Vec::new();
29
30    for path in paths {
31        let canonical = canonicalize_lossy(path);
32        let is_dir = std::fs::metadata(&canonical)
33            .map(|meta| meta.is_dir())
34            .unwrap_or(false);
35
36        if let Some(existing) = includes.iter_mut().find(|inc| inc.path == canonical) {
37            if is_dir {
38                existing.is_dir = true;
39            }
40        } else {
41            includes.push(IncludePattern {
42                path: canonical,
43                is_dir,
44            });
45        }
46    }
47
48    includes
49}
50
51pub(crate) fn split_path_patterns(path: &Path) -> Vec<String> {
52    let path_str = path.to_string_lossy();
53    if !path_str.contains(';') {
54        return vec![path_str.to_string()];
55    }
56
57    path_str
58        .split(';')
59        .filter_map(|segment| {
60            let trimmed = segment.trim();
61            if trimmed.is_empty() {
62                None
63            } else {
64                Some(trimmed.to_string())
65            }
66        })
67        .collect()
68}
69
70fn expand_glob_patterns_internal(
71    paths: &[PathBuf],
72    exclude_patterns: &[String],
73    base_dir: Option<&Path>,
74) -> Result<Vec<PathBuf>> {
75    let globset = build_globset(exclude_patterns);
76    let mut expanded = Vec::new();
77
78    for path in paths {
79        for pattern in split_path_patterns(path) {
80            let is_simple = !pattern.contains('/') && !pattern.contains('\\');
81
82            let glob_path = if let Some(base) = base_dir {
83                let candidate = Path::new(&pattern);
84                if candidate.is_absolute() {
85                    candidate.to_path_buf()
86                } else {
87                    base.join(&pattern)
88                }
89            } else {
90                PathBuf::from(&pattern)
91            };
92
93            let glob_str = glob_path.to_string_lossy().to_string();
94            let mut matched = run_glob(&glob_str, &globset, base_dir, &mut expanded)?;
95
96            if is_simple {
97                let fallback_path = if let Some(base) = base_dir {
98                    base.join(format!("**/{pattern}"))
99                } else {
100                    PathBuf::from(format!("**/{pattern}"))
101                };
102                let fallback_str = fallback_path.to_string_lossy().to_string();
103                matched |= run_glob(&fallback_str, &globset, base_dir, &mut expanded)?;
104            }
105
106            if !matched {
107                push_if_new(&mut expanded, glob_path);
108            }
109        }
110    }
111
112    Ok(expanded)
113}
114
115fn run_glob(
116    pattern: &str,
117    globset: &GlobSet,
118    base_dir: Option<&Path>,
119    expanded: &mut Vec<PathBuf>,
120) -> Result<bool> {
121    let mut matched = false;
122    match glob(pattern) {
123        Ok(glob_paths) => {
124            for glob_result in glob_paths {
125                match glob_result {
126                    Ok(matched_path) => {
127                        if should_exclude_path(&matched_path, globset, base_dir) {
128                            continue;
129                        }
130                        matched = true;
131                        push_if_new(expanded, matched_path);
132                    }
133                    Err(e) => {
134                        eprintln!("Warning: glob error for pattern '{pattern}': {e}");
135                    }
136                }
137            }
138        }
139        Err(e) => {
140            eprintln!("Warning: invalid glob pattern '{pattern}': {e}");
141        }
142    }
143    Ok(matched)
144}
145
146fn push_if_new(acc: &mut Vec<PathBuf>, candidate: PathBuf) {
147    if !acc.iter().any(|existing| existing == &candidate) {
148        acc.push(candidate);
149    }
150}
151
152fn canonicalize_lossy(path: &Path) -> PathBuf {
153    if let Ok(canonical) = path.canonicalize() {
154        return canonical;
155    }
156
157    std::env::current_dir()
158        .map(|cwd| cwd.join(path))
159        .unwrap_or_else(|_| path.to_path_buf())
160}
161
162fn should_exclude_path(path: &Path, globset: &GlobSet, base_dir: Option<&Path>) -> bool {
163    if globset.is_match(path) {
164        return true;
165    }
166
167    if let Some(base) = base_dir
168        && let Ok(relative) = path.strip_prefix(base)
169    {
170        if !relative.as_os_str().is_empty() && globset.is_match(relative) {
171            return true;
172        }
173
174        for component in relative.components() {
175            if let Component::Normal(name) = component
176                && globset.is_match(name)
177            {
178                return true;
179            }
180        }
181    }
182
183    for component in path.components() {
184        if let Component::Normal(name) = component
185            && globset.is_match(name)
186        {
187            return true;
188        }
189    }
190
191    false
192}
193
194fn build_globset(patterns: &[String]) -> GlobSet {
195    let mut builder = GlobSetBuilder::new();
196
197    for pattern in patterns {
198        if let Ok(glob) = Glob::new(pattern) {
199            builder.add(glob);
200        }
201
202        if let Some(stripped) = pattern.strip_suffix("/**") {
203            if !stripped.is_empty()
204                && let Ok(glob) = Glob::new(stripped)
205            {
206                builder.add(glob);
207            }
208        } else if let Some(stripped) = pattern.strip_suffix("\\**") {
209            // Support Windows-style globstar suffixes as well.
210            if !stripped.is_empty()
211                && let Ok(glob) = Glob::new(stripped)
212            {
213                builder.add(glob);
214            }
215        }
216    }
217
218    builder.build().unwrap_or_else(|_| GlobSet::empty())
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use std::fs;
225    use std::path::{Path, PathBuf};
226    use tempfile::tempdir;
227
228    fn write_file(path: &Path, contents: &str) {
229        if let Some(parent) = path.parent() {
230            fs::create_dir_all(parent).unwrap();
231        }
232        fs::write(path, contents).unwrap();
233    }
234
235    #[test]
236    fn expands_basic_glob_patterns() {
237        let temp_dir = tempdir().unwrap();
238        let base = temp_dir.path();
239
240        write_file(&base.join("alpha.rs"), "fn main() {}");
241        write_file(&base.join("nested/beta.rs"), "fn helper() {}");
242        write_file(&base.join("gamma.ts"), "export const X = 1;");
243
244        let expanded = expand_glob_patterns_with_base(base, &[PathBuf::from("*.rs")], &[])
245            .expect("expand *.rs");
246
247        let has_alpha = expanded.iter().any(|p| p.ends_with("alpha.rs"));
248        let has_beta = expanded.iter().any(|p| p.ends_with("beta.rs"));
249
250        assert!(has_alpha, "alpha.rs should be expanded");
251        assert!(
252            has_beta,
253            "nested/beta.rs should be expanded via recursive fallback"
254        );
255        assert_eq!(expanded.len(), 2);
256    }
257
258    #[test]
259    fn expands_literal_files_and_directories() {
260        let temp_dir = tempdir().unwrap();
261        let base = temp_dir.path();
262
263        write_file(&base.join("docs/guide.md"), "# Guide");
264        write_file(&base.join("src/file.ts"), "export {}");
265
266        let expanded = expand_glob_patterns_with_base(
267            base,
268            &[PathBuf::from("docs/"), PathBuf::from("src/file.ts")],
269            &[],
270        )
271        .expect("expand literals");
272
273        let has_docs = expanded.iter().any(|p| p.ends_with("docs"));
274        let has_ts = expanded.iter().any(|p| p.ends_with("file.ts"));
275
276        assert!(has_docs, "docs directory should be included");
277        assert!(has_ts, "file.ts should be included");
278    }
279
280    #[test]
281    fn splits_semicolon_separated_patterns() {
282        let temp_dir = tempdir().unwrap();
283        let base = temp_dir.path();
284
285        write_file(&base.join("docs/readme.md"), "# docs");
286        write_file(&base.join("lib/lib.rs"), "pub fn lib() {}");
287        write_file(&base.join("file.ts"), "export {}");
288
289        let expanded =
290            expand_glob_patterns_with_base(base, &[PathBuf::from("docs/;*.rs;file.ts")], &[])
291                .expect("expand semicolon list");
292
293        let has_docs = expanded.iter().any(|p| p.ends_with("docs"));
294        let has_rs = expanded.iter().any(|p| p.ends_with("lib.rs"));
295        let has_ts = expanded.iter().any(|p| p.ends_with("file.ts"));
296
297        assert!(has_docs, "docs directory should be present");
298        assert!(has_rs, "lib.rs should be matched from glob");
299        assert!(has_ts, "file.ts should be included explicitly");
300    }
301
302    #[test]
303    fn respects_wildcard_exclude_patterns() {
304        let temp_dir = tempdir().unwrap();
305        let base = temp_dir.path();
306
307        write_file(&base.join("keep.rs"), "fn main() {}");
308        write_file(&base.join("ignore.json"), "{}");
309        write_file(&base.join("nested/keep.rs"), "fn helper() {}");
310        write_file(&base.join("nested/ignore.json"), "{}");
311
312        let expanded =
313            expand_glob_patterns_with_base(base, &[PathBuf::from("**/*")], &["*.json".to_string()])
314                .expect("expand with excludes");
315
316        let includes_json = expanded.iter().any(|p| p.ends_with("ignore.json"));
317        let includes_rs = expanded.iter().filter(|p| p.ends_with("keep.rs")).count();
318
319        assert!(!includes_json, "*.json files should be excluded");
320        assert_eq!(includes_rs, 2, "both keep.rs files should remain");
321    }
322
323    #[test]
324    fn respects_directory_globstar_excludes() {
325        let temp_dir = tempdir().unwrap();
326        let base = temp_dir.path();
327
328        write_file(&base.join("foo/bar/data.txt"), "foo");
329        write_file(&base.join("foo/other.txt"), "foo");
330        write_file(&base.join("root.txt"), "root");
331
332        let expanded =
333            expand_glob_patterns_with_base(base, &[PathBuf::from("**/*")], &["foo/**".to_string()])
334                .expect("expand with directory glob");
335
336        let includes_foo = expanded.iter().any(|p| p.to_string_lossy().contains("foo"));
337        let includes_root = expanded.iter().any(|p| p.ends_with("root.txt"));
338
339        assert!(!includes_foo, "foo/** should exclude everything under foo");
340        assert!(includes_root, "root.txt should still be present");
341    }
342}