1use anyhow::Result;
2use ck_core::IncludePattern;
3use glob::glob;
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use std::path::{Component, Path, PathBuf};
6
7pub 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
25pub 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 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}