1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::sync::{Mutex, OnceLock};
4
5use fallow_config::{ResolvedConfig, WorkspaceDiagnostic, WorkspaceDiagnosticKind};
6use fallow_types::discover::{DiscoveredFile, FileId};
7use ignore::WalkBuilder;
8use rustc_hash::FxHashSet;
9
10use super::ALLOWED_HIDDEN_DIRS;
11
12fn should_emit_note_once(key: String) -> bool {
18 static EMITTED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
19 EMITTED
20 .get_or_init(|| Mutex::new(FxHashSet::default()))
21 .lock()
22 .map_or(true, |mut set| set.insert(key))
23}
24
25type SizedFile = (PathBuf, u64);
28
29const NOTE_EXAMPLE_CAP: usize = 5;
33
34const LARGE_SET_THRESHOLD: usize = 20_000;
38
39const LARGE_FILE_NOTE_BYTES: u64 = 4 * 1024 * 1024;
44
45const NOTE_FILE_FLOOR_BYTES: u64 = 256 * 1024;
49
50const MINIFIED_FILE_SKIP_BYTES: u64 = 1024 * 1024;
54
55const MINIFIED_SAMPLE_BYTES: usize = 256 * 1024;
57
58const MINIFIED_LONG_LINE_BYTES: usize = 128 * 1024;
61
62fn is_declaration_file(path: &Path) -> bool {
67 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
68 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
69}
70
71fn is_plain_js_file(path: &Path) -> bool {
72 matches!(
73 path.extension().and_then(|ext| ext.to_str()),
74 Some("js" | "mjs" | "cjs")
75 )
76}
77
78fn has_minified_line_shape(path: &Path) -> bool {
79 use std::io::Read;
80
81 let Ok(mut file) = std::fs::File::open(path) else {
82 return false;
83 };
84 let mut sample = vec![0; MINIFIED_SAMPLE_BYTES];
85 let Ok(len) = file.read(&mut sample) else {
86 return false;
87 };
88 sample.truncate(len);
89 if sample.is_empty() {
90 return false;
91 }
92
93 let mut current_line = 0usize;
94 for byte in sample {
95 if byte == b'\n' || byte == b'\r' {
96 current_line = 0;
97 continue;
98 }
99 current_line += 1;
100 if current_line >= MINIFIED_LONG_LINE_BYTES {
101 return true;
102 }
103 }
104 false
105}
106
107fn is_probably_minified_generated_js(path: &Path, size_bytes: u64) -> bool {
108 size_bytes >= MINIFIED_FILE_SKIP_BYTES
109 && is_plain_js_file(path)
110 && !is_declaration_file(path)
111 && has_minified_line_shape(path)
112}
113
114fn format_size_mb(bytes: u64) -> String {
116 #[expect(
117 clippy::cast_precision_loss,
118 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
119 )]
120 let mb = bytes as f64 / (1024.0 * 1024.0);
121 format!("{mb:.1} MB")
122}
123
124fn summarize_examples(root: &Path, examples: &[SizedFile]) -> String {
127 let shown: Vec<String> = examples
128 .iter()
129 .take(NOTE_EXAMPLE_CAP)
130 .map(|(path, size)| {
131 let display = path
132 .strip_prefix(root)
133 .unwrap_or(path)
134 .display()
135 .to_string()
136 .replace('\\', "/");
137 format!("{display} ({})", format_size_mb(*size))
138 })
139 .collect();
140 let remaining = examples.len().saturating_sub(NOTE_EXAMPLE_CAP);
141 if remaining > 0 {
142 format!("{}, and {remaining} more", shown.join(", "))
143 } else {
144 shown.join(", ")
145 }
146}
147
148fn partition_by_size(
151 raw: Vec<SizedFile>,
152 max_file_size_bytes: Option<u64>,
153) -> (Vec<SizedFile>, Vec<SizedFile>) {
154 let Some(limit) = max_file_size_bytes else {
155 return (raw, Vec::new());
156 };
157 raw.into_iter()
158 .partition(|(path, size)| *size <= limit || is_declaration_file(path))
159}
160
161fn partition_minified_generated_js(
164 raw: Vec<SizedFile>,
165 max_file_size_bytes: Option<u64>,
166) -> (Vec<SizedFile>, Vec<SizedFile>) {
167 if max_file_size_bytes.is_none() {
168 return (raw, Vec::new());
169 }
170 raw.into_iter()
171 .partition(|(path, size)| !is_probably_minified_generated_js(path, *size))
172}
173
174fn report_skipped_large_files(config: &ResolvedConfig, skipped: &[SizedFile]) {
179 if skipped.is_empty() {
180 return;
181 }
182 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
183 .iter()
184 .map(|(path, size_bytes)| {
185 WorkspaceDiagnostic::new(
186 &config.root,
187 path.clone(),
188 WorkspaceDiagnosticKind::SkippedLargeFile {
189 size_bytes: *size_bytes,
190 },
191 )
192 })
193 .collect();
194 fallow_config::append_workspace_diagnostics(&config.root, diagnostics);
195
196 let mut sorted: Vec<SizedFile> = skipped.to_vec();
197 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
198 let count = skipped.len();
199 if !config.quiet
200 && should_emit_note_once(format!(
201 "skip::{}::{count}::{}",
202 config.root.display(),
203 sorted.first().map_or(0, |f| f.1)
204 ))
205 {
206 let examples = summarize_examples(&config.root, &sorted);
207 let noun = if count == 1 { "file" } else { "files" };
208 tracing::warn!(
209 "fallow: skipped {count} {noun} over the max file size limit ({examples}). \
210 Raise the limit with --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add them to ignorePatterns."
211 );
212 }
213}
214
215fn report_skipped_minified_files(config: &ResolvedConfig, skipped: &[SizedFile]) {
217 if skipped.is_empty() {
218 return;
219 }
220 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
221 .iter()
222 .map(|(path, size_bytes)| {
223 WorkspaceDiagnostic::new(
224 &config.root,
225 path.clone(),
226 WorkspaceDiagnosticKind::SkippedMinifiedFile {
227 size_bytes: *size_bytes,
228 },
229 )
230 })
231 .collect();
232 fallow_config::append_workspace_diagnostics(&config.root, diagnostics);
233
234 let mut sorted: Vec<SizedFile> = skipped.to_vec();
235 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
236 let count = skipped.len();
237 if !config.quiet
238 && should_emit_note_once(format!(
239 "minified::{}::{count}::{}",
240 config.root.display(),
241 sorted.first().map_or(0, |f| f.1)
242 ))
243 {
244 let examples = summarize_examples(&config.root, &sorted);
245 let noun = if count == 1 { "file" } else { "files" };
246 let pronoun = if count == 1 { "it" } else { "them" };
247 tracing::warn!(
248 "fallow: skipped {count} minified generated JS {noun} ({examples}). \
249 Add {pronoun} to ignorePatterns, rename {pronoun} with a .min.js suffix, or use --max-file-size 0 to analyze {pronoun}."
250 );
251 }
252}
253
254fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
259 if files.is_empty() {
260 return None;
261 }
262 let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
263 if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
264 return None;
265 }
266 let count = files.len();
267 let noun = if count == 1 { "file" } else { "files" };
268 let mut by_size: Vec<SizedFile> = files
269 .iter()
270 .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
271 .map(|f| (f.path.clone(), f.size_bytes))
272 .collect();
273 by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
274 if by_size.is_empty() {
275 return Some(format!(
278 "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
279 exclude large generated files via ignorePatterns or --max-file-size."
280 ));
281 }
282 let examples = summarize_examples(root, &by_size);
283 Some(format!(
284 "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
285 exclude large generated files via ignorePatterns or --max-file-size."
286 ))
287}
288
289fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
294 if config.quiet {
295 return;
296 }
297 if let Some(message) = build_largest_files_note(&config.root, files)
298 && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
299 {
300 tracing::warn!("{message}");
301 }
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct HiddenDirScope {
307 root: PathBuf,
308 dirs: Vec<String>,
309}
310
311impl HiddenDirScope {
312 pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
313 Self { root, dirs }
314 }
315
316 fn allows(&self, path: &Path, name: &OsStr) -> bool {
317 path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
318 }
319}
320
321struct FileVisitor<'a> {
328 root: &'a Path,
329 ignore_patterns: &'a globset::GlobSet,
330 production_excludes: &'a Option<globset::GlobSet>,
331 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
332 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
333 local: Vec<(std::path::PathBuf, u64)>,
334 config_local: Vec<std::path::PathBuf>,
335}
336
337impl ignore::ParallelVisitor for FileVisitor<'_> {
338 fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
339 let Ok(entry) = result else {
340 return ignore::WalkState::Continue;
341 };
342 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
343 return ignore::WalkState::Continue;
344 }
345 let relative = entry
346 .path()
347 .strip_prefix(self.root)
348 .unwrap_or_else(|_| entry.path());
349 if self.ignore_patterns.is_match(relative) {
350 return ignore::WalkState::Continue;
351 }
352 if self
353 .production_excludes
354 .as_ref()
355 .is_some_and(|excludes| excludes.is_match(relative))
356 {
357 return ignore::WalkState::Continue;
358 }
359 if has_source_extension(entry.path()) {
360 let size_bytes = entry.metadata().map_or(0, |m| m.len());
361 self.local.push((entry.into_path(), size_bytes));
362 } else if self.config_shared.is_some() {
363 self.config_local.push(entry.into_path());
366 }
367 ignore::WalkState::Continue
368 }
369}
370
371impl Drop for FileVisitor<'_> {
372 #[expect(
373 clippy::expect_used,
374 reason = "poisoned walk collector lock means worker state is unrecoverable"
375 )]
376 fn drop(&mut self) {
377 if !self.local.is_empty() {
378 self.shared
379 .lock()
380 .expect("walk collector lock poisoned")
381 .append(&mut self.local);
382 }
383 if let Some(config_shared) = self.config_shared
384 && !self.config_local.is_empty()
385 {
386 config_shared
387 .lock()
388 .expect("walk config collector lock poisoned")
389 .append(&mut self.config_local);
390 }
391 }
392}
393
394struct FileVisitorBuilder<'a> {
396 root: &'a Path,
397 ignore_patterns: &'a globset::GlobSet,
398 production_excludes: &'a Option<globset::GlobSet>,
399 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
400 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
401}
402
403impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
404 fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
405 Box::new(FileVisitor {
406 root: self.root,
407 ignore_patterns: self.ignore_patterns,
408 production_excludes: self.production_excludes,
409 shared: self.shared,
410 config_shared: self.config_shared,
411 local: Vec::new(),
412 config_local: Vec::new(),
413 })
414 }
415}
416
417pub const SOURCE_EXTENSIONS: &[&str] = &[
418 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
419 "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
420];
421
422pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
424 "**/*.test.*",
425 "**/*.spec.*",
426 "**/*.e2e.*",
427 "**/*.e2e-spec.*",
428 "**/*.bench.*",
429 "**/*.fixture.*",
430 "**/*.stories.*",
431 "**/*.story.*",
432 "**/__tests__/**",
433 "**/__mocks__/**",
434 "**/__snapshots__/**",
435 "**/__fixtures__/**",
436 "**/test/**",
437 "**/tests/**",
438 "*.config.*",
439 "**/.*.js",
440 "**/.*.ts",
441 "**/.*.mjs",
442 "**/.*.cjs",
443];
444
445pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
447 ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
448}
449
450fn is_allowed_scoped_hidden_dir(
451 name: &OsStr,
452 path: &Path,
453 additional_hidden_dir_scopes: &[HiddenDirScope],
454) -> bool {
455 additional_hidden_dir_scopes
456 .iter()
457 .any(|scope| scope.allows(path, name))
458}
459
460fn is_allowed_hidden(entry: &ignore::DirEntry) -> bool {
466 is_allowed_hidden_with_scopes(entry, &[])
467}
468
469fn is_allowed_hidden_with_scopes(
470 entry: &ignore::DirEntry,
471 additional_hidden_dir_scopes: &[HiddenDirScope],
472) -> bool {
473 let name = entry.file_name();
474 let name_str = name.to_string_lossy();
475
476 if !name_str.starts_with('.') {
477 return true;
478 }
479
480 if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
481 return true;
482 }
483
484 is_allowed_hidden_dir(name)
485 || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
486}
487
488pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
494 discover_files_with_additional_hidden_dirs(config, &[])
495}
496
497fn config_candidate_basename_globs() -> &'static [String] {
509 static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
510 GLOBS.get_or_init(|| {
511 let mut set: FxHashSet<String> = FxHashSet::default();
512 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
513 for pattern in plugin.config_patterns() {
514 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
515 set.insert(basename.to_string());
516 }
517 }
518 let mut globs: Vec<String> = set.into_iter().collect();
519 globs.sort_unstable();
520 globs
521 })
522}
523
524fn has_source_extension(path: &Path) -> bool {
527 path.extension()
528 .and_then(OsStr::to_str)
529 .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
530}
531
532#[expect(
536 clippy::expect_used,
537 reason = "source file globs are hard-coded compile-time constants"
538)]
539fn build_walk_types(capture_config: bool) -> ignore::types::Types {
540 let mut types_builder = ignore::types::TypesBuilder::new();
541 for ext in SOURCE_EXTENSIONS {
542 types_builder
543 .add("source", &format!("*.{ext}"))
544 .expect("valid glob");
545 }
546 types_builder.select("source");
547 if capture_config {
548 for glob in config_candidate_basename_globs() {
549 let _ = types_builder.add("config", glob);
553 }
554 types_builder.select("config");
555 }
556 types_builder.build().expect("valid types")
557}
558
559fn build_source_walk_builder(
563 config: &ResolvedConfig,
564 additional_hidden_dir_scopes: &[HiddenDirScope],
565 capture_config: bool,
566) -> WalkBuilder {
567 let mut walk_builder = WalkBuilder::new(&config.root);
568 walk_builder
569 .hidden(false)
570 .git_ignore(true)
571 .git_global(true)
572 .git_exclude(true)
573 .types(build_walk_types(capture_config))
574 .threads(config.threads);
575 if additional_hidden_dir_scopes.is_empty() {
576 walk_builder.filter_entry(is_allowed_hidden);
577 } else {
578 let scopes = additional_hidden_dir_scopes.to_vec();
579 walk_builder.filter_entry(move |entry| is_allowed_hidden_with_scopes(entry, &scopes));
580 }
581 walk_builder
582}
583
584fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
586 if !config.production {
587 return None;
588 }
589 let mut builder = globset::GlobSetBuilder::new();
590 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
591 if let Ok(glob) = globset::GlobBuilder::new(pattern)
592 .literal_separator(true)
593 .build()
594 {
595 builder.add(glob);
596 }
597 }
598 builder.build().ok()
599}
600
601pub fn discover_files_with_additional_hidden_dirs(
607 config: &ResolvedConfig,
608 additional_hidden_dir_scopes: &[HiddenDirScope],
609) -> Vec<DiscoveredFile> {
610 discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
611}
612
613#[expect(
629 clippy::cast_possible_truncation,
630 reason = "file count is bounded by project size, well under u32::MAX"
631)]
632#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
633pub fn discover_files_and_config_candidates(
634 config: &ResolvedConfig,
635 additional_hidden_dir_scopes: &[HiddenDirScope],
636) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
637 let _span = tracing::info_span!("discover_files").entered();
638
639 let capture_config = !config.production;
640 let walk_builder =
641 build_source_walk_builder(config, additional_hidden_dir_scopes, capture_config);
642 let production_excludes = build_production_excludes(config);
643
644 let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
645 let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
646 let mut visitor_builder = FileVisitorBuilder {
647 root: &config.root,
648 ignore_patterns: &config.ignore_patterns,
649 production_excludes: &production_excludes,
650 shared: &collected,
651 config_shared: capture_config.then_some(&config_collected),
652 };
653 walk_builder.build_parallel().visit(&mut visitor_builder);
654
655 let mut raw = collected
656 .into_inner()
657 .expect("walk collector lock poisoned");
658 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
659
660 let mut config_candidates = config_collected
661 .into_inner()
662 .expect("walk config collector lock poisoned");
663 config_candidates.sort_unstable();
664
665 fallow_config::clear_source_discovery_diagnostics(&config.root);
669 let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
670 report_skipped_large_files(config, &skipped);
671 let (kept, skipped_minified) =
672 partition_minified_generated_js(kept, config.max_file_size_bytes);
673 report_skipped_minified_files(config, &skipped_minified);
674
675 let files: Vec<DiscoveredFile> = kept
676 .into_iter()
677 .enumerate()
678 .map(|(idx, (path, size_bytes))| DiscoveredFile {
679 id: FileId(idx as u32),
680 path,
681 size_bytes,
682 })
683 .collect();
684
685 note_largest_files(config, &files);
686
687 (files, config_candidates)
688}
689
690#[cfg(test)]
691mod tests {
692 use std::ffi::OsStr;
693
694 use super::*;
695
696 #[test]
697 fn allowed_hidden_dirs() {
698 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
699 assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
700 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
701 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
702 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
703 }
704
705 #[test]
706 fn disallowed_hidden_dirs() {
707 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
708 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
709 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
710 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
711 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
712 }
713
714 #[test]
715 fn non_hidden_dirs_not_in_allowlist() {
716 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
717 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
718 }
719
720 #[test]
721 fn source_extensions_include_typescript() {
722 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
723 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
724 assert!(SOURCE_EXTENSIONS.contains(&"mts"));
725 assert!(SOURCE_EXTENSIONS.contains(&"cts"));
726 assert!(SOURCE_EXTENSIONS.contains(&"gts"));
727 }
728
729 #[test]
730 fn source_extensions_include_javascript() {
731 assert!(SOURCE_EXTENSIONS.contains(&"js"));
732 assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
733 assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
734 assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
735 assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
736 }
737
738 #[test]
739 fn source_extensions_include_sfc_formats() {
740 assert!(SOURCE_EXTENSIONS.contains(&"vue"));
741 assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
742 assert!(SOURCE_EXTENSIONS.contains(&"astro"));
743 }
744
745 #[test]
746 fn source_extensions_include_styles() {
747 assert!(SOURCE_EXTENSIONS.contains(&"css"));
748 assert!(SOURCE_EXTENSIONS.contains(&"scss"));
749 assert!(SOURCE_EXTENSIONS.contains(&"sass"));
750 assert!(SOURCE_EXTENSIONS.contains(&"less"));
751 }
752
753 #[test]
754 fn source_extensions_exclude_non_source() {
755 assert!(!SOURCE_EXTENSIONS.contains(&"json"));
756 assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
757 assert!(!SOURCE_EXTENSIONS.contains(&"md"));
758 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
759 assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
760 }
761
762 #[test]
763 fn source_extensions_include_html() {
764 assert!(SOURCE_EXTENSIONS.contains(&"html"));
765 }
766
767 #[test]
768 fn source_extensions_include_graphql_documents() {
769 assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
770 assert!(SOURCE_EXTENSIONS.contains(&"gql"));
771 }
772
773 fn build_production_glob_set() -> globset::GlobSet {
774 let mut builder = globset::GlobSetBuilder::new();
775 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
776 builder.add(
777 globset::GlobBuilder::new(pattern)
778 .literal_separator(true)
779 .build()
780 .expect("valid glob pattern"),
781 );
782 }
783 builder.build().expect("valid glob set")
784 }
785
786 #[test]
787 fn production_excludes_test_files() {
788 let set = build_production_glob_set();
789 assert!(set.is_match("src/Button.test.ts"));
790 assert!(set.is_match("src/utils.spec.tsx"));
791 assert!(set.is_match("src/__tests__/helper.ts"));
792 assert!(!set.is_match("src/Button.ts"));
793 assert!(!set.is_match("src/utils.tsx"));
794 }
795
796 #[test]
797 fn production_excludes_story_files() {
798 let set = build_production_glob_set();
799 assert!(set.is_match("src/Button.stories.tsx"));
800 assert!(set.is_match("src/Card.story.ts"));
801 assert!(!set.is_match("src/Button.tsx"));
802 }
803
804 #[test]
805 fn production_excludes_config_files_at_root_only() {
806 let set = build_production_glob_set();
807 assert!(set.is_match("vitest.config.ts"));
808 assert!(set.is_match("jest.config.js"));
809 assert!(!set.is_match("src/app/app.config.ts"));
810 assert!(!set.is_match("src/app/app.config.server.ts"));
811 assert!(!set.is_match("packages/foo/vitest.config.ts"));
812 assert!(!set.is_match("src/config.ts"));
813 }
814
815 #[test]
816 fn production_patterns_are_valid_globs() {
817 let _ = build_production_glob_set();
818 }
819
820 #[test]
821 fn disallowed_hidden_dirs_idea() {
822 assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
823 }
824
825 #[test]
826 fn source_extensions_include_mdx() {
827 assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
828 }
829
830 #[test]
831 fn source_extensions_exclude_image_and_data_formats() {
832 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
833 assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
834 assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
835 assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
836 assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
837 assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
838 }
839
840 #[test]
841 fn is_declaration_file_matches_dts_variants() {
842 assert!(is_declaration_file(Path::new("env.d.ts")));
843 assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
844 assert!(is_declaration_file(Path::new("mod.d.mts")));
845 assert!(is_declaration_file(Path::new("compat.d.cts")));
846 assert!(!is_declaration_file(Path::new("index.ts")));
847 assert!(!is_declaration_file(Path::new("component.tsx")));
848 assert!(!is_declaration_file(Path::new("notes.d.txt")));
849 }
850
851 #[test]
852 fn format_size_mb_renders_one_decimal() {
853 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
854 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
855 assert_eq!(format_size_mb(0), "0.0 MB");
856 }
857
858 #[test]
859 fn partition_by_size_no_limit_keeps_all() {
860 let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
861 let (kept, skipped) = partition_by_size(raw, None);
862 assert_eq!(kept.len(), 2);
863 assert!(skipped.is_empty());
864 }
865
866 #[test]
867 fn partition_by_size_skips_strictly_over_limit() {
868 let raw = vec![
869 (PathBuf::from("under.ts"), 99),
870 (PathBuf::from("exact.ts"), 100),
871 (PathBuf::from("over.ts"), 101),
872 ];
873 let (kept, skipped) = partition_by_size(raw, Some(100));
874 let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
875 assert!(kept_has("under.ts"));
876 assert!(
877 kept_has("exact.ts"),
878 "a file exactly at the limit is kept (skip is strictly-greater)"
879 );
880 assert_eq!(skipped.len(), 1);
881 assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
882 }
883
884 #[test]
885 fn partition_by_size_exempts_declaration_files() {
886 let raw = vec![
887 (PathBuf::from("huge.ts"), 10_000),
888 (PathBuf::from("auto-imports.d.ts"), 10_000),
889 ];
890 let (kept, skipped) = partition_by_size(raw, Some(100));
891 assert!(
892 kept.iter()
893 .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
894 "declaration files are exempt from the size skip regardless of size"
895 );
896 assert_eq!(skipped.len(), 1);
897 assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
898 }
899
900 fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
901 DiscoveredFile {
902 id: FileId(0),
903 path: PathBuf::from(path),
904 size_bytes,
905 }
906 }
907
908 #[test]
909 fn largest_files_note_below_threshold_is_none() {
910 let files = [disco("a.ts", 100), disco("b.ts", 200)];
911 assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
912 }
913
914 #[test]
915 fn largest_files_note_single_file_uses_singular() {
916 let files = [disco("big.ts", 5 * 1024 * 1024)];
917 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
918 assert!(
919 note.contains("discovered 1 file;"),
920 "singular noun on the single-big-file path (issue #1086 regression): {note}"
921 );
922 assert!(!note.contains("discovered 1 files"));
923 assert!(note.contains("big.ts (5.0 MB)"));
924 }
925
926 #[test]
927 fn largest_files_note_filters_sub_floor_files() {
928 let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
929 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
930 assert!(note.contains("discovered 2 files;"));
931 assert!(note.contains("big.ts (5.0 MB)"));
932 assert!(
933 !note.contains("tiny.ts"),
934 "sub-floor files are not listed as `0.0 MB` chaff: {note}"
935 );
936 }
937
938 #[test]
939 fn largest_files_note_large_set_no_big_file_omits_list() {
940 let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
941 .map(|i| disco(&format!("f{i}.ts"), 100))
942 .collect();
943 let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
944 assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
945 assert!(
946 !note.contains("largest:"),
947 "no sub-floor `largest:` list when no file clears the floor: {note}"
948 );
949 }
950
951 mod discover_files_integration {
952 use std::path::PathBuf;
953
954 use fallow_config::{
955 DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
956 RulesConfig,
957 };
958
959 use super::*;
960
961 fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
963 FallowConfig {
964 production: production.into(),
965 ..Default::default()
966 }
967 .resolve(root, OutputFormat::Human, 1, true, true, None)
968 }
969
970 fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
973 files
974 .iter()
975 .map(|f| {
976 f.path
977 .strip_prefix(root)
978 .unwrap_or(&f.path)
979 .to_string_lossy()
980 .replace('\\', "/")
981 })
982 .collect()
983 }
984
985 #[test]
986 fn discovers_source_files_with_valid_extensions() {
987 let dir = tempfile::tempdir().expect("create temp dir");
988 let src = dir.path().join("src");
989 std::fs::create_dir_all(&src).unwrap();
990
991 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
992 std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
993 std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
994 std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
995 std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
996 std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
997 std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
998 std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
999
1000 let config = make_config(dir.path().to_path_buf(), false);
1001 let files = discover_files(&config);
1002 let names = file_names(&files, dir.path());
1003
1004 assert!(names.contains(&"src/app.ts".to_string()));
1005 assert!(names.contains(&"src/component.tsx".to_string()));
1006 assert!(names.contains(&"src/utils.js".to_string()));
1007 assert!(names.contains(&"src/helper.jsx".to_string()));
1008 assert!(names.contains(&"src/config.mjs".to_string()));
1009 assert!(names.contains(&"src/legacy.cjs".to_string()));
1010 assert!(names.contains(&"src/types.mts".to_string()));
1011 assert!(names.contains(&"src/compat.cts".to_string()));
1012 }
1013
1014 #[test]
1015 fn excludes_non_source_extensions() {
1016 let dir = tempfile::tempdir().expect("create temp dir");
1017 let src = dir.path().join("src");
1018 std::fs::create_dir_all(&src).unwrap();
1019
1020 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1021
1022 std::fs::write(src.join("data.json"), "{}").unwrap();
1023 std::fs::write(src.join("readme.md"), "# Hello").unwrap();
1024 std::fs::write(src.join("notes.txt"), "notes").unwrap();
1025 std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
1026
1027 let config = make_config(dir.path().to_path_buf(), false);
1028 let files = discover_files(&config);
1029 let names = file_names(&files, dir.path());
1030
1031 assert_eq!(names.len(), 1, "only the .ts file should be discovered");
1032 assert!(names.contains(&"src/app.ts".to_string()));
1033 }
1034
1035 #[test]
1036 fn excludes_disallowed_hidden_directories() {
1037 let dir = tempfile::tempdir().expect("create temp dir");
1038
1039 let git_dir = dir.path().join(".git");
1040 std::fs::create_dir_all(&git_dir).unwrap();
1041 std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
1042
1043 let idea_dir = dir.path().join(".idea");
1044 std::fs::create_dir_all(&idea_dir).unwrap();
1045 std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
1046
1047 let cache_dir = dir.path().join(".cache");
1048 std::fs::create_dir_all(&cache_dir).unwrap();
1049 std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
1050
1051 let src = dir.path().join("src");
1052 std::fs::create_dir_all(&src).unwrap();
1053 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1054
1055 let config = make_config(dir.path().to_path_buf(), false);
1056 let files = discover_files(&config);
1057 let names = file_names(&files, dir.path());
1058
1059 assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
1060 assert!(names.contains(&"src/app.ts".to_string()));
1061 }
1062
1063 #[test]
1064 fn includes_allowed_hidden_directories() {
1065 let dir = tempfile::tempdir().expect("create temp dir");
1066
1067 let storybook = dir.path().join(".storybook");
1068 std::fs::create_dir_all(&storybook).unwrap();
1069 std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
1070
1071 let github = dir.path().join(".github");
1072 std::fs::create_dir_all(&github).unwrap();
1073 std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
1074
1075 let changeset = dir.path().join(".changeset");
1076 std::fs::create_dir_all(&changeset).unwrap();
1077 std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
1078
1079 let config = make_config(dir.path().to_path_buf(), false);
1080 let files = discover_files(&config);
1081 let names = file_names(&files, dir.path());
1082
1083 assert!(
1084 names.contains(&".storybook/main.ts".to_string()),
1085 "files in .storybook should be discovered"
1086 );
1087 assert!(
1088 names.contains(&".github/actions.js".to_string()),
1089 "files in .github should be discovered"
1090 );
1091 assert!(
1092 names.contains(&".changeset/config.js".to_string()),
1093 "files in .changeset should be discovered"
1094 );
1095 }
1096
1097 #[test]
1098 fn default_discovery_excludes_client_and_server_hidden_directories() {
1099 let dir = tempfile::tempdir().expect("create temp dir");
1100 let app = dir.path().join("app");
1101 std::fs::create_dir_all(app.join(".client")).unwrap();
1102 std::fs::create_dir_all(app.join(".server")).unwrap();
1103 std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
1104 std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
1105 std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
1106
1107 let config = make_config(dir.path().to_path_buf(), false);
1108 let files = discover_files(&config);
1109 let names = file_names(&files, dir.path());
1110
1111 assert!(names.contains(&"app/root.tsx".to_string()));
1112 assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
1113 assert!(!names.contains(&"app/.server/db.ts".to_string()));
1114 }
1115
1116 #[test]
1117 fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
1118 let dir = tempfile::tempdir().expect("create temp dir");
1119 let package = dir.path().join("packages/app");
1120 std::fs::create_dir_all(package.join("app/.client")).unwrap();
1121 std::fs::create_dir_all(package.join("app/.server")).unwrap();
1122 std::fs::write(
1123 package.join("app/.client/analytics.ts"),
1124 "export const track = () => {};",
1125 )
1126 .unwrap();
1127 std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
1128
1129 let config = make_config(dir.path().to_path_buf(), false);
1130 let scopes = [HiddenDirScope::new(
1131 package,
1132 vec![".client".to_string(), ".server".to_string()],
1133 )];
1134 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1135 let names = file_names(&files, dir.path());
1136
1137 assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
1138 assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
1139 }
1140
1141 #[test]
1142 fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
1143 let dir = tempfile::tempdir().expect("create temp dir");
1144 let active = dir.path().join("packages/active");
1145 let inactive = dir.path().join("packages/inactive");
1146 std::fs::create_dir_all(active.join("app/.server")).unwrap();
1147 std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
1148 std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
1149 std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
1150
1151 let config = make_config(dir.path().to_path_buf(), false);
1152 let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
1153 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1154 let names = file_names(&files, dir.path());
1155
1156 assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
1157 assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
1158 }
1159
1160 #[test]
1161 fn excludes_root_build_directory() {
1162 let dir = tempfile::tempdir().expect("create temp dir");
1163
1164 std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
1165
1166 let build_dir = dir.path().join("build");
1167 std::fs::create_dir_all(&build_dir).unwrap();
1168 std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
1169
1170 let src = dir.path().join("src");
1171 std::fs::create_dir_all(&src).unwrap();
1172 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1173
1174 let config = make_config(dir.path().to_path_buf(), false);
1175 let files = discover_files(&config);
1176 let names = file_names(&files, dir.path());
1177
1178 assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
1179 assert!(names.contains(&"src/app.ts".to_string()));
1180 }
1181
1182 #[test]
1183 fn includes_nested_build_directory() {
1184 let dir = tempfile::tempdir().expect("create temp dir");
1185
1186 let nested_build = dir.path().join("src").join("build");
1187 std::fs::create_dir_all(&nested_build).unwrap();
1188 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1189
1190 let config = make_config(dir.path().to_path_buf(), false);
1191 let files = discover_files(&config);
1192 let names = file_names(&files, dir.path());
1193
1194 assert!(
1195 names.contains(&"src/build/helper.ts".to_string()),
1196 "nested build/ directories should be included"
1197 );
1198 }
1199
1200 #[test]
1201 #[expect(
1202 clippy::cast_possible_truncation,
1203 reason = "test file counts are trivially small"
1204 )]
1205 fn file_ids_are_sequential_after_sorting() {
1206 let dir = tempfile::tempdir().expect("create temp dir");
1207 let src = dir.path().join("src");
1208 std::fs::create_dir_all(&src).unwrap();
1209
1210 std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
1211 std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
1212 std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
1213
1214 let config = make_config(dir.path().to_path_buf(), false);
1215 let files = discover_files(&config);
1216
1217 for (idx, file) in files.iter().enumerate() {
1218 assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
1219 }
1220
1221 for pair in files.windows(2) {
1222 assert!(
1223 pair[0].path < pair[1].path,
1224 "files should be sorted by path"
1225 );
1226 }
1227 }
1228
1229 #[test]
1230 fn production_mode_excludes_test_files() {
1231 let dir = tempfile::tempdir().expect("create temp dir");
1232 let src = dir.path().join("src");
1233 std::fs::create_dir_all(&src).unwrap();
1234
1235 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1236 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1237 std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
1238 std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
1239
1240 let config = make_config(dir.path().to_path_buf(), true);
1241 let files = discover_files(&config);
1242 let names = file_names(&files, dir.path());
1243
1244 assert!(
1245 names.contains(&"src/app.ts".to_string()),
1246 "source files should be included in production mode"
1247 );
1248 assert!(
1249 !names.contains(&"src/app.test.ts".to_string()),
1250 "test files should be excluded in production mode"
1251 );
1252 assert!(
1253 !names.contains(&"src/app.spec.ts".to_string()),
1254 "spec files should be excluded in production mode"
1255 );
1256 assert!(
1257 !names.contains(&"src/app.stories.tsx".to_string()),
1258 "story files should be excluded in production mode"
1259 );
1260 }
1261
1262 #[test]
1263 fn non_production_mode_includes_test_files() {
1264 let dir = tempfile::tempdir().expect("create temp dir");
1265 let src = dir.path().join("src");
1266 std::fs::create_dir_all(&src).unwrap();
1267
1268 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1269 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1270
1271 let config = make_config(dir.path().to_path_buf(), false);
1272 let files = discover_files(&config);
1273 let names = file_names(&files, dir.path());
1274
1275 assert!(names.contains(&"src/app.ts".to_string()));
1276 assert!(
1277 names.contains(&"src/app.test.ts".to_string()),
1278 "test files should be included in non-production mode"
1279 );
1280 }
1281
1282 #[test]
1283 fn empty_directory_returns_no_files() {
1284 let dir = tempfile::tempdir().expect("create temp dir");
1285 let config = make_config(dir.path().to_path_buf(), false);
1286 let files = discover_files(&config);
1287 assert!(files.is_empty(), "empty project should discover no files");
1288 }
1289
1290 #[test]
1291 fn hidden_files_not_discovered_as_source() {
1292 let dir = tempfile::tempdir().expect("create temp dir");
1293
1294 std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
1295 std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
1296 std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
1297
1298 let src = dir.path().join("src");
1299 std::fs::create_dir_all(&src).unwrap();
1300 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1301
1302 let config = make_config(dir.path().to_path_buf(), false);
1303 let files = discover_files(&config);
1304 let names = file_names(&files, dir.path());
1305
1306 assert!(
1307 !names.contains(&".env".to_string()),
1308 ".env should not be discovered"
1309 );
1310 assert!(
1311 !names.contains(&".gitignore".to_string()),
1312 ".gitignore should not be discovered"
1313 );
1314 }
1315
1316 fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
1318 FallowConfig {
1319 schema: None,
1320 extends: vec![],
1321 entry: vec![],
1322 ignore_patterns: ignores,
1323 framework: vec![],
1324 workspaces: None,
1325 ignore_dependencies: vec![],
1326 ignore_unresolved_imports: vec![],
1327 ignore_exports: vec![],
1328 ignore_catalog_references: vec![],
1329 ignore_dependency_overrides: vec![],
1330 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
1331 ),
1332 used_class_members: vec![],
1333 ignore_decorators: vec![],
1334 duplicates: DuplicatesConfig::default(),
1335 health: HealthConfig::default(),
1336 rules: RulesConfig::default(),
1337 boundaries: fallow_config::BoundaryConfig::default(),
1338 production: false.into(),
1339 plugins: vec![],
1340 rule_packs: vec![],
1341 dynamically_loaded: vec![],
1342 overrides: vec![],
1343 regression: None,
1344 audit: fallow_config::AuditConfig::default(),
1345 codeowners: None,
1346 public_packages: vec![],
1347 flags: FlagsConfig::default(),
1348 security: fallow_config::SecurityConfig::default(),
1349 fix: fallow_config::FixConfig::default(),
1350 resolve: ResolveConfig::default(),
1351 sealed: false,
1352 include_entry_exports: false,
1353 auto_imports: false,
1354 cache: fallow_config::CacheConfig::default(),
1355 }
1356 .resolve(root, OutputFormat::Human, 1, true, true, None)
1357 }
1358
1359 #[test]
1360 fn custom_ignore_patterns_exclude_matching_files() {
1361 let dir = tempfile::tempdir().expect("create temp dir");
1362
1363 let generated = dir.path().join("src").join("api").join("generated");
1364 std::fs::create_dir_all(&generated).unwrap();
1365 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1366
1367 let client = dir.path().join("src").join("api").join("client");
1368 std::fs::create_dir_all(&client).unwrap();
1369 std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
1370
1371 let src = dir.path().join("src");
1372 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1373
1374 let config = make_config_with_ignores(
1375 dir.path().to_path_buf(),
1376 vec![
1377 "src/api/generated/**".to_string(),
1378 "src/api/client/**".to_string(),
1379 ],
1380 );
1381 let files = discover_files(&config);
1382 let names = file_names(&files, dir.path());
1383
1384 assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
1385 assert!(names.contains(&"src/index.ts".to_string()));
1386 }
1387
1388 #[test]
1389 fn default_ignore_patterns_exclude_node_modules_and_dist() {
1390 let dir = tempfile::tempdir().expect("create temp dir");
1391
1392 let nm = dir.path().join("node_modules").join("lodash");
1393 std::fs::create_dir_all(&nm).unwrap();
1394 std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
1395
1396 let dist = dir.path().join("dist");
1397 std::fs::create_dir_all(&dist).unwrap();
1398 std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
1399
1400 let src = dir.path().join("src");
1401 std::fs::create_dir_all(&src).unwrap();
1402 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1403
1404 let config = make_config(dir.path().to_path_buf(), false);
1405 let files = discover_files(&config);
1406 let names = file_names(&files, dir.path());
1407
1408 assert_eq!(names.len(), 1);
1409 assert!(names.contains(&"src/index.ts".to_string()));
1410 }
1411
1412 #[test]
1413 fn default_ignore_patterns_exclude_root_build() {
1414 let dir = tempfile::tempdir().expect("create temp dir");
1415
1416 let build = dir.path().join("build");
1417 std::fs::create_dir_all(&build).unwrap();
1418 std::fs::write(build.join("output.js"), "// built").unwrap();
1419
1420 let nested_build = dir.path().join("src").join("build");
1421 std::fs::create_dir_all(&nested_build).unwrap();
1422 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1423
1424 let src = dir.path().join("src");
1425 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1426
1427 let config = make_config(dir.path().to_path_buf(), false);
1428 let files = discover_files(&config);
1429 let names = file_names(&files, dir.path());
1430
1431 assert_eq!(
1432 names.len(),
1433 2,
1434 "root build/ excluded, nested kept: {names:?}"
1435 );
1436 assert!(names.contains(&"src/index.ts".to_string()));
1437 assert!(names.contains(&"src/build/helper.ts".to_string()));
1438 }
1439
1440 fn make_config_with_max_file_size(
1442 root: PathBuf,
1443 max_file_size_bytes: Option<u64>,
1444 ) -> ResolvedConfig {
1445 let mut config = make_config(root, false);
1446 config.max_file_size_bytes = max_file_size_bytes;
1447 config
1448 }
1449
1450 #[test]
1451 fn skips_files_over_max_file_size() {
1452 let dir = tempfile::tempdir().expect("create temp dir");
1453 let src = dir.path().join("src");
1454 std::fs::create_dir_all(&src).unwrap();
1455 std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
1456 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1457
1458 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1459 let files = discover_files(&config);
1460 let names = file_names(&files, dir.path());
1461
1462 assert!(names.contains(&"src/small.ts".to_string()));
1463 assert!(
1464 !names.contains(&"src/huge.ts".to_string()),
1465 "a file over the size limit must not be discovered"
1466 );
1467 }
1468
1469 #[test]
1470 fn declaration_files_exempt_from_size_skip() {
1471 let dir = tempfile::tempdir().expect("create temp dir");
1472 let src = dir.path().join("src");
1473 std::fs::create_dir_all(&src).unwrap();
1474 std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
1475 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1476
1477 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1478 let files = discover_files(&config);
1479 let names = file_names(&files, dir.path());
1480
1481 assert!(
1482 names.contains(&"src/auto-imports.d.ts".to_string()),
1483 "a large .d.ts is exempt from the skip (reachability root for global types)"
1484 );
1485 assert!(!names.contains(&"src/huge.ts".to_string()));
1486 }
1487
1488 #[test]
1489 fn unlimited_size_keeps_large_files() {
1490 let dir = tempfile::tempdir().expect("create temp dir");
1491 let src = dir.path().join("src");
1492 std::fs::create_dir_all(&src).unwrap();
1493 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1494
1495 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1496 let files = discover_files(&config);
1497 let names = file_names(&files, dir.path());
1498
1499 assert!(
1500 names.contains(&"src/huge.ts".to_string()),
1501 "no limit keeps every file"
1502 );
1503 }
1504
1505 #[test]
1506 fn skipped_file_recorded_in_workspace_diagnostics() {
1507 let dir = tempfile::tempdir().expect("create temp dir");
1508 let src = dir.path().join("src");
1509 std::fs::create_dir_all(&src).unwrap();
1510 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1511
1512 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1513 let _ = discover_files(&config);
1514
1515 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1516 let skipped: Vec<_> = diagnostics
1517 .iter()
1518 .filter(|d| {
1519 matches!(
1520 d.kind,
1521 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1522 )
1523 })
1524 .collect();
1525 assert_eq!(
1526 skipped.len(),
1527 1,
1528 "the skipped file is recorded in workspace diagnostics for JSON output"
1529 );
1530 assert!(skipped[0].path.ends_with("src/huge.ts"));
1531 assert!(
1532 matches!(
1533 skipped[0].kind,
1534 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
1535 if size_bytes == 5_000
1536 ),
1537 "the recorded diagnostic carries the on-disk byte size"
1538 );
1539 }
1540
1541 #[test]
1542 fn skips_large_one_line_js_as_minified_generated_output() {
1543 let dir = tempfile::tempdir().expect("create temp dir");
1544 let src = dir.path().join("src");
1545 std::fs::create_dir_all(&src).unwrap();
1546 let asset = src.join("index-abc123.js");
1547 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1548
1549 let config = make_config(dir.path().to_path_buf(), false);
1550 let files = discover_files(&config);
1551 let names = file_names(&files, dir.path());
1552
1553 assert!(
1554 !names.contains(&"src/index-abc123.js".to_string()),
1555 "large one-line JS assets should be skipped before parsing"
1556 );
1557
1558 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1559 assert!(
1560 diagnostics.iter().any(|diag| {
1561 diag.path.ends_with("src/index-abc123.js")
1562 && matches!(
1563 diag.kind,
1564 fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
1565 )
1566 }),
1567 "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
1568 );
1569 }
1570
1571 #[test]
1572 fn unlimited_size_keeps_large_one_line_js() {
1573 let dir = tempfile::tempdir().expect("create temp dir");
1574 let src = dir.path().join("src");
1575 std::fs::create_dir_all(&src).unwrap();
1576 let asset = src.join("index-abc123.js");
1577 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1578
1579 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1580 let files = discover_files(&config);
1581 let names = file_names(&files, dir.path());
1582
1583 assert!(
1584 names.contains(&"src/index-abc123.js".to_string()),
1585 "--max-file-size 0 should opt out of generated JS skipping"
1586 );
1587 }
1588
1589 #[test]
1590 fn keeps_large_multiline_js() {
1591 let dir = tempfile::tempdir().expect("create temp dir");
1592 let src = dir.path().join("src");
1593 std::fs::create_dir_all(&src).unwrap();
1594 let asset = src.join("handwritten.js");
1595 let mut content = String::new();
1596 while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
1597 content.push_str("export const value = 1;\n");
1598 }
1599 std::fs::write(&asset, content).unwrap();
1600
1601 let config = make_config(dir.path().to_path_buf(), false);
1602 let files = discover_files(&config);
1603 let names = file_names(&files, dir.path());
1604
1605 assert!(
1606 names.contains(&"src/handwritten.js".to_string()),
1607 "large multiline JS should not be treated as a generated minified asset"
1608 );
1609 }
1610 }
1611}