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    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
76/// Builder that creates per-thread `FileVisitor` instances for the parallel walker.
77struct 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
101/// Glob patterns for test/dev/story files excluded in production mode.
102pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
103    // Test files
104    "**/*.test.*",
105    "**/*.spec.*",
106    "**/*.e2e.*",
107    "**/*.e2e-spec.*",
108    "**/*.bench.*",
109    "**/*.fixture.*",
110    // Story files
111    "**/*.stories.*",
112    "**/*.story.*",
113    // Test directories
114    "**/__tests__/**",
115    "**/__mocks__/**",
116    "**/__snapshots__/**",
117    "**/__fixtures__/**",
118    "**/test/**",
119    "**/tests/**",
120    // Dev/config files at project root only (not nested src/ files like Angular's app.config.ts)
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    // Not hidden — always allow
160    if !name_str.starts_with('.') {
161        return true;
162    }
163
164    // Hidden files are fine — the type filter (source extensions) will handle them
165    if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
166        return true;
167    }
168
169    // Hidden directory — check against the allowlist
170    is_allowed_hidden_dir(name)
171        || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
172}
173
174/// Discover all source files in the project.
175///
176/// # Panics
177///
178/// Panics if the file type glob or progress template is invalid (compile-time constants).
179pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
180    discover_files_with_additional_hidden_dirs(config, &[])
181}
182
183/// Discover all source files in the project, with package-scoped hidden dirs.
184///
185/// # Panics
186///
187/// Panics if the file type glob or progress template is invalid (compile-time constants).
188#[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    // Build production exclude matcher if needed
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    // Parallel filesystem walk — uses work-stealing across config.threads threads.
239    // `build_parallel()` honors the `.threads()` setting (unlike sequential `build()`).
240    // Each thread collects results into a local buffer, flushed on drop to avoid
241    // per-entry Mutex contention.
242    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    // Sort by path for stable, deterministic FileId assignment.
252    // The same set of files always produces the same IDs regardless of file
253    // size changes, which is the foundation for incremental analysis and
254    // cross-run graph caching.
255    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    // is_allowed_hidden_dir tests
280    #[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        // Non-hidden names should not match the allowlist (they are always allowed
301        // by is_allowed_hidden because they don't start with '.')
302        assert!(!is_allowed_hidden_dir(OsStr::new("src")));
303        assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
304    }
305
306    // SOURCE_EXTENSIONS tests
307    #[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    // PRODUCTION_EXCLUDE_PATTERNS tests — verify actual glob matching, not just string contains
359    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        // Non-test files should NOT match
379        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        // Non-story files should NOT match
389        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        // Root-level tool configs should match
396        assert!(set.is_match("vitest.config.ts"));
397        assert!(set.is_match("jest.config.js"));
398        // Nested config files should NOT match (e.g. Angular app.config.ts)
399        assert!(!set.is_match("src/app/app.config.ts"));
400        assert!(!set.is_match("src/app/app.config.server.ts"));
401        // Workspace-level tool configs are no longer excluded (acceptable trade-off)
402        assert!(!set.is_match("packages/foo/vitest.config.ts"));
403        // Source files should NOT match
404        assert!(!set.is_match("src/config.ts"));
405    }
406
407    #[test]
408    fn production_patterns_are_valid_globs() {
409        // build_production_glob_set() already validates all patterns compile
410        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    // discover_files integration tests using tempdir fixtures
434    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        /// Create a minimal ResolvedConfig pointing at the given root directory.
445        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, None)
451        }
452
453        /// Helper to collect discovered file names (relative to root) for assertions.
454        /// Normalizes path separators to `/` for cross-platform test consistency.
455        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            // Source files that should be discovered
475            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            // Source file to ensure discovery works at all
505            std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
506
507            // Non-source files that should be excluded
508            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            // Files inside disallowed hidden directories
526            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            // A normal source file to confirm discovery works
539            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            // Files inside allowed hidden directories
556            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            // The `ignore` crate respects `.ignore` files (independent of git).
654            // Use this to simulate build/ exclusion as it happens in real projects.
655            std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
656
657            // Root-level build/ should be ignored
658            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            // Normal source file
663            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            // Nested build/ directory should NOT be ignored
680            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            // IDs should be sequential 0, 1, 2
712            for (idx, file) in files.iter().enumerate() {
713                assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
714            }
715
716            // Files should be sorted by path
717            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            // Hidden files at root — these have source extensions but are dotfiles.
791            // The type filter (`*.ts`, not `.*ts`) will exclude them because the
792            // `ignore` crate's type matcher only matches non-hidden filenames.
793            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            // Normal source file
798            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        /// Create a config with custom ignore patterns.
817        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                ignore_decorators: vec![],
833                duplicates: DuplicatesConfig::default(),
834                health: HealthConfig::default(),
835                rules: RulesConfig::default(),
836                boundaries: fallow_config::BoundaryConfig::default(),
837                production: false.into(),
838                plugins: vec![],
839                dynamically_loaded: vec![],
840                overrides: vec![],
841                regression: None,
842                audit: fallow_config::AuditConfig::default(),
843                codeowners: None,
844                public_packages: vec![],
845                flags: FlagsConfig::default(),
846                fix: fallow_config::FixConfig::default(),
847                resolve: ResolveConfig::default(),
848                sealed: false,
849                include_entry_exports: false,
850                cache: fallow_config::CacheConfig::default(),
851            }
852            .resolve(root, OutputFormat::Human, 1, true, true, None)
853        }
854
855        #[test]
856        fn custom_ignore_patterns_exclude_matching_files() {
857            let dir = tempfile::tempdir().expect("create temp dir");
858
859            let generated = dir.path().join("src").join("api").join("generated");
860            std::fs::create_dir_all(&generated).unwrap();
861            std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
862
863            let client = dir.path().join("src").join("api").join("client");
864            std::fs::create_dir_all(&client).unwrap();
865            std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
866
867            let src = dir.path().join("src");
868            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
869
870            let config = make_config_with_ignores(
871                dir.path().to_path_buf(),
872                vec![
873                    "src/api/generated/**".to_string(),
874                    "src/api/client/**".to_string(),
875                ],
876            );
877            let files = discover_files(&config);
878            let names = file_names(&files, dir.path());
879
880            assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
881            assert!(names.contains(&"src/index.ts".to_string()));
882        }
883
884        #[test]
885        fn default_ignore_patterns_exclude_node_modules_and_dist() {
886            let dir = tempfile::tempdir().expect("create temp dir");
887
888            let nm = dir.path().join("node_modules").join("lodash");
889            std::fs::create_dir_all(&nm).unwrap();
890            std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
891
892            let dist = dir.path().join("dist");
893            std::fs::create_dir_all(&dist).unwrap();
894            std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
895
896            let src = dir.path().join("src");
897            std::fs::create_dir_all(&src).unwrap();
898            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
899
900            let config = make_config(dir.path().to_path_buf(), false);
901            let files = discover_files(&config);
902            let names = file_names(&files, dir.path());
903
904            assert_eq!(names.len(), 1);
905            assert!(names.contains(&"src/index.ts".to_string()));
906        }
907
908        #[test]
909        fn default_ignore_patterns_exclude_root_build() {
910            let dir = tempfile::tempdir().expect("create temp dir");
911
912            // Root-level build/ should be excluded
913            let build = dir.path().join("build");
914            std::fs::create_dir_all(&build).unwrap();
915            std::fs::write(build.join("output.js"), "// built").unwrap();
916
917            // Nested build/ should NOT be excluded
918            let nested_build = dir.path().join("src").join("build");
919            std::fs::create_dir_all(&nested_build).unwrap();
920            std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
921
922            let src = dir.path().join("src");
923            std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
924
925            let config = make_config(dir.path().to_path_buf(), false);
926            let files = discover_files(&config);
927            let names = file_names(&files, dir.path());
928
929            assert_eq!(
930                names.len(),
931                2,
932                "root build/ excluded, nested kept: {names:?}"
933            );
934            assert!(names.contains(&"src/index.ts".to_string()));
935            assert!(names.contains(&"src/build/helper.ts".to_string()));
936        }
937    }
938}