Skip to main content

fallow_core/discover/
walk.rs

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/// Package-scoped hidden directories that source discovery should traverse.
12#[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
28/// Per-thread file collector for the parallel walker.
29struct 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
80/// Builder that creates per-thread `FileVisitor` instances for the parallel walker.
81struct 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
105/// Glob patterns for test/dev/story files excluded in production mode.
106pub 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
128/// Check if a hidden directory name is on the allowlist.
129pub 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
143/// Check if a hidden directory entry should be allowed through the filter.
144///
145/// Returns `true` if the entry is not hidden or is on the allowlist.
146/// Hidden files (not directories) are always allowed through since the type
147/// filter handles them.
148fn 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
171/// Discover all source files in the project.
172///
173/// # Panics
174///
175/// Panics if the file type glob or progress template is invalid (compile-time constants).
176pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
177    discover_files_with_additional_hidden_dirs(config, &[])
178}
179
180/// Discover all source files in the project, with package-scoped hidden dirs.
181///
182/// # Panics
183///
184/// Panics if the file type glob or progress template is invalid (compile-time constants).
185#[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        /// Create a minimal ResolvedConfig pointing at the given root directory.
424        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        /// Helper to collect discovered file names (relative to root) for assertions.
433        /// Normalizes path separators to `/` for cross-platform test consistency.
434        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        /// Create a config with custom ignore patterns.
779        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}