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 #[must_use]
317 pub fn root(&self) -> &Path {
318 &self.root
319 }
320
321 #[must_use]
322 pub fn dirs(&self) -> &[String] {
323 &self.dirs
324 }
325
326 fn allows(&self, path: &Path, name: &OsStr) -> bool {
327 path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
328 }
329}
330
331struct FileVisitor<'a> {
338 root: &'a Path,
339 canonical_root: Option<&'a Path>,
340 ignore_patterns: &'a globset::GlobSet,
341 production_excludes: &'a Option<globset::GlobSet>,
342 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
343 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
344 local: Vec<(std::path::PathBuf, u64)>,
345 config_local: Vec<std::path::PathBuf>,
346}
347
348impl ignore::ParallelVisitor for FileVisitor<'_> {
349 fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
350 let Ok(entry) = result else {
351 return ignore::WalkState::Continue;
352 };
353 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
354 return ignore::WalkState::Continue;
355 }
356 let relative = entry
357 .path()
358 .strip_prefix(self.root)
359 .unwrap_or_else(|_| entry.path());
360 if self.ignore_patterns.is_match(relative) {
361 return ignore::WalkState::Continue;
362 }
363 if self
364 .production_excludes
365 .as_ref()
366 .is_some_and(|excludes| excludes.is_match(relative))
367 {
368 return ignore::WalkState::Continue;
369 }
370 let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
371 let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
372 tracing::debug!(
373 path = %entry.path().display(),
374 "skipping source symlink with a broken, non-file, or outside-root target"
375 );
376 return ignore::WalkState::Continue;
377 };
378 Some(size)
379 } else {
380 None
381 };
382 if has_source_extension(entry.path()) {
383 let size_bytes =
384 symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
385 self.local.push((entry.into_path(), size_bytes));
386 } else if self.config_shared.is_some() {
387 self.config_local.push(entry.into_path());
390 }
391 ignore::WalkState::Continue
392 }
393}
394
395fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
396 let root = canonical_root?;
397 let target = path.canonicalize().ok()?;
398 if !target.starts_with(root) {
399 return None;
400 }
401 let metadata = target.metadata().ok()?;
402 metadata.is_file().then_some(metadata.len())
403}
404
405impl Drop for FileVisitor<'_> {
406 #[expect(
407 clippy::expect_used,
408 reason = "poisoned walk collector lock means worker state is unrecoverable"
409 )]
410 fn drop(&mut self) {
411 if !self.local.is_empty() {
412 self.shared
413 .lock()
414 .expect("walk collector lock poisoned")
415 .append(&mut self.local);
416 }
417 if let Some(config_shared) = self.config_shared
418 && !self.config_local.is_empty()
419 {
420 config_shared
421 .lock()
422 .expect("walk config collector lock poisoned")
423 .append(&mut self.config_local);
424 }
425 }
426}
427
428struct FileVisitorBuilder<'a> {
430 root: &'a Path,
431 canonical_root: Option<&'a Path>,
432 ignore_patterns: &'a globset::GlobSet,
433 production_excludes: &'a Option<globset::GlobSet>,
434 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
435 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
436}
437
438impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
439 fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
440 Box::new(FileVisitor {
441 root: self.root,
442 canonical_root: self.canonical_root,
443 ignore_patterns: self.ignore_patterns,
444 production_excludes: self.production_excludes,
445 shared: self.shared,
446 config_shared: self.config_shared,
447 local: Vec::new(),
448 config_local: Vec::new(),
449 })
450 }
451}
452
453pub const SOURCE_EXTENSIONS: &[&str] = &[
454 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
455 "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
456];
457
458pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
460 "**/*.test.*",
461 "**/*.spec.*",
462 "**/*.e2e.*",
463 "**/*.e2e-spec.*",
464 "**/*.bench.*",
465 "**/*.fixture.*",
466 "**/*.stories.*",
467 "**/*.story.*",
468 "**/__tests__/**",
469 "**/__mocks__/**",
470 "**/__snapshots__/**",
471 "**/__fixtures__/**",
472 "**/test/**",
473 "**/tests/**",
474 "*.config.*",
475 "**/.*.js",
476 "**/.*.ts",
477 "**/.*.mjs",
478 "**/.*.cjs",
479];
480
481pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
483 ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
484}
485
486fn is_allowed_scoped_hidden_dir(
487 name: &OsStr,
488 path: &Path,
489 additional_hidden_dir_scopes: &[HiddenDirScope],
490) -> bool {
491 additional_hidden_dir_scopes
492 .iter()
493 .any(|scope| scope.allows(path, name))
494}
495
496fn is_allowed_hidden(entry: &ignore::DirEntry) -> bool {
502 is_allowed_hidden_with_scopes(entry, &[])
503}
504
505fn is_allowed_hidden_with_scopes(
506 entry: &ignore::DirEntry,
507 additional_hidden_dir_scopes: &[HiddenDirScope],
508) -> bool {
509 let name = entry.file_name();
510 let name_str = name.to_string_lossy();
511
512 if !name_str.starts_with('.') {
513 return true;
514 }
515
516 if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
517 return true;
518 }
519
520 is_allowed_hidden_dir(name)
521 || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
522}
523
524pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
530 discover_files_with_additional_hidden_dirs(config, &[])
531}
532
533fn config_candidate_basename_globs() -> &'static [String] {
545 static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
546 GLOBS.get_or_init(|| {
547 let mut set: FxHashSet<String> = FxHashSet::default();
548 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
549 for pattern in plugin.config_patterns() {
550 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
551 set.insert(basename.to_string());
552 }
553 }
554 let mut globs: Vec<String> = set.into_iter().collect();
555 globs.sort_unstable();
556 globs
557 })
558}
559
560fn has_source_extension(path: &Path) -> bool {
563 path.extension()
564 .and_then(OsStr::to_str)
565 .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
566}
567
568#[expect(
572 clippy::expect_used,
573 reason = "source file globs are hard-coded compile-time constants"
574)]
575fn build_walk_types(capture_config: bool) -> ignore::types::Types {
576 let mut types_builder = ignore::types::TypesBuilder::new();
577 for ext in SOURCE_EXTENSIONS {
578 types_builder
579 .add("source", &format!("*.{ext}"))
580 .expect("valid glob");
581 }
582 types_builder.select("source");
583 if capture_config {
584 for glob in config_candidate_basename_globs() {
585 let _ = types_builder.add("config", glob);
589 }
590 types_builder.select("config");
591 }
592 types_builder.build().expect("valid types")
593}
594
595fn build_source_walk_builder(
599 config: &ResolvedConfig,
600 additional_hidden_dir_scopes: &[HiddenDirScope],
601 capture_config: bool,
602) -> WalkBuilder {
603 let mut walk_builder = WalkBuilder::new(&config.root);
604 walk_builder
605 .hidden(false)
606 .git_ignore(true)
607 .git_global(true)
608 .git_exclude(true)
609 .types(build_walk_types(capture_config))
610 .threads(config.threads);
611 if additional_hidden_dir_scopes.is_empty() {
612 walk_builder.filter_entry(is_allowed_hidden);
613 } else {
614 let scopes = additional_hidden_dir_scopes.to_vec();
615 walk_builder.filter_entry(move |entry| is_allowed_hidden_with_scopes(entry, &scopes));
616 }
617 walk_builder
618}
619
620fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
622 if !config.production {
623 return None;
624 }
625 let mut builder = globset::GlobSetBuilder::new();
626 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
627 if let Ok(glob) = globset::GlobBuilder::new(pattern)
628 .literal_separator(true)
629 .build()
630 {
631 builder.add(glob);
632 }
633 }
634 builder.build().ok()
635}
636
637pub fn discover_files_with_additional_hidden_dirs(
643 config: &ResolvedConfig,
644 additional_hidden_dir_scopes: &[HiddenDirScope],
645) -> Vec<DiscoveredFile> {
646 discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
647}
648
649#[expect(
665 clippy::cast_possible_truncation,
666 reason = "file count is bounded by project size, well under u32::MAX"
667)]
668#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
669pub fn discover_files_and_config_candidates(
670 config: &ResolvedConfig,
671 additional_hidden_dir_scopes: &[HiddenDirScope],
672) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
673 let _span = tracing::info_span!("discover_files").entered();
674
675 let capture_config = !config.production;
676 let walk_builder =
677 build_source_walk_builder(config, additional_hidden_dir_scopes, capture_config);
678 let production_excludes = build_production_excludes(config);
679 let canonical_root = config.root.canonicalize().ok();
680
681 let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
682 let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
683 let mut visitor_builder = FileVisitorBuilder {
684 root: &config.root,
685 canonical_root: canonical_root.as_deref(),
686 ignore_patterns: &config.ignore_patterns,
687 production_excludes: &production_excludes,
688 shared: &collected,
689 config_shared: capture_config.then_some(&config_collected),
690 };
691 walk_builder.build_parallel().visit(&mut visitor_builder);
692
693 let mut raw = collected
694 .into_inner()
695 .expect("walk collector lock poisoned");
696 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
704
705 let mut config_candidates = config_collected
706 .into_inner()
707 .expect("walk config collector lock poisoned");
708 config_candidates.sort_unstable();
709
710 fallow_config::clear_source_discovery_diagnostics(&config.root);
714 let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
715 report_skipped_large_files(config, &skipped);
716 let (kept, skipped_minified) =
717 partition_minified_generated_js(kept, config.max_file_size_bytes);
718 report_skipped_minified_files(config, &skipped_minified);
719
720 let files: Vec<DiscoveredFile> = kept
721 .into_iter()
722 .enumerate()
723 .map(|(idx, (path, size_bytes))| DiscoveredFile {
724 id: FileId(idx as u32),
725 path,
726 size_bytes,
727 })
728 .collect();
729
730 note_largest_files(config, &files);
731
732 (files, config_candidates)
733}
734
735#[cfg(test)]
736mod tests {
737 use std::ffi::OsStr;
738
739 use super::*;
740
741 fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
744 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
745 raw.into_iter()
746 .enumerate()
747 .map(|(idx, (path, size_bytes))| DiscoveredFile {
748 id: FileId(idx as u32),
749 path,
750 size_bytes,
751 })
752 .collect()
753 }
754
755 #[test]
761 fn file_id_assignment_is_deterministic_for_identical_file_set() {
762 let paths = [
763 "/project/src/z.ts",
764 "/project/src/a.ts",
765 "/project/src/components/Button.tsx",
766 "/project/src/components/Button.module.css",
767 "/project/index.ts",
768 ];
769
770 let walk_one: Vec<(std::path::PathBuf, u64)> = paths
772 .iter()
773 .map(|p| (std::path::PathBuf::from(p), 10))
774 .collect();
775 let mut walk_two = walk_one.clone();
776 walk_two.reverse();
777
778 let files_one = assign_file_ids(walk_one);
779 let files_two = assign_file_ids(walk_two);
780
781 assert_eq!(files_one.len(), files_two.len());
783 for (a, b) in files_one.iter().zip(files_two.iter()) {
784 assert_eq!(a.id, b.id);
785 assert_eq!(a.path, b.path);
786 }
787
788 for (idx, file) in files_one.iter().enumerate() {
791 assert_eq!(file.id, FileId(idx as u32));
792 }
793 assert_eq!(
794 files_one[0].path,
795 std::path::PathBuf::from("/project/index.ts")
796 );
797 }
798
799 #[test]
800 fn file_id_assignment_recomputes_after_rename_or_delete() {
801 let before = assign_file_ids(vec![
802 (std::path::PathBuf::from("/project/src/a.ts"), 10),
803 (std::path::PathBuf::from("/project/src/b.ts"), 10),
804 (std::path::PathBuf::from("/project/src/c.ts"), 10),
805 ]);
806 let after_delete = assign_file_ids(vec![
807 (std::path::PathBuf::from("/project/src/a.ts"), 10),
808 (std::path::PathBuf::from("/project/src/c.ts"), 10),
809 ]);
810 let after_rename = assign_file_ids(vec![
811 (std::path::PathBuf::from("/project/src/a.ts"), 10),
812 (std::path::PathBuf::from("/project/src/c.ts"), 10),
813 (std::path::PathBuf::from("/project/src/d.ts"), 10),
814 ]);
815
816 assert_eq!(before[0].id, FileId(0));
817 assert_eq!(before[1].id, FileId(1));
818 assert_eq!(before[2].id, FileId(2));
819 assert_eq!(after_delete[0].id, FileId(0));
820 assert_eq!(after_delete[1].id, FileId(1));
821 assert_eq!(
822 after_delete[1].path,
823 std::path::PathBuf::from("/project/src/c.ts")
824 );
825 assert_eq!(after_rename[0].id, FileId(0));
826 assert_eq!(after_rename[1].id, FileId(1));
827 assert_eq!(
828 after_rename[1].path,
829 std::path::PathBuf::from("/project/src/c.ts")
830 );
831 assert_eq!(after_rename[2].id, FileId(2));
832 assert_eq!(
833 after_rename[2].path,
834 std::path::PathBuf::from("/project/src/d.ts")
835 );
836 }
837
838 #[test]
839 fn allowed_hidden_dirs() {
840 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
841 assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
842 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
843 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
844 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
845 }
846
847 #[test]
848 fn disallowed_hidden_dirs() {
849 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
850 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
851 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
852 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
853 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
854 }
855
856 #[test]
857 fn non_hidden_dirs_not_in_allowlist() {
858 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
859 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
860 }
861
862 #[test]
863 fn source_extensions_include_typescript() {
864 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
865 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
866 assert!(SOURCE_EXTENSIONS.contains(&"mts"));
867 assert!(SOURCE_EXTENSIONS.contains(&"cts"));
868 assert!(SOURCE_EXTENSIONS.contains(&"gts"));
869 }
870
871 #[test]
872 fn source_extensions_include_javascript() {
873 assert!(SOURCE_EXTENSIONS.contains(&"js"));
874 assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
875 assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
876 assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
877 assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
878 }
879
880 #[test]
881 fn source_extensions_include_sfc_formats() {
882 assert!(SOURCE_EXTENSIONS.contains(&"vue"));
883 assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
884 assert!(SOURCE_EXTENSIONS.contains(&"astro"));
885 }
886
887 #[test]
888 fn source_extensions_include_styles() {
889 assert!(SOURCE_EXTENSIONS.contains(&"css"));
890 assert!(SOURCE_EXTENSIONS.contains(&"scss"));
891 assert!(SOURCE_EXTENSIONS.contains(&"sass"));
892 assert!(SOURCE_EXTENSIONS.contains(&"less"));
893 }
894
895 #[test]
896 fn source_extensions_exclude_non_source() {
897 assert!(!SOURCE_EXTENSIONS.contains(&"json"));
898 assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
899 assert!(!SOURCE_EXTENSIONS.contains(&"md"));
900 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
901 assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
902 }
903
904 #[test]
905 fn source_extensions_include_html() {
906 assert!(SOURCE_EXTENSIONS.contains(&"html"));
907 }
908
909 #[test]
910 fn source_extensions_include_graphql_documents() {
911 assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
912 assert!(SOURCE_EXTENSIONS.contains(&"gql"));
913 }
914
915 fn build_production_glob_set() -> globset::GlobSet {
916 let mut builder = globset::GlobSetBuilder::new();
917 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
918 builder.add(
919 globset::GlobBuilder::new(pattern)
920 .literal_separator(true)
921 .build()
922 .expect("valid glob pattern"),
923 );
924 }
925 builder.build().expect("valid glob set")
926 }
927
928 #[test]
929 fn production_excludes_test_files() {
930 let set = build_production_glob_set();
931 assert!(set.is_match("src/Button.test.ts"));
932 assert!(set.is_match("src/utils.spec.tsx"));
933 assert!(set.is_match("src/__tests__/helper.ts"));
934 assert!(!set.is_match("src/Button.ts"));
935 assert!(!set.is_match("src/utils.tsx"));
936 }
937
938 #[test]
939 fn production_excludes_story_files() {
940 let set = build_production_glob_set();
941 assert!(set.is_match("src/Button.stories.tsx"));
942 assert!(set.is_match("src/Card.story.ts"));
943 assert!(!set.is_match("src/Button.tsx"));
944 }
945
946 #[test]
947 fn production_excludes_config_files_at_root_only() {
948 let set = build_production_glob_set();
949 assert!(set.is_match("vitest.config.ts"));
950 assert!(set.is_match("jest.config.js"));
951 assert!(!set.is_match("src/app/app.config.ts"));
952 assert!(!set.is_match("src/app/app.config.server.ts"));
953 assert!(!set.is_match("packages/foo/vitest.config.ts"));
954 assert!(!set.is_match("src/config.ts"));
955 }
956
957 #[test]
958 fn production_patterns_are_valid_globs() {
959 let _ = build_production_glob_set();
960 }
961
962 #[test]
963 fn disallowed_hidden_dirs_idea() {
964 assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
965 }
966
967 #[test]
968 fn source_extensions_include_mdx() {
969 assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
970 }
971
972 #[test]
973 fn source_extensions_exclude_image_and_data_formats() {
974 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
975 assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
976 assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
977 assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
978 assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
979 assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
980 }
981
982 #[test]
983 fn is_declaration_file_matches_dts_variants() {
984 assert!(is_declaration_file(Path::new("env.d.ts")));
985 assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
986 assert!(is_declaration_file(Path::new("mod.d.mts")));
987 assert!(is_declaration_file(Path::new("compat.d.cts")));
988 assert!(!is_declaration_file(Path::new("index.ts")));
989 assert!(!is_declaration_file(Path::new("component.tsx")));
990 assert!(!is_declaration_file(Path::new("notes.d.txt")));
991 }
992
993 #[test]
994 fn format_size_mb_renders_one_decimal() {
995 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
996 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
997 assert_eq!(format_size_mb(0), "0.0 MB");
998 }
999
1000 #[test]
1001 fn partition_by_size_no_limit_keeps_all() {
1002 let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
1003 let (kept, skipped) = partition_by_size(raw, None);
1004 assert_eq!(kept.len(), 2);
1005 assert!(skipped.is_empty());
1006 }
1007
1008 #[test]
1009 fn partition_by_size_skips_strictly_over_limit() {
1010 let raw = vec![
1011 (PathBuf::from("under.ts"), 99),
1012 (PathBuf::from("exact.ts"), 100),
1013 (PathBuf::from("over.ts"), 101),
1014 ];
1015 let (kept, skipped) = partition_by_size(raw, Some(100));
1016 let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
1017 assert!(kept_has("under.ts"));
1018 assert!(
1019 kept_has("exact.ts"),
1020 "a file exactly at the limit is kept (skip is strictly-greater)"
1021 );
1022 assert_eq!(skipped.len(), 1);
1023 assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
1024 }
1025
1026 #[test]
1027 fn partition_by_size_exempts_declaration_files() {
1028 let raw = vec![
1029 (PathBuf::from("huge.ts"), 10_000),
1030 (PathBuf::from("auto-imports.d.ts"), 10_000),
1031 ];
1032 let (kept, skipped) = partition_by_size(raw, Some(100));
1033 assert!(
1034 kept.iter()
1035 .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
1036 "declaration files are exempt from the size skip regardless of size"
1037 );
1038 assert_eq!(skipped.len(), 1);
1039 assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
1040 }
1041
1042 fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
1043 DiscoveredFile {
1044 id: FileId(0),
1045 path: PathBuf::from(path),
1046 size_bytes,
1047 }
1048 }
1049
1050 #[test]
1051 fn largest_files_note_below_threshold_is_none() {
1052 let files = [disco("a.ts", 100), disco("b.ts", 200)];
1053 assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
1054 }
1055
1056 #[test]
1057 fn largest_files_note_single_file_uses_singular() {
1058 let files = [disco("big.ts", 5 * 1024 * 1024)];
1059 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1060 assert!(
1061 note.contains("discovered 1 file;"),
1062 "singular noun on the single-big-file path (issue #1086 regression): {note}"
1063 );
1064 assert!(!note.contains("discovered 1 files"));
1065 assert!(note.contains("big.ts (5.0 MB)"));
1066 }
1067
1068 #[test]
1069 fn largest_files_note_filters_sub_floor_files() {
1070 let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
1071 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1072 assert!(note.contains("discovered 2 files;"));
1073 assert!(note.contains("big.ts (5.0 MB)"));
1074 assert!(
1075 !note.contains("tiny.ts"),
1076 "sub-floor files are not listed as `0.0 MB` chaff: {note}"
1077 );
1078 }
1079
1080 #[test]
1081 fn largest_files_note_large_set_no_big_file_omits_list() {
1082 let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
1083 .map(|i| disco(&format!("f{i}.ts"), 100))
1084 .collect();
1085 let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
1086 assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
1087 assert!(
1088 !note.contains("largest:"),
1089 "no sub-floor `largest:` list when no file clears the floor: {note}"
1090 );
1091 }
1092
1093 mod discover_files_integration {
1094 use std::path::PathBuf;
1095
1096 use fallow_config::{
1097 DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
1098 RulesConfig,
1099 };
1100
1101 use super::*;
1102
1103 fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
1105 FallowConfig {
1106 production: production.into(),
1107 ..Default::default()
1108 }
1109 .resolve(root, OutputFormat::Human, 1, true, true, None)
1110 }
1111
1112 fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
1115 files
1116 .iter()
1117 .map(|f| {
1118 f.path
1119 .strip_prefix(root)
1120 .unwrap_or(&f.path)
1121 .to_string_lossy()
1122 .replace('\\', "/")
1123 })
1124 .collect()
1125 }
1126
1127 #[cfg(unix)]
1128 fn symlink_file(target: &Path, link: &Path) {
1129 std::os::unix::fs::symlink(target, link).expect("create file symlink");
1130 }
1131
1132 #[cfg(windows)]
1133 fn symlink_file(target: &Path, link: &Path) {
1134 std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1135 }
1136
1137 #[cfg(unix)]
1138 fn symlink_dir(target: &Path, link: &Path) {
1139 std::os::unix::fs::symlink(target, link).expect("create directory symlink");
1140 }
1141
1142 #[cfg(windows)]
1143 fn symlink_dir(target: &Path, link: &Path) {
1144 std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
1145 }
1146
1147 #[test]
1148 fn source_symlinks_must_target_regular_files_inside_root() {
1149 let dir = tempfile::tempdir().expect("create project");
1150 let outside = tempfile::tempdir().expect("create outside dir");
1151 let src = dir.path().join("src");
1152 std::fs::create_dir_all(&src).unwrap();
1153 std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
1154 std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
1155 std::fs::write(
1156 outside.path().join("outside-target.ts"),
1157 "export const outside = 1;",
1158 )
1159 .unwrap();
1160 std::fs::create_dir_all(src.join("directory-target")).unwrap();
1161
1162 symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
1163 symlink_file(
1164 &outside.path().join("outside-target.ts"),
1165 &src.join("outside-link.ts"),
1166 );
1167 symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
1168 symlink_dir(
1169 &src.join("directory-target"),
1170 &src.join("directory-link.ts"),
1171 );
1172
1173 let config = make_config(dir.path().to_path_buf(), false);
1174 let names = file_names(&discover_files(&config), dir.path());
1175
1176 assert!(names.contains(&"src/regular.ts".to_string()));
1177 assert!(names.contains(&"src/inside-target.ts".to_string()));
1178 assert!(names.contains(&"src/inside-link.ts".to_string()));
1179 assert!(!names.contains(&"src/outside-link.ts".to_string()));
1180 assert!(!names.contains(&"src/broken-link.ts".to_string()));
1181 assert!(!names.contains(&"src/directory-link.ts".to_string()));
1182 }
1183
1184 #[test]
1185 fn discovers_source_files_with_valid_extensions() {
1186 let dir = tempfile::tempdir().expect("create temp dir");
1187 let src = dir.path().join("src");
1188 std::fs::create_dir_all(&src).unwrap();
1189
1190 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1191 std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
1192 std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
1193 std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
1194 std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
1195 std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
1196 std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
1197 std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
1198
1199 let config = make_config(dir.path().to_path_buf(), false);
1200 let files = discover_files(&config);
1201 let names = file_names(&files, dir.path());
1202
1203 assert!(names.contains(&"src/app.ts".to_string()));
1204 assert!(names.contains(&"src/component.tsx".to_string()));
1205 assert!(names.contains(&"src/utils.js".to_string()));
1206 assert!(names.contains(&"src/helper.jsx".to_string()));
1207 assert!(names.contains(&"src/config.mjs".to_string()));
1208 assert!(names.contains(&"src/legacy.cjs".to_string()));
1209 assert!(names.contains(&"src/types.mts".to_string()));
1210 assert!(names.contains(&"src/compat.cts".to_string()));
1211 }
1212
1213 #[test]
1214 fn excludes_non_source_extensions() {
1215 let dir = tempfile::tempdir().expect("create temp dir");
1216 let src = dir.path().join("src");
1217 std::fs::create_dir_all(&src).unwrap();
1218
1219 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1220
1221 std::fs::write(src.join("data.json"), "{}").unwrap();
1222 std::fs::write(src.join("readme.md"), "# Hello").unwrap();
1223 std::fs::write(src.join("notes.txt"), "notes").unwrap();
1224 std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
1225
1226 let config = make_config(dir.path().to_path_buf(), false);
1227 let files = discover_files(&config);
1228 let names = file_names(&files, dir.path());
1229
1230 assert_eq!(names.len(), 1, "only the .ts file should be discovered");
1231 assert!(names.contains(&"src/app.ts".to_string()));
1232 }
1233
1234 #[test]
1235 fn excludes_disallowed_hidden_directories() {
1236 let dir = tempfile::tempdir().expect("create temp dir");
1237
1238 let git_dir = dir.path().join(".git");
1239 std::fs::create_dir_all(&git_dir).unwrap();
1240 std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
1241
1242 let idea_dir = dir.path().join(".idea");
1243 std::fs::create_dir_all(&idea_dir).unwrap();
1244 std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
1245
1246 let cache_dir = dir.path().join(".cache");
1247 std::fs::create_dir_all(&cache_dir).unwrap();
1248 std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
1249
1250 let src = dir.path().join("src");
1251 std::fs::create_dir_all(&src).unwrap();
1252 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1253
1254 let config = make_config(dir.path().to_path_buf(), false);
1255 let files = discover_files(&config);
1256 let names = file_names(&files, dir.path());
1257
1258 assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
1259 assert!(names.contains(&"src/app.ts".to_string()));
1260 }
1261
1262 #[test]
1263 fn includes_allowed_hidden_directories() {
1264 let dir = tempfile::tempdir().expect("create temp dir");
1265
1266 let storybook = dir.path().join(".storybook");
1267 std::fs::create_dir_all(&storybook).unwrap();
1268 std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
1269
1270 let github = dir.path().join(".github");
1271 std::fs::create_dir_all(&github).unwrap();
1272 std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
1273
1274 let changeset = dir.path().join(".changeset");
1275 std::fs::create_dir_all(&changeset).unwrap();
1276 std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
1277
1278 let config = make_config(dir.path().to_path_buf(), false);
1279 let files = discover_files(&config);
1280 let names = file_names(&files, dir.path());
1281
1282 assert!(
1283 names.contains(&".storybook/main.ts".to_string()),
1284 "files in .storybook should be discovered"
1285 );
1286 assert!(
1287 names.contains(&".github/actions.js".to_string()),
1288 "files in .github should be discovered"
1289 );
1290 assert!(
1291 names.contains(&".changeset/config.js".to_string()),
1292 "files in .changeset should be discovered"
1293 );
1294 }
1295
1296 #[test]
1297 fn default_discovery_excludes_client_and_server_hidden_directories() {
1298 let dir = tempfile::tempdir().expect("create temp dir");
1299 let app = dir.path().join("app");
1300 std::fs::create_dir_all(app.join(".client")).unwrap();
1301 std::fs::create_dir_all(app.join(".server")).unwrap();
1302 std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
1303 std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
1304 std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
1305
1306 let config = make_config(dir.path().to_path_buf(), false);
1307 let files = discover_files(&config);
1308 let names = file_names(&files, dir.path());
1309
1310 assert!(names.contains(&"app/root.tsx".to_string()));
1311 assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
1312 assert!(!names.contains(&"app/.server/db.ts".to_string()));
1313 }
1314
1315 #[test]
1316 fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
1317 let dir = tempfile::tempdir().expect("create temp dir");
1318 let package = dir.path().join("packages/app");
1319 std::fs::create_dir_all(package.join("app/.client")).unwrap();
1320 std::fs::create_dir_all(package.join("app/.server")).unwrap();
1321 std::fs::write(
1322 package.join("app/.client/analytics.ts"),
1323 "export const track = () => {};",
1324 )
1325 .unwrap();
1326 std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
1327
1328 let config = make_config(dir.path().to_path_buf(), false);
1329 let scopes = [HiddenDirScope::new(
1330 package,
1331 vec![".client".to_string(), ".server".to_string()],
1332 )];
1333 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1334 let names = file_names(&files, dir.path());
1335
1336 assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
1337 assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
1338 }
1339
1340 #[test]
1341 fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
1342 let dir = tempfile::tempdir().expect("create temp dir");
1343 let active = dir.path().join("packages/active");
1344 let inactive = dir.path().join("packages/inactive");
1345 std::fs::create_dir_all(active.join("app/.server")).unwrap();
1346 std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
1347 std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
1348 std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
1349
1350 let config = make_config(dir.path().to_path_buf(), false);
1351 let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
1352 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1353 let names = file_names(&files, dir.path());
1354
1355 assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
1356 assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
1357 }
1358
1359 #[test]
1360 fn excludes_root_build_directory() {
1361 let dir = tempfile::tempdir().expect("create temp dir");
1362
1363 std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
1364
1365 let build_dir = dir.path().join("build");
1366 std::fs::create_dir_all(&build_dir).unwrap();
1367 std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
1368
1369 let src = dir.path().join("src");
1370 std::fs::create_dir_all(&src).unwrap();
1371 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1372
1373 let config = make_config(dir.path().to_path_buf(), false);
1374 let files = discover_files(&config);
1375 let names = file_names(&files, dir.path());
1376
1377 assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
1378 assert!(names.contains(&"src/app.ts".to_string()));
1379 }
1380
1381 #[test]
1382 fn includes_nested_build_directory() {
1383 let dir = tempfile::tempdir().expect("create temp dir");
1384
1385 let nested_build = dir.path().join("src").join("build");
1386 std::fs::create_dir_all(&nested_build).unwrap();
1387 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1388
1389 let config = make_config(dir.path().to_path_buf(), false);
1390 let files = discover_files(&config);
1391 let names = file_names(&files, dir.path());
1392
1393 assert!(
1394 names.contains(&"src/build/helper.ts".to_string()),
1395 "nested build/ directories should be included"
1396 );
1397 }
1398
1399 #[test]
1400 #[expect(
1401 clippy::cast_possible_truncation,
1402 reason = "test file counts are trivially small"
1403 )]
1404 fn file_ids_are_sequential_after_sorting() {
1405 let dir = tempfile::tempdir().expect("create temp dir");
1406 let src = dir.path().join("src");
1407 std::fs::create_dir_all(&src).unwrap();
1408
1409 std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
1410 std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
1411 std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
1412
1413 let config = make_config(dir.path().to_path_buf(), false);
1414 let files = discover_files(&config);
1415
1416 for (idx, file) in files.iter().enumerate() {
1417 assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
1418 }
1419
1420 for pair in files.windows(2) {
1421 assert!(
1422 pair[0].path < pair[1].path,
1423 "files should be sorted by path"
1424 );
1425 }
1426 }
1427
1428 #[test]
1429 fn production_mode_excludes_test_files() {
1430 let dir = tempfile::tempdir().expect("create temp dir");
1431 let src = dir.path().join("src");
1432 std::fs::create_dir_all(&src).unwrap();
1433
1434 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1435 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1436 std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
1437 std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
1438
1439 let config = make_config(dir.path().to_path_buf(), true);
1440 let files = discover_files(&config);
1441 let names = file_names(&files, dir.path());
1442
1443 assert!(
1444 names.contains(&"src/app.ts".to_string()),
1445 "source files should be included in production mode"
1446 );
1447 assert!(
1448 !names.contains(&"src/app.test.ts".to_string()),
1449 "test files should be excluded in production mode"
1450 );
1451 assert!(
1452 !names.contains(&"src/app.spec.ts".to_string()),
1453 "spec files should be excluded in production mode"
1454 );
1455 assert!(
1456 !names.contains(&"src/app.stories.tsx".to_string()),
1457 "story files should be excluded in production mode"
1458 );
1459 }
1460
1461 #[test]
1462 fn non_production_mode_includes_test_files() {
1463 let dir = tempfile::tempdir().expect("create temp dir");
1464 let src = dir.path().join("src");
1465 std::fs::create_dir_all(&src).unwrap();
1466
1467 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1468 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1469
1470 let config = make_config(dir.path().to_path_buf(), false);
1471 let files = discover_files(&config);
1472 let names = file_names(&files, dir.path());
1473
1474 assert!(names.contains(&"src/app.ts".to_string()));
1475 assert!(
1476 names.contains(&"src/app.test.ts".to_string()),
1477 "test files should be included in non-production mode"
1478 );
1479 }
1480
1481 #[test]
1482 fn empty_directory_returns_no_files() {
1483 let dir = tempfile::tempdir().expect("create temp dir");
1484 let config = make_config(dir.path().to_path_buf(), false);
1485 let files = discover_files(&config);
1486 assert!(files.is_empty(), "empty project should discover no files");
1487 }
1488
1489 #[test]
1490 fn hidden_files_not_discovered_as_source() {
1491 let dir = tempfile::tempdir().expect("create temp dir");
1492
1493 std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
1494 std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
1495 std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
1496
1497 let src = dir.path().join("src");
1498 std::fs::create_dir_all(&src).unwrap();
1499 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1500
1501 let config = make_config(dir.path().to_path_buf(), false);
1502 let files = discover_files(&config);
1503 let names = file_names(&files, dir.path());
1504
1505 assert!(
1506 !names.contains(&".env".to_string()),
1507 ".env should not be discovered"
1508 );
1509 assert!(
1510 !names.contains(&".gitignore".to_string()),
1511 ".gitignore should not be discovered"
1512 );
1513 }
1514
1515 fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
1517 FallowConfig {
1518 schema: None,
1519 extends: vec![],
1520 entry: vec![],
1521 ignore_patterns: ignores,
1522 framework: vec![],
1523 workspaces: None,
1524 ignore_dependencies: vec![],
1525 ignore_unresolved_imports: vec![],
1526 ignore_exports: vec![],
1527 ignore_catalog_references: vec![],
1528 ignore_dependency_overrides: vec![],
1529 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
1530 ),
1531 used_class_members: vec![],
1532 ignore_decorators: vec![],
1533 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1534 duplicates: DuplicatesConfig::default(),
1535 health: HealthConfig::default(),
1536 rules: RulesConfig::default(),
1537 boundaries: fallow_config::BoundaryConfig::default(),
1538 production: false.into(),
1539 plugins: vec![],
1540 rule_packs: vec![],
1541 dynamically_loaded: vec![],
1542 overrides: vec![],
1543 regression: None,
1544 audit: fallow_config::AuditConfig::default(),
1545 codeowners: None,
1546 public_packages: vec![],
1547 flags: FlagsConfig::default(),
1548 security: fallow_config::SecurityConfig::default(),
1549 fix: fallow_config::FixConfig::default(),
1550 resolve: ResolveConfig::default(),
1551 sealed: false,
1552 include_entry_exports: false,
1553 auto_imports: false,
1554 cache: fallow_config::CacheConfig::default(),
1555 }
1556 .resolve(root, OutputFormat::Human, 1, true, true, None)
1557 }
1558
1559 #[test]
1560 fn custom_ignore_patterns_exclude_matching_files() {
1561 let dir = tempfile::tempdir().expect("create temp dir");
1562
1563 let generated = dir.path().join("src").join("api").join("generated");
1564 std::fs::create_dir_all(&generated).unwrap();
1565 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1566
1567 let client = dir.path().join("src").join("api").join("client");
1568 std::fs::create_dir_all(&client).unwrap();
1569 std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
1570
1571 let src = dir.path().join("src");
1572 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1573
1574 let config = make_config_with_ignores(
1575 dir.path().to_path_buf(),
1576 vec![
1577 "src/api/generated/**".to_string(),
1578 "src/api/client/**".to_string(),
1579 ],
1580 );
1581 let files = discover_files(&config);
1582 let names = file_names(&files, dir.path());
1583
1584 assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
1585 assert!(names.contains(&"src/index.ts".to_string()));
1586 }
1587
1588 #[test]
1589 fn leading_dot_ignore_patterns_exclude_matching_files() {
1590 let dir = tempfile::tempdir().expect("create temp dir");
1591
1592 let generated = dir.path().join("src").join("generated");
1593 std::fs::create_dir_all(&generated).unwrap();
1594 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1595
1596 let src = dir.path().join("src");
1597 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1598
1599 let config = make_config_with_ignores(
1600 dir.path().to_path_buf(),
1601 vec!["./src/generated/**".to_string()],
1602 );
1603 let files = discover_files(&config);
1604 let names = file_names(&files, dir.path());
1605
1606 assert_eq!(names, vec!["src/index.ts"]);
1607 }
1608
1609 #[test]
1610 fn default_ignore_patterns_exclude_node_modules_and_dist() {
1611 let dir = tempfile::tempdir().expect("create temp dir");
1612
1613 let nm = dir.path().join("node_modules").join("lodash");
1614 std::fs::create_dir_all(&nm).unwrap();
1615 std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
1616
1617 let dist = dir.path().join("dist");
1618 std::fs::create_dir_all(&dist).unwrap();
1619 std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
1620
1621 let src = dir.path().join("src");
1622 std::fs::create_dir_all(&src).unwrap();
1623 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1624
1625 let config = make_config(dir.path().to_path_buf(), false);
1626 let files = discover_files(&config);
1627 let names = file_names(&files, dir.path());
1628
1629 assert_eq!(names.len(), 1);
1630 assert!(names.contains(&"src/index.ts".to_string()));
1631 }
1632
1633 #[test]
1634 fn default_ignore_patterns_exclude_root_build() {
1635 let dir = tempfile::tempdir().expect("create temp dir");
1636
1637 let build = dir.path().join("build");
1638 std::fs::create_dir_all(&build).unwrap();
1639 std::fs::write(build.join("output.js"), "// built").unwrap();
1640
1641 let nested_build = dir.path().join("src").join("build");
1642 std::fs::create_dir_all(&nested_build).unwrap();
1643 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1644
1645 let src = dir.path().join("src");
1646 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1647
1648 let config = make_config(dir.path().to_path_buf(), false);
1649 let files = discover_files(&config);
1650 let names = file_names(&files, dir.path());
1651
1652 assert_eq!(
1653 names.len(),
1654 2,
1655 "root build/ excluded, nested kept: {names:?}"
1656 );
1657 assert!(names.contains(&"src/index.ts".to_string()));
1658 assert!(names.contains(&"src/build/helper.ts".to_string()));
1659 }
1660
1661 fn make_config_with_max_file_size(
1663 root: PathBuf,
1664 max_file_size_bytes: Option<u64>,
1665 ) -> ResolvedConfig {
1666 let mut config = make_config(root, false);
1667 config.max_file_size_bytes = max_file_size_bytes;
1668 config
1669 }
1670
1671 #[test]
1672 fn skips_files_over_max_file_size() {
1673 let dir = tempfile::tempdir().expect("create temp dir");
1674 let src = dir.path().join("src");
1675 std::fs::create_dir_all(&src).unwrap();
1676 std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
1677 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1678
1679 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1680 let files = discover_files(&config);
1681 let names = file_names(&files, dir.path());
1682
1683 assert!(names.contains(&"src/small.ts".to_string()));
1684 assert!(
1685 !names.contains(&"src/huge.ts".to_string()),
1686 "a file over the size limit must not be discovered"
1687 );
1688 }
1689
1690 #[test]
1691 fn declaration_files_exempt_from_size_skip() {
1692 let dir = tempfile::tempdir().expect("create temp dir");
1693 let src = dir.path().join("src");
1694 std::fs::create_dir_all(&src).unwrap();
1695 std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
1696 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1697
1698 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1699 let files = discover_files(&config);
1700 let names = file_names(&files, dir.path());
1701
1702 assert!(
1703 names.contains(&"src/auto-imports.d.ts".to_string()),
1704 "a large .d.ts is exempt from the skip (reachability root for global types)"
1705 );
1706 assert!(!names.contains(&"src/huge.ts".to_string()));
1707 }
1708
1709 #[test]
1710 fn unlimited_size_keeps_large_files() {
1711 let dir = tempfile::tempdir().expect("create temp dir");
1712 let src = dir.path().join("src");
1713 std::fs::create_dir_all(&src).unwrap();
1714 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1715
1716 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1717 let files = discover_files(&config);
1718 let names = file_names(&files, dir.path());
1719
1720 assert!(
1721 names.contains(&"src/huge.ts".to_string()),
1722 "no limit keeps every file"
1723 );
1724 }
1725
1726 #[test]
1727 fn skipped_file_recorded_in_workspace_diagnostics() {
1728 let dir = tempfile::tempdir().expect("create temp dir");
1729 let src = dir.path().join("src");
1730 std::fs::create_dir_all(&src).unwrap();
1731 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1732
1733 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1734 let _ = discover_files(&config);
1735
1736 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1737 let skipped: Vec<_> = diagnostics
1738 .iter()
1739 .filter(|d| {
1740 matches!(
1741 d.kind,
1742 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1743 )
1744 })
1745 .collect();
1746 assert_eq!(
1747 skipped.len(),
1748 1,
1749 "the skipped file is recorded in workspace diagnostics for JSON output"
1750 );
1751 assert!(skipped[0].path.ends_with("src/huge.ts"));
1752 assert!(
1753 matches!(
1754 skipped[0].kind,
1755 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
1756 if size_bytes == 5_000
1757 ),
1758 "the recorded diagnostic carries the on-disk byte size"
1759 );
1760 }
1761
1762 #[test]
1763 fn skips_large_one_line_js_as_minified_generated_output() {
1764 let dir = tempfile::tempdir().expect("create temp dir");
1765 let src = dir.path().join("src");
1766 std::fs::create_dir_all(&src).unwrap();
1767 let asset = src.join("index-abc123.js");
1768 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1769
1770 let config = make_config(dir.path().to_path_buf(), false);
1771 let files = discover_files(&config);
1772 let names = file_names(&files, dir.path());
1773
1774 assert!(
1775 !names.contains(&"src/index-abc123.js".to_string()),
1776 "large one-line JS assets should be skipped before parsing"
1777 );
1778
1779 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1780 assert!(
1781 diagnostics.iter().any(|diag| {
1782 diag.path.ends_with("src/index-abc123.js")
1783 && matches!(
1784 diag.kind,
1785 fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
1786 )
1787 }),
1788 "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
1789 );
1790 }
1791
1792 #[test]
1793 fn unlimited_size_keeps_large_one_line_js() {
1794 let dir = tempfile::tempdir().expect("create temp dir");
1795 let src = dir.path().join("src");
1796 std::fs::create_dir_all(&src).unwrap();
1797 let asset = src.join("index-abc123.js");
1798 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1799
1800 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1801 let files = discover_files(&config);
1802 let names = file_names(&files, dir.path());
1803
1804 assert!(
1805 names.contains(&"src/index-abc123.js".to_string()),
1806 "--max-file-size 0 should opt out of generated JS skipping"
1807 );
1808 }
1809
1810 #[test]
1811 fn keeps_large_multiline_js() {
1812 let dir = tempfile::tempdir().expect("create temp dir");
1813 let src = dir.path().join("src");
1814 std::fs::create_dir_all(&src).unwrap();
1815 let asset = src.join("handwritten.js");
1816 let mut content = String::new();
1817 while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
1818 content.push_str("export const value = 1;\n");
1819 }
1820 std::fs::write(&asset, content).unwrap();
1821
1822 let config = make_config(dir.path().to_path_buf(), false);
1823 let files = discover_files(&config);
1824 let names = file_names(&files, dir.path());
1825
1826 assert!(
1827 names.contains(&"src/handwritten.js".to_string()),
1828 "large multiline JS should not be treated as a generated minified asset"
1829 );
1830 }
1831 }
1832}