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