1use std::path::{Path, PathBuf};
2use walkdir::WalkDir;
3
4#[derive(Debug, Clone, Default)]
5pub struct ScanConfig {
6 pub include_patterns: Vec<String>,
7 pub exclude_patterns: Vec<String>,
8 pub respect_gitignore: bool,
9}
10
11impl ScanConfig {
12 pub fn default_enabled() -> Self {
13 Self {
14 include_patterns: Vec::new(),
15 exclude_patterns: Vec::new(),
16 respect_gitignore: true,
17 }
18 }
19}
20
21pub fn parse_csv_patterns(raw: Option<&str>) -> Vec<String> {
22 raw.unwrap_or_default()
23 .split(',')
24 .map(str::trim)
25 .filter(|p| !p.is_empty())
26 .map(|p| p.replace('\\', "/"))
27 .collect()
28}
29
30pub fn path_matches_any(path: &str, patterns: &[String]) -> bool {
31 patterns.iter().any(|p| pattern_matches(path, p))
32}
33
34pub fn pattern_matches(path: &str, pattern: &str) -> bool {
35 let path = normalize(path);
36 let pattern = normalize(pattern);
37
38 if pattern.is_empty() {
39 return false;
40 }
41
42 if !pattern.contains('/') {
43 if wildcard_match(&path, &pattern) {
44 return true;
45 }
46 return path.split('/').any(|seg| wildcard_match(seg, &pattern));
47 }
48
49 if let Some(tail) = pattern.strip_prefix("**/") {
50 return path.split('/').enumerate().any(|(idx, _)| {
51 wildcard_match(
52 &path.split('/').skip(idx).collect::<Vec<_>>().join("/"),
53 tail,
54 )
55 });
56 }
57
58 if wildcard_match(&path, &pattern) {
59 return true;
60 }
61
62 if path.len() >= pattern.len() {
63 return path.ends_with(&pattern);
64 }
65
66 false
67}
68
69fn wildcard_match(text: &str, pattern: &str) -> bool {
70 let t = text.as_bytes();
71 let p = pattern.as_bytes();
72
73 let (mut ti, mut pi) = (0usize, 0usize);
74 let mut star = None::<usize>;
75 let mut match_i = 0usize;
76
77 while ti < t.len() {
78 if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
79 ti += 1;
80 pi += 1;
81 } else if pi < p.len() && p[pi] == b'*' {
82 star = Some(pi);
83 pi += 1;
84 match_i = ti;
85 } else if let Some(star_pos) = star {
86 pi = star_pos + 1;
87 match_i += 1;
88 ti = match_i;
89 } else {
90 return false;
91 }
92 }
93
94 while pi < p.len() && p[pi] == b'*' {
95 pi += 1;
96 }
97
98 pi == p.len()
99}
100
101fn normalize(input: &str) -> String {
102 input
103 .trim()
104 .replace('\\', "/")
105 .trim_start_matches("./")
106 .trim_matches('/')
107 .to_string()
108}
109
110#[derive(Debug, Clone)]
111pub struct GitignoreRule {
112 pub pattern: String,
113 pub negated: bool,
114 pub directory_only: bool,
115}
116
117pub fn load_root_gitignore_rules(root: &Path) -> Vec<GitignoreRule> {
118 let path = root.join(".gitignore");
119 let Ok(content) = std::fs::read_to_string(path) else {
120 return Vec::new();
121 };
122
123 content
124 .lines()
125 .map(str::trim)
126 .filter(|line| !line.is_empty() && !line.starts_with('#'))
127 .filter_map(|line| {
128 let negated = line.starts_with('!');
129 let mut body = if negated { &line[1..] } else { line };
130 body = body.trim();
131 if body.is_empty() {
132 return None;
133 }
134
135 let directory_only = body.ends_with('/');
136 let pattern = body.trim_end_matches('/').replace('\\', "/");
137 Some(GitignoreRule {
138 pattern,
139 negated,
140 directory_only,
141 })
142 })
143 .collect()
144}
145
146pub fn is_ignored_by_rules(rel_path: &str, _is_dir: bool, rules: &[GitignoreRule]) -> bool {
147 let path = normalize(rel_path);
148 if path.is_empty() {
149 return false;
150 }
151
152 let mut ignored = false;
153
154 for rule in rules {
155 let anchored = rule.pattern.starts_with('/');
156 let rule_pattern = rule.pattern.trim_start_matches('/');
157 let dir_prefix = format!("{rule_pattern}/");
158
159 let matches_candidate = |candidate: &str| {
160 if rule.directory_only {
161 candidate == rule_pattern || candidate.starts_with(&dir_prefix)
162 } else {
163 pattern_matches(candidate, rule_pattern)
164 }
165 };
166
167 let matched = if anchored {
168 matches_candidate(&path)
169 } else {
170 matches_candidate(&path)
171 || path.split('/').enumerate().any(|(idx, _)| {
172 matches_candidate(&path.split('/').skip(idx).collect::<Vec<_>>().join("/"))
173 })
174 };
175
176 if matched {
177 ignored = !rule.negated;
178 }
179 }
180
181 ignored
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::path::PathBuf;
188
189 #[test]
190 fn test_parse_csv_patterns() {
191 let patterns = parse_csv_patterns(Some("src/**, tests/*.ts , ,node_modules/**"));
192 assert_eq!(
193 patterns,
194 vec![
195 "src/**".to_string(),
196 "tests/*.ts".to_string(),
197 "node_modules/**".to_string()
198 ]
199 );
200 }
201
202 #[test]
203 fn test_pattern_matches_globs_and_suffix() {
204 assert!(pattern_matches("src/a/b/file.ts", "src/**"));
205 assert!(pattern_matches("src/a/b/file.ts", "**/*.ts"));
206 assert!(pattern_matches("src/a/b/file.ts", "*.ts"));
207 assert!(pattern_matches("src/a/b/file.ts", "a/b/file.ts"));
208 assert!(!pattern_matches("src/a/b/file.ts", "*.tsx"));
209 }
210
211 #[test]
212 fn test_gitignore_rule_evaluation_with_negation() {
213 let rules = vec![
214 GitignoreRule {
215 pattern: "dist".to_string(),
216 negated: false,
217 directory_only: true,
218 },
219 GitignoreRule {
220 pattern: "dist/keep.ts".to_string(),
221 negated: true,
222 directory_only: false,
223 },
224 ];
225
226 assert!(is_ignored_by_rules("dist", true, &rules));
227 assert!(is_ignored_by_rules("dist/a.ts", false, &rules));
228 assert!(!is_ignored_by_rules("dist/keep.ts", false, &rules));
229 }
230
231 #[test]
232 fn test_include_patterns_descend_correctly_into_subdirectories() {
233 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/scan/monorepo");
234 let config = ScanConfig {
235 include_patterns: vec!["projects/frontend/web-portal/**".to_string()],
236 exclude_patterns: Vec::new(),
237 respect_gitignore: false,
238 };
239
240 let files = walk_source_files_with_config(&root, &config, |_| true)
241 .expect("walk should succeed for monorepo fixture");
242 let rel_files: Vec<String> = files
243 .iter()
244 .filter_map(|p| {
245 p.strip_prefix(&root)
246 .ok()
247 .map(|r| r.to_string_lossy().replace('\\', "/"))
248 })
249 .collect();
250
251 assert!(
252 rel_files
253 .iter()
254 .any(|f| f.ends_with("projects/frontend/web-portal/src/App.ts")),
255 "should include App.ts under nested include path"
256 );
257 assert!(
258 rel_files
259 .iter()
260 .any(|f| f.ends_with("projects/frontend/web-portal/src/Component.ts")),
261 "should include Component.ts under nested include path"
262 );
263 }
264
265 #[test]
266 fn test_include_with_double_star_finds_nested_files() {
267 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/scan/monorepo");
268 let config = ScanConfig {
269 include_patterns: vec!["projects/**/*.ts".to_string()],
270 exclude_patterns: Vec::new(),
271 respect_gitignore: false,
272 };
273
274 let files = walk_source_files_with_config(&root, &config, |_| true)
275 .expect("walk should succeed for recursive glob include");
276 let rel_files: Vec<String> = files
277 .iter()
278 .filter_map(|p| {
279 p.strip_prefix(&root)
280 .ok()
281 .map(|r| r.to_string_lossy().replace('\\', "/"))
282 })
283 .collect();
284
285 assert_eq!(
286 rel_files.len(),
287 3,
288 "recursive include should find all three fixture TypeScript files"
289 );
290 assert!(
291 rel_files
292 .iter()
293 .any(|f| f.ends_with("projects/api/administration/src/AdminService.ts")),
294 "recursive include should include AdminService.ts"
295 );
296 }
297}
298
299pub fn walk_source_files_with_config<F>(
300 root: &Path,
301 config: &ScanConfig,
302 is_supported: F,
303) -> Result<Vec<PathBuf>, std::io::Error>
304where
305 F: Fn(&Path) -> bool,
306{
307 let mut out = Vec::new();
308 let rules = if config.respect_gitignore {
309 load_root_gitignore_rules(root)
310 } else {
311 Vec::new()
312 };
313
314 for entry in WalkDir::new(root)
315 .into_iter()
316 .filter_entry(|e| should_descend(root, e.path(), config, &rules))
317 {
318 let entry = entry?;
319 let path = entry.path();
320 if !path.is_file() || !is_supported(path) {
321 continue;
322 }
323
324 if should_include_file(root, path, config, &rules) {
325 out.push(path.to_path_buf());
326 }
327 }
328
329 out.sort();
330 Ok(out)
331}
332
333const DEFAULT_EXCLUDE_DIRS: &[&str] = &[
334 "node_modules",
335 "dist",
336 "build",
337 "out",
338 ".next",
339 ".nuxt",
340 ".output",
341 "coverage",
342 ".cache",
343 ".turbo",
344 ".parcel-cache",
345 ".graphyn",
346 ".git",
347 "target",
348];
349
350const DEFAULT_EXCLUDE_SUFFIXES: &[&str] = &[
351 ".d.ts", ".d.mts", ".d.cts", ".min.js", ".min.mjs", ".min.css", ".map", ];
356
357pub fn should_include_relative_path(
358 relative_path: &str,
359 is_dir: bool,
360 config: &ScanConfig,
361 rules: &[GitignoreRule],
362) -> bool {
363 let rel = relative_path.replace('\\', "/");
364
365 if rel.is_empty() || rel == "." {
366 return true;
367 }
368
369 for segment in rel.split('/') {
371 if DEFAULT_EXCLUDE_DIRS.contains(&segment) {
372 return false;
373 }
374 }
375
376 if !is_dir {
378 for suffix in DEFAULT_EXCLUDE_SUFFIXES {
379 if rel.ends_with(suffix) {
380 return false;
381 }
382 }
383 }
384
385 if config.respect_gitignore && is_ignored_by_rules(&rel, is_dir, rules) {
386 return false;
387 }
388
389 if !config.exclude_patterns.is_empty() && path_matches_any(&rel, &config.exclude_patterns) {
390 return false;
391 }
392
393 if config.include_patterns.is_empty() {
394 return true;
395 }
396
397 path_matches_any(&rel, &config.include_patterns)
398}
399
400fn should_descend(root: &Path, path: &Path, config: &ScanConfig, rules: &[GitignoreRule]) -> bool {
401 if !path.is_dir() {
402 return true;
403 }
404
405 let Ok(relative) = path.strip_prefix(root) else {
406 return true;
407 };
408 let rel = relative.to_string_lossy().replace('\\', "/");
409 if rel.is_empty() {
410 return true;
411 }
412
413 for segment in rel.split('/') {
414 if DEFAULT_EXCLUDE_DIRS.contains(&segment) {
415 return false;
416 }
417 }
418
419 if config.respect_gitignore && is_ignored_by_rules(&rel, true, rules) {
420 return false;
421 }
422
423 if !config.exclude_patterns.is_empty() && path_matches_any(&rel, &config.exclude_patterns) {
424 return false;
425 }
426
427 if !config.include_patterns.is_empty() {
428 return config
429 .include_patterns
430 .iter()
431 .any(|pattern| directory_could_contain_match(&rel, pattern));
432 }
433
434 true
435}
436
437fn directory_could_contain_match(dir_rel: &str, pattern: &str) -> bool {
438 let dir = normalize(dir_rel);
439 let pat = normalize(pattern);
440
441 if dir.is_empty() || pat.is_empty() {
442 return true;
443 }
444
445 if pat.starts_with("**") {
446 return true;
447 }
448
449 let pat_no_globstar = pat.strip_prefix("**/").unwrap_or(&pat);
450 let fixed_prefix = pat_no_globstar
451 .split('*')
452 .next()
453 .unwrap_or("")
454 .trim_matches('/');
455
456 if fixed_prefix.is_empty() {
457 return true;
458 }
459
460 if fixed_prefix.starts_with(&dir) || dir.starts_with(fixed_prefix) {
461 return true;
462 }
463
464 pattern_matches(&format!("{dir}/dummy"), &pat)
465}
466
467fn should_include_file(
468 root: &Path,
469 path: &Path,
470 config: &ScanConfig,
471 rules: &[GitignoreRule],
472) -> bool {
473 let Ok(relative) = path.strip_prefix(root) else {
474 return false;
475 };
476
477 let rel = relative.to_string_lossy().replace('\\', "/");
478 should_include_relative_path(&rel, false, config, rules)
479}