1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::sync::Mutex;
4
5use fallow_config::ResolvedConfig;
6use fallow_types::discover::{DiscoveredFile, FileId};
7use ignore::WalkBuilder;
8
9use super::ALLOWED_HIDDEN_DIRS;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct HiddenDirScope {
14 root: PathBuf,
15 dirs: Vec<String>,
16}
17
18impl HiddenDirScope {
19 pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
20 Self { root, dirs }
21 }
22
23 fn allows(&self, path: &Path, name: &OsStr) -> bool {
24 path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
25 }
26}
27
28struct FileVisitor<'a> {
30 root: &'a Path,
31 ignore_patterns: &'a globset::GlobSet,
32 production_excludes: &'a Option<globset::GlobSet>,
33 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
34 local: Vec<(std::path::PathBuf, u64)>,
35}
36
37impl ignore::ParallelVisitor for FileVisitor<'_> {
38 fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
39 let Ok(entry) = result else {
40 return ignore::WalkState::Continue;
41 };
42 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
43 return ignore::WalkState::Continue;
44 }
45 let relative = entry
46 .path()
47 .strip_prefix(self.root)
48 .unwrap_or_else(|_| entry.path());
49 if self.ignore_patterns.is_match(relative) {
50 return ignore::WalkState::Continue;
51 }
52 if self
53 .production_excludes
54 .as_ref()
55 .is_some_and(|excludes| excludes.is_match(relative))
56 {
57 return ignore::WalkState::Continue;
58 }
59 let size_bytes = entry.metadata().map_or(0, |m| m.len());
60 self.local.push((entry.into_path(), size_bytes));
61 ignore::WalkState::Continue
62 }
63}
64
65impl Drop for FileVisitor<'_> {
66 #[expect(
67 clippy::expect_used,
68 reason = "poisoned walk collector lock means worker state is unrecoverable"
69 )]
70 fn drop(&mut self) {
71 if !self.local.is_empty() {
72 self.shared
73 .lock()
74 .expect("walk collector lock poisoned")
75 .append(&mut self.local);
76 }
77 }
78}
79
80struct FileVisitorBuilder<'a> {
82 root: &'a Path,
83 ignore_patterns: &'a globset::GlobSet,
84 production_excludes: &'a Option<globset::GlobSet>,
85 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
86}
87
88impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
89 fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
90 Box::new(FileVisitor {
91 root: self.root,
92 ignore_patterns: self.ignore_patterns,
93 production_excludes: self.production_excludes,
94 shared: self.shared,
95 local: Vec::new(),
96 })
97 }
98}
99
100pub const SOURCE_EXTENSIONS: &[&str] = &[
101 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
102 "mdx", "css", "scss", "html", "graphql", "gql",
103];
104
105pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
107 "**/*.test.*",
108 "**/*.spec.*",
109 "**/*.e2e.*",
110 "**/*.e2e-spec.*",
111 "**/*.bench.*",
112 "**/*.fixture.*",
113 "**/*.stories.*",
114 "**/*.story.*",
115 "**/__tests__/**",
116 "**/__mocks__/**",
117 "**/__snapshots__/**",
118 "**/__fixtures__/**",
119 "**/test/**",
120 "**/tests/**",
121 "*.config.*",
122 "**/.*.js",
123 "**/.*.ts",
124 "**/.*.mjs",
125 "**/.*.cjs",
126];
127
128pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
130 ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
131}
132
133fn is_allowed_scoped_hidden_dir(
134 name: &OsStr,
135 path: &Path,
136 additional_hidden_dir_scopes: &[HiddenDirScope],
137) -> bool {
138 additional_hidden_dir_scopes
139 .iter()
140 .any(|scope| scope.allows(path, name))
141}
142
143fn is_allowed_hidden(entry: &ignore::DirEntry) -> bool {
149 is_allowed_hidden_with_scopes(entry, &[])
150}
151
152fn is_allowed_hidden_with_scopes(
153 entry: &ignore::DirEntry,
154 additional_hidden_dir_scopes: &[HiddenDirScope],
155) -> bool {
156 let name = entry.file_name();
157 let name_str = name.to_string_lossy();
158
159 if !name_str.starts_with('.') {
160 return true;
161 }
162
163 if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
164 return true;
165 }
166
167 is_allowed_hidden_dir(name)
168 || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
169}
170
171pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
177 discover_files_with_additional_hidden_dirs(config, &[])
178}
179
180#[expect(
186 clippy::cast_possible_truncation,
187 reason = "file count is bounded by project size, well under u32::MAX"
188)]
189#[expect(
190 clippy::expect_used,
191 reason = "source file globs are hard-coded and the collector lock must remain usable"
192)]
193pub fn discover_files_with_additional_hidden_dirs(
194 config: &ResolvedConfig,
195 additional_hidden_dir_scopes: &[HiddenDirScope],
196) -> Vec<DiscoveredFile> {
197 let _span = tracing::info_span!("discover_files").entered();
198
199 let mut types_builder = ignore::types::TypesBuilder::new();
200 for ext in SOURCE_EXTENSIONS {
201 types_builder
202 .add("source", &format!("*.{ext}"))
203 .expect("valid glob");
204 }
205 types_builder.select("source");
206 let types = types_builder.build().expect("valid types");
207
208 let mut walk_builder = WalkBuilder::new(&config.root);
209 walk_builder
210 .hidden(false)
211 .git_ignore(true)
212 .git_global(true)
213 .git_exclude(true)
214 .types(types)
215 .threads(config.threads);
216 if additional_hidden_dir_scopes.is_empty() {
217 walk_builder.filter_entry(is_allowed_hidden);
218 } else {
219 let scopes = additional_hidden_dir_scopes.to_vec();
220 walk_builder.filter_entry(move |entry| is_allowed_hidden_with_scopes(entry, &scopes));
221 }
222
223 let production_excludes = if config.production {
224 let mut builder = globset::GlobSetBuilder::new();
225 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
226 if let Ok(glob) = globset::GlobBuilder::new(pattern)
227 .literal_separator(true)
228 .build()
229 {
230 builder.add(glob);
231 }
232 }
233 builder.build().ok()
234 } else {
235 None
236 };
237
238 let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
239 let mut visitor_builder = FileVisitorBuilder {
240 root: &config.root,
241 ignore_patterns: &config.ignore_patterns,
242 production_excludes: &production_excludes,
243 shared: &collected,
244 };
245 walk_builder.build_parallel().visit(&mut visitor_builder);
246
247 let mut raw = collected
248 .into_inner()
249 .expect("walk collector lock poisoned");
250 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
251
252 let files: Vec<DiscoveredFile> = raw
253 .into_iter()
254 .enumerate()
255 .map(|(idx, (path, size_bytes))| DiscoveredFile {
256 id: FileId(idx as u32),
257 path,
258 size_bytes,
259 })
260 .collect();
261
262 files
263}
264
265#[cfg(test)]
266mod tests {
267 use std::ffi::OsStr;
268
269 use super::*;
270
271 #[test]
272 fn allowed_hidden_dirs() {
273 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
274 assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
275 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
276 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
277 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
278 }
279
280 #[test]
281 fn disallowed_hidden_dirs() {
282 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
283 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
284 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
285 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
286 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
287 }
288
289 #[test]
290 fn non_hidden_dirs_not_in_allowlist() {
291 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
292 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
293 }
294
295 #[test]
296 fn source_extensions_include_typescript() {
297 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
298 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
299 assert!(SOURCE_EXTENSIONS.contains(&"mts"));
300 assert!(SOURCE_EXTENSIONS.contains(&"cts"));
301 assert!(SOURCE_EXTENSIONS.contains(&"gts"));
302 }
303
304 #[test]
305 fn source_extensions_include_javascript() {
306 assert!(SOURCE_EXTENSIONS.contains(&"js"));
307 assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
308 assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
309 assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
310 assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
311 }
312
313 #[test]
314 fn source_extensions_include_sfc_formats() {
315 assert!(SOURCE_EXTENSIONS.contains(&"vue"));
316 assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
317 assert!(SOURCE_EXTENSIONS.contains(&"astro"));
318 }
319
320 #[test]
321 fn source_extensions_include_styles() {
322 assert!(SOURCE_EXTENSIONS.contains(&"css"));
323 assert!(SOURCE_EXTENSIONS.contains(&"scss"));
324 }
325
326 #[test]
327 fn source_extensions_exclude_non_source() {
328 assert!(!SOURCE_EXTENSIONS.contains(&"json"));
329 assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
330 assert!(!SOURCE_EXTENSIONS.contains(&"md"));
331 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
332 assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
333 }
334
335 #[test]
336 fn source_extensions_include_html() {
337 assert!(SOURCE_EXTENSIONS.contains(&"html"));
338 }
339
340 #[test]
341 fn source_extensions_include_graphql_documents() {
342 assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
343 assert!(SOURCE_EXTENSIONS.contains(&"gql"));
344 }
345
346 fn build_production_glob_set() -> globset::GlobSet {
347 let mut builder = globset::GlobSetBuilder::new();
348 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
349 builder.add(
350 globset::GlobBuilder::new(pattern)
351 .literal_separator(true)
352 .build()
353 .expect("valid glob pattern"),
354 );
355 }
356 builder.build().expect("valid glob set")
357 }
358
359 #[test]
360 fn production_excludes_test_files() {
361 let set = build_production_glob_set();
362 assert!(set.is_match("src/Button.test.ts"));
363 assert!(set.is_match("src/utils.spec.tsx"));
364 assert!(set.is_match("src/__tests__/helper.ts"));
365 assert!(!set.is_match("src/Button.ts"));
366 assert!(!set.is_match("src/utils.tsx"));
367 }
368
369 #[test]
370 fn production_excludes_story_files() {
371 let set = build_production_glob_set();
372 assert!(set.is_match("src/Button.stories.tsx"));
373 assert!(set.is_match("src/Card.story.ts"));
374 assert!(!set.is_match("src/Button.tsx"));
375 }
376
377 #[test]
378 fn production_excludes_config_files_at_root_only() {
379 let set = build_production_glob_set();
380 assert!(set.is_match("vitest.config.ts"));
381 assert!(set.is_match("jest.config.js"));
382 assert!(!set.is_match("src/app/app.config.ts"));
383 assert!(!set.is_match("src/app/app.config.server.ts"));
384 assert!(!set.is_match("packages/foo/vitest.config.ts"));
385 assert!(!set.is_match("src/config.ts"));
386 }
387
388 #[test]
389 fn production_patterns_are_valid_globs() {
390 let _ = build_production_glob_set();
391 }
392
393 #[test]
394 fn disallowed_hidden_dirs_idea() {
395 assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
396 }
397
398 #[test]
399 fn source_extensions_include_mdx() {
400 assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
401 }
402
403 #[test]
404 fn source_extensions_exclude_image_and_data_formats() {
405 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
406 assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
407 assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
408 assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
409 assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
410 assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
411 }
412
413 mod discover_files_integration {
414 use std::path::PathBuf;
415
416 use fallow_config::{
417 DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
418 RulesConfig,
419 };
420
421 use super::*;
422
423 fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
425 FallowConfig {
426 production: production.into(),
427 ..Default::default()
428 }
429 .resolve(root, OutputFormat::Human, 1, true, true, None)
430 }
431
432 fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
435 files
436 .iter()
437 .map(|f| {
438 f.path
439 .strip_prefix(root)
440 .unwrap_or(&f.path)
441 .to_string_lossy()
442 .replace('\\', "/")
443 })
444 .collect()
445 }
446
447 #[test]
448 fn discovers_source_files_with_valid_extensions() {
449 let dir = tempfile::tempdir().expect("create temp dir");
450 let src = dir.path().join("src");
451 std::fs::create_dir_all(&src).unwrap();
452
453 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
454 std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
455 std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
456 std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
457 std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
458 std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
459 std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
460 std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
461
462 let config = make_config(dir.path().to_path_buf(), false);
463 let files = discover_files(&config);
464 let names = file_names(&files, dir.path());
465
466 assert!(names.contains(&"src/app.ts".to_string()));
467 assert!(names.contains(&"src/component.tsx".to_string()));
468 assert!(names.contains(&"src/utils.js".to_string()));
469 assert!(names.contains(&"src/helper.jsx".to_string()));
470 assert!(names.contains(&"src/config.mjs".to_string()));
471 assert!(names.contains(&"src/legacy.cjs".to_string()));
472 assert!(names.contains(&"src/types.mts".to_string()));
473 assert!(names.contains(&"src/compat.cts".to_string()));
474 }
475
476 #[test]
477 fn excludes_non_source_extensions() {
478 let dir = tempfile::tempdir().expect("create temp dir");
479 let src = dir.path().join("src");
480 std::fs::create_dir_all(&src).unwrap();
481
482 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
483
484 std::fs::write(src.join("data.json"), "{}").unwrap();
485 std::fs::write(src.join("readme.md"), "# Hello").unwrap();
486 std::fs::write(src.join("notes.txt"), "notes").unwrap();
487 std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
488
489 let config = make_config(dir.path().to_path_buf(), false);
490 let files = discover_files(&config);
491 let names = file_names(&files, dir.path());
492
493 assert_eq!(names.len(), 1, "only the .ts file should be discovered");
494 assert!(names.contains(&"src/app.ts".to_string()));
495 }
496
497 #[test]
498 fn excludes_disallowed_hidden_directories() {
499 let dir = tempfile::tempdir().expect("create temp dir");
500
501 let git_dir = dir.path().join(".git");
502 std::fs::create_dir_all(&git_dir).unwrap();
503 std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
504
505 let idea_dir = dir.path().join(".idea");
506 std::fs::create_dir_all(&idea_dir).unwrap();
507 std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
508
509 let cache_dir = dir.path().join(".cache");
510 std::fs::create_dir_all(&cache_dir).unwrap();
511 std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
512
513 let src = dir.path().join("src");
514 std::fs::create_dir_all(&src).unwrap();
515 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
516
517 let config = make_config(dir.path().to_path_buf(), false);
518 let files = discover_files(&config);
519 let names = file_names(&files, dir.path());
520
521 assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
522 assert!(names.contains(&"src/app.ts".to_string()));
523 }
524
525 #[test]
526 fn includes_allowed_hidden_directories() {
527 let dir = tempfile::tempdir().expect("create temp dir");
528
529 let storybook = dir.path().join(".storybook");
530 std::fs::create_dir_all(&storybook).unwrap();
531 std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
532
533 let github = dir.path().join(".github");
534 std::fs::create_dir_all(&github).unwrap();
535 std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
536
537 let changeset = dir.path().join(".changeset");
538 std::fs::create_dir_all(&changeset).unwrap();
539 std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
540
541 let config = make_config(dir.path().to_path_buf(), false);
542 let files = discover_files(&config);
543 let names = file_names(&files, dir.path());
544
545 assert!(
546 names.contains(&".storybook/main.ts".to_string()),
547 "files in .storybook should be discovered"
548 );
549 assert!(
550 names.contains(&".github/actions.js".to_string()),
551 "files in .github should be discovered"
552 );
553 assert!(
554 names.contains(&".changeset/config.js".to_string()),
555 "files in .changeset should be discovered"
556 );
557 }
558
559 #[test]
560 fn default_discovery_excludes_client_and_server_hidden_directories() {
561 let dir = tempfile::tempdir().expect("create temp dir");
562 let app = dir.path().join("app");
563 std::fs::create_dir_all(app.join(".client")).unwrap();
564 std::fs::create_dir_all(app.join(".server")).unwrap();
565 std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
566 std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
567 std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
568
569 let config = make_config(dir.path().to_path_buf(), false);
570 let files = discover_files(&config);
571 let names = file_names(&files, dir.path());
572
573 assert!(names.contains(&"app/root.tsx".to_string()));
574 assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
575 assert!(!names.contains(&"app/.server/db.ts".to_string()));
576 }
577
578 #[test]
579 fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
580 let dir = tempfile::tempdir().expect("create temp dir");
581 let package = dir.path().join("packages/app");
582 std::fs::create_dir_all(package.join("app/.client")).unwrap();
583 std::fs::create_dir_all(package.join("app/.server")).unwrap();
584 std::fs::write(
585 package.join("app/.client/analytics.ts"),
586 "export const track = () => {};",
587 )
588 .unwrap();
589 std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
590
591 let config = make_config(dir.path().to_path_buf(), false);
592 let scopes = [HiddenDirScope::new(
593 package,
594 vec![".client".to_string(), ".server".to_string()],
595 )];
596 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
597 let names = file_names(&files, dir.path());
598
599 assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
600 assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
601 }
602
603 #[test]
604 fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
605 let dir = tempfile::tempdir().expect("create temp dir");
606 let active = dir.path().join("packages/active");
607 let inactive = dir.path().join("packages/inactive");
608 std::fs::create_dir_all(active.join("app/.server")).unwrap();
609 std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
610 std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
611 std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
612
613 let config = make_config(dir.path().to_path_buf(), false);
614 let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
615 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
616 let names = file_names(&files, dir.path());
617
618 assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
619 assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
620 }
621
622 #[test]
623 fn excludes_root_build_directory() {
624 let dir = tempfile::tempdir().expect("create temp dir");
625
626 std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
627
628 let build_dir = dir.path().join("build");
629 std::fs::create_dir_all(&build_dir).unwrap();
630 std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
631
632 let src = dir.path().join("src");
633 std::fs::create_dir_all(&src).unwrap();
634 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
635
636 let config = make_config(dir.path().to_path_buf(), false);
637 let files = discover_files(&config);
638 let names = file_names(&files, dir.path());
639
640 assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
641 assert!(names.contains(&"src/app.ts".to_string()));
642 }
643
644 #[test]
645 fn includes_nested_build_directory() {
646 let dir = tempfile::tempdir().expect("create temp dir");
647
648 let nested_build = dir.path().join("src").join("build");
649 std::fs::create_dir_all(&nested_build).unwrap();
650 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
651
652 let config = make_config(dir.path().to_path_buf(), false);
653 let files = discover_files(&config);
654 let names = file_names(&files, dir.path());
655
656 assert!(
657 names.contains(&"src/build/helper.ts".to_string()),
658 "nested build/ directories should be included"
659 );
660 }
661
662 #[test]
663 #[expect(
664 clippy::cast_possible_truncation,
665 reason = "test file counts are trivially small"
666 )]
667 fn file_ids_are_sequential_after_sorting() {
668 let dir = tempfile::tempdir().expect("create temp dir");
669 let src = dir.path().join("src");
670 std::fs::create_dir_all(&src).unwrap();
671
672 std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
673 std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
674 std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
675
676 let config = make_config(dir.path().to_path_buf(), false);
677 let files = discover_files(&config);
678
679 for (idx, file) in files.iter().enumerate() {
680 assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
681 }
682
683 for pair in files.windows(2) {
684 assert!(
685 pair[0].path < pair[1].path,
686 "files should be sorted by path"
687 );
688 }
689 }
690
691 #[test]
692 fn production_mode_excludes_test_files() {
693 let dir = tempfile::tempdir().expect("create temp dir");
694 let src = dir.path().join("src");
695 std::fs::create_dir_all(&src).unwrap();
696
697 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
698 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
699 std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
700 std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
701
702 let config = make_config(dir.path().to_path_buf(), true);
703 let files = discover_files(&config);
704 let names = file_names(&files, dir.path());
705
706 assert!(
707 names.contains(&"src/app.ts".to_string()),
708 "source files should be included in production mode"
709 );
710 assert!(
711 !names.contains(&"src/app.test.ts".to_string()),
712 "test files should be excluded in production mode"
713 );
714 assert!(
715 !names.contains(&"src/app.spec.ts".to_string()),
716 "spec files should be excluded in production mode"
717 );
718 assert!(
719 !names.contains(&"src/app.stories.tsx".to_string()),
720 "story files should be excluded in production mode"
721 );
722 }
723
724 #[test]
725 fn non_production_mode_includes_test_files() {
726 let dir = tempfile::tempdir().expect("create temp dir");
727 let src = dir.path().join("src");
728 std::fs::create_dir_all(&src).unwrap();
729
730 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
731 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
732
733 let config = make_config(dir.path().to_path_buf(), false);
734 let files = discover_files(&config);
735 let names = file_names(&files, dir.path());
736
737 assert!(names.contains(&"src/app.ts".to_string()));
738 assert!(
739 names.contains(&"src/app.test.ts".to_string()),
740 "test files should be included in non-production mode"
741 );
742 }
743
744 #[test]
745 fn empty_directory_returns_no_files() {
746 let dir = tempfile::tempdir().expect("create temp dir");
747 let config = make_config(dir.path().to_path_buf(), false);
748 let files = discover_files(&config);
749 assert!(files.is_empty(), "empty project should discover no files");
750 }
751
752 #[test]
753 fn hidden_files_not_discovered_as_source() {
754 let dir = tempfile::tempdir().expect("create temp dir");
755
756 std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
757 std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
758 std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
759
760 let src = dir.path().join("src");
761 std::fs::create_dir_all(&src).unwrap();
762 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
763
764 let config = make_config(dir.path().to_path_buf(), false);
765 let files = discover_files(&config);
766 let names = file_names(&files, dir.path());
767
768 assert!(
769 !names.contains(&".env".to_string()),
770 ".env should not be discovered"
771 );
772 assert!(
773 !names.contains(&".gitignore".to_string()),
774 ".gitignore should not be discovered"
775 );
776 }
777
778 fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
780 FallowConfig {
781 schema: None,
782 extends: vec![],
783 entry: vec![],
784 ignore_patterns: ignores,
785 framework: vec![],
786 workspaces: None,
787 ignore_dependencies: vec![],
788 ignore_unresolved_imports: vec![],
789 ignore_exports: vec![],
790 ignore_catalog_references: vec![],
791 ignore_dependency_overrides: vec![],
792 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
793 ),
794 used_class_members: vec![],
795 ignore_decorators: vec![],
796 duplicates: DuplicatesConfig::default(),
797 health: HealthConfig::default(),
798 rules: RulesConfig::default(),
799 boundaries: fallow_config::BoundaryConfig::default(),
800 production: false.into(),
801 plugins: vec![],
802 dynamically_loaded: vec![],
803 overrides: vec![],
804 regression: None,
805 audit: fallow_config::AuditConfig::default(),
806 codeowners: None,
807 public_packages: vec![],
808 flags: FlagsConfig::default(),
809 security: fallow_config::SecurityConfig::default(),
810 fix: fallow_config::FixConfig::default(),
811 resolve: ResolveConfig::default(),
812 sealed: false,
813 include_entry_exports: false,
814 auto_imports: false,
815 cache: fallow_config::CacheConfig::default(),
816 }
817 .resolve(root, OutputFormat::Human, 1, true, true, None)
818 }
819
820 #[test]
821 fn custom_ignore_patterns_exclude_matching_files() {
822 let dir = tempfile::tempdir().expect("create temp dir");
823
824 let generated = dir.path().join("src").join("api").join("generated");
825 std::fs::create_dir_all(&generated).unwrap();
826 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
827
828 let client = dir.path().join("src").join("api").join("client");
829 std::fs::create_dir_all(&client).unwrap();
830 std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
831
832 let src = dir.path().join("src");
833 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
834
835 let config = make_config_with_ignores(
836 dir.path().to_path_buf(),
837 vec![
838 "src/api/generated/**".to_string(),
839 "src/api/client/**".to_string(),
840 ],
841 );
842 let files = discover_files(&config);
843 let names = file_names(&files, dir.path());
844
845 assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
846 assert!(names.contains(&"src/index.ts".to_string()));
847 }
848
849 #[test]
850 fn default_ignore_patterns_exclude_node_modules_and_dist() {
851 let dir = tempfile::tempdir().expect("create temp dir");
852
853 let nm = dir.path().join("node_modules").join("lodash");
854 std::fs::create_dir_all(&nm).unwrap();
855 std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
856
857 let dist = dir.path().join("dist");
858 std::fs::create_dir_all(&dist).unwrap();
859 std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
860
861 let src = dir.path().join("src");
862 std::fs::create_dir_all(&src).unwrap();
863 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
864
865 let config = make_config(dir.path().to_path_buf(), false);
866 let files = discover_files(&config);
867 let names = file_names(&files, dir.path());
868
869 assert_eq!(names.len(), 1);
870 assert!(names.contains(&"src/index.ts".to_string()));
871 }
872
873 #[test]
874 fn default_ignore_patterns_exclude_root_build() {
875 let dir = tempfile::tempdir().expect("create temp dir");
876
877 let build = dir.path().join("build");
878 std::fs::create_dir_all(&build).unwrap();
879 std::fs::write(build.join("output.js"), "// built").unwrap();
880
881 let nested_build = dir.path().join("src").join("build");
882 std::fs::create_dir_all(&nested_build).unwrap();
883 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
884
885 let src = dir.path().join("src");
886 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
887
888 let config = make_config(dir.path().to_path_buf(), false);
889 let files = discover_files(&config);
890 let names = file_names(&files, dir.path());
891
892 assert_eq!(
893 names.len(),
894 2,
895 "root build/ excluded, nested kept: {names:?}"
896 );
897 assert!(names.contains(&"src/index.ts".to_string()));
898 assert!(names.contains(&"src/build/helper.ts".to_string()));
899 }
900 }
901}