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(
179 config: &ResolvedConfig,
180 skipped: &[SizedFile],
181) -> Vec<WorkspaceDiagnostic> {
182 if skipped.is_empty() {
183 return Vec::new();
184 }
185 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
186 .iter()
187 .map(|(path, size_bytes)| {
188 WorkspaceDiagnostic::new(
189 &config.root,
190 path.clone(),
191 WorkspaceDiagnosticKind::SkippedLargeFile {
192 size_bytes: *size_bytes,
193 },
194 )
195 })
196 .collect();
197
198 let mut sorted: Vec<SizedFile> = skipped.to_vec();
199 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
200 let count = skipped.len();
201 if !config.quiet
202 && should_emit_note_once(format!(
203 "skip::{}::{count}::{}",
204 config.root.display(),
205 sorted.first().map_or(0, |f| f.1)
206 ))
207 {
208 let examples = summarize_examples(&config.root, &sorted);
209 let noun = if count == 1 { "file" } else { "files" };
210 tracing::warn!(
211 "fallow: skipped {count} {noun} over the max file size limit ({examples}). \
212 Raise the limit with --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add them to ignorePatterns."
213 );
214 }
215 diagnostics
216}
217
218fn report_skipped_minified_files(
221 config: &ResolvedConfig,
222 skipped: &[SizedFile],
223) -> Vec<WorkspaceDiagnostic> {
224 if skipped.is_empty() {
225 return Vec::new();
226 }
227 let diagnostics: Vec<WorkspaceDiagnostic> = skipped
228 .iter()
229 .map(|(path, size_bytes)| {
230 WorkspaceDiagnostic::new(
231 &config.root,
232 path.clone(),
233 WorkspaceDiagnosticKind::SkippedMinifiedFile {
234 size_bytes: *size_bytes,
235 },
236 )
237 })
238 .collect();
239
240 let mut sorted: Vec<SizedFile> = skipped.to_vec();
241 sorted.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
242 let count = skipped.len();
243 if !config.quiet
244 && should_emit_note_once(format!(
245 "minified::{}::{count}::{}",
246 config.root.display(),
247 sorted.first().map_or(0, |f| f.1)
248 ))
249 {
250 let examples = summarize_examples(&config.root, &sorted);
251 let noun = if count == 1 { "file" } else { "files" };
252 let pronoun = if count == 1 { "it" } else { "them" };
253 tracing::warn!(
254 "fallow: skipped {count} minified generated JS {noun} ({examples}). \
255 Add {pronoun} to ignorePatterns, rename {pronoun} with a .min.js suffix, or use --max-file-size 0 to analyze {pronoun}."
256 );
257 }
258 diagnostics
259}
260
261fn build_largest_files_note(root: &Path, files: &[DiscoveredFile]) -> Option<String> {
266 if files.is_empty() {
267 return None;
268 }
269 let largest = files.iter().map(|f| f.size_bytes).max().unwrap_or(0);
270 if files.len() <= LARGE_SET_THRESHOLD && largest < LARGE_FILE_NOTE_BYTES {
271 return None;
272 }
273 let count = files.len();
274 let noun = if count == 1 { "file" } else { "files" };
275 let mut by_size: Vec<SizedFile> = files
276 .iter()
277 .filter(|f| f.size_bytes >= NOTE_FILE_FLOOR_BYTES)
278 .map(|f| (f.path.clone(), f.size_bytes))
279 .collect();
280 by_size.sort_unstable_by_key(|f| std::cmp::Reverse(f.1));
281 if by_size.is_empty() {
282 return Some(format!(
285 "fallow: discovered {count} {noun}. If analysis stalls or runs out of memory, \
286 exclude large generated files via ignorePatterns or --max-file-size."
287 ));
288 }
289 let examples = summarize_examples(root, &by_size);
290 Some(format!(
291 "fallow: discovered {count} {noun}; largest: {examples}. If analysis stalls or runs out of memory, \
292 exclude large generated files via ignorePatterns or --max-file-size."
293 ))
294}
295
296fn note_largest_files(config: &ResolvedConfig, files: &[DiscoveredFile]) {
301 if config.quiet {
302 return;
303 }
304 if let Some(message) = build_largest_files_note(&config.root, files)
305 && should_emit_note_once(format!("note::{}::{}", config.root.display(), files.len()))
306 {
307 tracing::warn!("{message}");
308 }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct HiddenDirScope {
314 root: PathBuf,
315 dirs: Vec<String>,
316}
317
318impl HiddenDirScope {
319 #[must_use]
322 pub fn new(root: PathBuf, dirs: Vec<String>) -> Self {
323 Self { root, dirs }
324 }
325
326 #[must_use]
327 pub fn root(&self) -> &Path {
328 &self.root
329 }
330
331 #[must_use]
332 pub fn dirs(&self) -> &[String] {
333 &self.dirs
334 }
335
336 fn allows(&self, path: &Path, name: &OsStr) -> bool {
337 path.starts_with(&self.root) && self.dirs.iter().any(|dir| OsStr::new(dir) == name)
338 }
339}
340
341struct FileVisitor<'a> {
348 root: &'a Path,
349 canonical_root: Option<&'a Path>,
350 ignore_patterns: &'a globset::GlobSet,
351 production_excludes: &'a Option<globset::GlobSet>,
352 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
353 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
354 local: Vec<(std::path::PathBuf, u64)>,
355 config_local: Vec<std::path::PathBuf>,
356}
357
358impl ignore::ParallelVisitor for FileVisitor<'_> {
359 fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
360 let Ok(entry) = result else {
361 return ignore::WalkState::Continue;
362 };
363 if entry.file_type().is_some_and(|ft| ft.is_dir()) {
364 return ignore::WalkState::Continue;
365 }
366 let relative = entry
367 .path()
368 .strip_prefix(self.root)
369 .unwrap_or_else(|_| entry.path());
370 if self.ignore_patterns.is_match(relative) {
371 return ignore::WalkState::Continue;
372 }
373 if self
374 .production_excludes
375 .as_ref()
376 .is_some_and(|excludes| excludes.is_match(relative))
377 {
378 return ignore::WalkState::Continue;
379 }
380 let symlink_size = if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
381 let Some(size) = contained_symlink_file_size(entry.path(), self.canonical_root) else {
382 tracing::debug!(
383 path = %entry.path().display(),
384 "skipping source symlink with a broken, non-file, or outside-root target"
385 );
386 return ignore::WalkState::Continue;
387 };
388 Some(size)
389 } else {
390 None
391 };
392 if has_source_extension(entry.path()) {
393 let size_bytes =
394 symlink_size.unwrap_or_else(|| entry.metadata().map_or(0, |m| m.len()));
395 self.local.push((entry.into_path(), size_bytes));
396 } else if self.config_shared.is_some() {
397 self.config_local.push(entry.into_path());
400 }
401 ignore::WalkState::Continue
402 }
403}
404
405fn contained_symlink_file_size(path: &Path, canonical_root: Option<&Path>) -> Option<u64> {
406 let root = canonical_root?;
407 let target = path.canonicalize().ok()?;
408 if !target.starts_with(root) {
409 return None;
410 }
411 let metadata = target.metadata().ok()?;
412 metadata.is_file().then_some(metadata.len())
413}
414
415impl Drop for FileVisitor<'_> {
416 #[expect(
417 clippy::expect_used,
418 reason = "poisoned walk collector lock means worker state is unrecoverable"
419 )]
420 fn drop(&mut self) {
421 if !self.local.is_empty() {
422 self.shared
423 .lock()
424 .expect("walk collector lock poisoned")
425 .append(&mut self.local);
426 }
427 if let Some(config_shared) = self.config_shared
428 && !self.config_local.is_empty()
429 {
430 config_shared
431 .lock()
432 .expect("walk config collector lock poisoned")
433 .append(&mut self.config_local);
434 }
435 }
436}
437
438struct FileVisitorBuilder<'a> {
440 root: &'a Path,
441 canonical_root: Option<&'a Path>,
442 ignore_patterns: &'a globset::GlobSet,
443 production_excludes: &'a Option<globset::GlobSet>,
444 shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
445 config_shared: Option<&'a Mutex<Vec<std::path::PathBuf>>>,
446}
447
448impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
449 fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
450 Box::new(FileVisitor {
451 root: self.root,
452 canonical_root: self.canonical_root,
453 ignore_patterns: self.ignore_patterns,
454 production_excludes: self.production_excludes,
455 shared: self.shared,
456 config_shared: self.config_shared,
457 local: Vec::new(),
458 config_local: Vec::new(),
459 })
460 }
461}
462
463pub const SOURCE_EXTENSIONS: &[&str] = &[
465 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
466 "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
467];
468
469pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
471 "**/*.test.*",
472 "**/*.spec.*",
473 "**/*.e2e.*",
474 "**/*.e2e-spec.*",
475 "**/*.bench.*",
476 "**/*.fixture.*",
477 "**/*.stories.*",
478 "**/*.story.*",
479 "**/__tests__/**",
480 "**/__mocks__/**",
481 "**/__snapshots__/**",
482 "**/__fixtures__/**",
483 "**/test/**",
484 "**/tests/**",
485 "*.config.*",
486 "**/.*.js",
487 "**/.*.ts",
488 "**/.*.mjs",
489 "**/.*.cjs",
490];
491
492pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
494 ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
495}
496
497fn is_allowed_scoped_hidden_dir(
498 name: &OsStr,
499 path: &Path,
500 additional_hidden_dir_scopes: &[HiddenDirScope],
501) -> bool {
502 additional_hidden_dir_scopes
503 .iter()
504 .any(|scope| scope.allows(path, name))
505}
506
507fn is_allowed_hidden(entry: &ignore::DirEntry) -> bool {
513 is_allowed_hidden_with_scopes(entry, &[])
514}
515
516fn is_allowed_hidden_with_scopes(
517 entry: &ignore::DirEntry,
518 additional_hidden_dir_scopes: &[HiddenDirScope],
519) -> bool {
520 let name = entry.file_name();
521 let name_str = name.to_string_lossy();
522
523 if !name_str.starts_with('.') {
524 return true;
525 }
526
527 if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
528 return true;
529 }
530
531 is_allowed_hidden_dir(name)
532 || is_allowed_scoped_hidden_dir(name, entry.path(), additional_hidden_dir_scopes)
533}
534
535pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
541 discover_files_with_additional_hidden_dirs(config, &[])
542}
543
544fn config_candidate_basename_globs() -> &'static [String] {
556 static GLOBS: OnceLock<Vec<String>> = OnceLock::new();
557 GLOBS.get_or_init(|| {
558 let mut set: FxHashSet<String> = FxHashSet::default();
559 for plugin in crate::plugins::registry::builtin::create_builtin_plugins() {
560 for pattern in plugin.config_patterns() {
561 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
562 set.insert(basename.to_string());
563 }
564 }
565 let mut globs: Vec<String> = set.into_iter().collect();
566 globs.sort_unstable();
567 globs
568 })
569}
570
571fn has_source_extension(path: &Path) -> bool {
574 path.extension()
575 .and_then(OsStr::to_str)
576 .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
577}
578
579#[expect(
583 clippy::expect_used,
584 reason = "source file globs are hard-coded compile-time constants"
585)]
586fn build_walk_types(capture_config: bool) -> ignore::types::Types {
587 static SOURCE_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
588 static SOURCE_AND_CONFIG_TYPES: OnceLock<ignore::types::Types> = OnceLock::new();
589
590 let cache = if capture_config {
591 &SOURCE_AND_CONFIG_TYPES
592 } else {
593 &SOURCE_TYPES
594 };
595 cache
596 .get_or_init(|| {
597 let mut types_builder = ignore::types::TypesBuilder::new();
598 let source_glob = format!("*.{{{}}}", SOURCE_EXTENSIONS.join(","));
599 types_builder
600 .add("source", &source_glob)
601 .expect("valid glob");
602 types_builder.select("source");
603 if capture_config {
604 for glob in config_candidate_basename_globs() {
605 let _ = types_builder.add("config", glob);
609 }
610 types_builder.select("config");
611 }
612 types_builder.build().expect("valid types")
613 })
614 .clone()
615}
616
617fn build_source_walk_builder(
621 config: &ResolvedConfig,
622 additional_hidden_dir_scopes: &[HiddenDirScope],
623 capture_config: bool,
624) -> WalkBuilder {
625 let mut walk_builder = WalkBuilder::new(&config.root);
626 walk_builder
627 .hidden(false)
628 .git_ignore(true)
629 .git_global(true)
630 .git_exclude(true)
631 .types(build_walk_types(capture_config))
632 .threads(config.threads);
633 if additional_hidden_dir_scopes.is_empty() {
634 walk_builder.filter_entry(is_allowed_hidden);
635 } else {
636 let scopes = additional_hidden_dir_scopes.to_vec();
637 walk_builder.filter_entry(move |entry| is_allowed_hidden_with_scopes(entry, &scopes));
638 }
639 walk_builder
640}
641
642fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
644 if !config.production {
645 return None;
646 }
647 let mut builder = globset::GlobSetBuilder::new();
648 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
649 if let Ok(glob) = globset::GlobBuilder::new(pattern)
650 .literal_separator(true)
651 .build()
652 {
653 builder.add(glob);
654 }
655 }
656 builder.build().ok()
657}
658
659pub fn discover_files_with_additional_hidden_dirs(
665 config: &ResolvedConfig,
666 additional_hidden_dir_scopes: &[HiddenDirScope],
667) -> Vec<DiscoveredFile> {
668 discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
669}
670
671pub fn discover_files_and_config_candidates(
687 config: &ResolvedConfig,
688 additional_hidden_dir_scopes: &[HiddenDirScope],
689) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
690 let discovered =
691 discover_files_config_candidates_and_diagnostics(config, additional_hidden_dir_scopes);
692 (discovered.files, discovered.config_candidates)
693}
694
695pub struct DiscoveredSources {
704 pub files: Vec<DiscoveredFile>,
706 pub config_candidates: Vec<PathBuf>,
708 pub diagnostics: Vec<WorkspaceDiagnostic>,
710}
711
712#[expect(
720 clippy::cast_possible_truncation,
721 reason = "file count is bounded by project size, well under u32::MAX"
722)]
723#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
724pub fn discover_files_config_candidates_and_diagnostics(
725 config: &ResolvedConfig,
726 additional_hidden_dir_scopes: &[HiddenDirScope],
727) -> DiscoveredSources {
728 let _span = tracing::info_span!("discover_files").entered();
729
730 let capture_config = !config.production;
731 let walk_builder =
732 build_source_walk_builder(config, additional_hidden_dir_scopes, capture_config);
733 let production_excludes = build_production_excludes(config);
734 let canonical_root = config.root.canonicalize().ok();
735
736 let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
737 let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
738 let mut visitor_builder = FileVisitorBuilder {
739 root: &config.root,
740 canonical_root: canonical_root.as_deref(),
741 ignore_patterns: &config.ignore_patterns,
742 production_excludes: &production_excludes,
743 shared: &collected,
744 config_shared: capture_config.then_some(&config_collected),
745 };
746 walk_builder.build_parallel().visit(&mut visitor_builder);
747
748 let mut raw = collected
749 .into_inner()
750 .expect("walk collector lock poisoned");
751 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
759
760 let mut config_candidates = config_collected
761 .into_inner()
762 .expect("walk config collector lock poisoned");
763 config_candidates.sort_unstable();
764
765 let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
766 let (kept, skipped_minified) =
767 partition_minified_generated_js(kept, config.max_file_size_bytes);
768 let diagnostics = fallow_config::replace_source_discovery_diagnostics(
773 &config.root,
774 report_skipped_large_files(config, &skipped)
775 .into_iter()
776 .chain(report_skipped_minified_files(config, &skipped_minified))
777 .collect(),
778 );
779
780 let files: Vec<DiscoveredFile> = kept
781 .into_iter()
782 .enumerate()
783 .map(|(idx, (path, size_bytes))| DiscoveredFile {
784 id: FileId(idx as u32),
785 path,
786 size_bytes,
787 })
788 .collect();
789
790 note_largest_files(config, &files);
791
792 DiscoveredSources {
793 files,
794 config_candidates,
795 diagnostics,
796 }
797}
798
799#[cfg(test)]
800mod tests {
801 use std::ffi::OsStr;
802
803 use super::*;
804
805 fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
808 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
809 raw.into_iter()
810 .enumerate()
811 .map(|(idx, (path, size_bytes))| DiscoveredFile {
812 id: FileId(idx as u32),
813 path,
814 size_bytes,
815 })
816 .collect()
817 }
818
819 #[test]
825 fn file_id_assignment_is_deterministic_for_identical_file_set() {
826 let paths = [
827 "/project/src/z.ts",
828 "/project/src/a.ts",
829 "/project/src/components/Button.tsx",
830 "/project/src/components/Button.module.css",
831 "/project/index.ts",
832 ];
833
834 let walk_one: Vec<(std::path::PathBuf, u64)> = paths
836 .iter()
837 .map(|p| (std::path::PathBuf::from(p), 10))
838 .collect();
839 let mut walk_two = walk_one.clone();
840 walk_two.reverse();
841
842 let files_one = assign_file_ids(walk_one);
843 let files_two = assign_file_ids(walk_two);
844
845 assert_eq!(files_one.len(), files_two.len());
847 for (a, b) in files_one.iter().zip(files_two.iter()) {
848 assert_eq!(a.id, b.id);
849 assert_eq!(a.path, b.path);
850 }
851
852 for (idx, file) in files_one.iter().enumerate() {
855 assert_eq!(file.id, FileId(idx as u32));
856 }
857 assert_eq!(
858 files_one[0].path,
859 std::path::PathBuf::from("/project/index.ts")
860 );
861 }
862
863 #[test]
864 fn file_id_assignment_recomputes_after_rename_or_delete() {
865 let before = assign_file_ids(vec![
866 (std::path::PathBuf::from("/project/src/a.ts"), 10),
867 (std::path::PathBuf::from("/project/src/b.ts"), 10),
868 (std::path::PathBuf::from("/project/src/c.ts"), 10),
869 ]);
870 let after_delete = assign_file_ids(vec![
871 (std::path::PathBuf::from("/project/src/a.ts"), 10),
872 (std::path::PathBuf::from("/project/src/c.ts"), 10),
873 ]);
874 let after_rename = assign_file_ids(vec![
875 (std::path::PathBuf::from("/project/src/a.ts"), 10),
876 (std::path::PathBuf::from("/project/src/c.ts"), 10),
877 (std::path::PathBuf::from("/project/src/d.ts"), 10),
878 ]);
879
880 assert_eq!(before[0].id, FileId(0));
881 assert_eq!(before[1].id, FileId(1));
882 assert_eq!(before[2].id, FileId(2));
883 assert_eq!(after_delete[0].id, FileId(0));
884 assert_eq!(after_delete[1].id, FileId(1));
885 assert_eq!(
886 after_delete[1].path,
887 std::path::PathBuf::from("/project/src/c.ts")
888 );
889 assert_eq!(after_rename[0].id, FileId(0));
890 assert_eq!(after_rename[1].id, FileId(1));
891 assert_eq!(
892 after_rename[1].path,
893 std::path::PathBuf::from("/project/src/c.ts")
894 );
895 assert_eq!(after_rename[2].id, FileId(2));
896 assert_eq!(
897 after_rename[2].path,
898 std::path::PathBuf::from("/project/src/d.ts")
899 );
900 }
901
902 #[test]
903 fn allowed_hidden_dirs() {
904 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
905 assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
906 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
907 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
908 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
909 }
910
911 #[test]
912 fn disallowed_hidden_dirs() {
913 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
914 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
915 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
916 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
917 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
918 }
919
920 #[test]
921 fn non_hidden_dirs_not_in_allowlist() {
922 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
923 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
924 }
925
926 #[test]
927 fn walk_types_match_every_supported_source_extension() {
928 for capture_config in [false, true] {
929 let types = build_walk_types(capture_config);
930 for extension in SOURCE_EXTENSIONS {
931 let path = format!("packages/ui/src/nested/component.{extension}");
932 assert!(
933 types.matched(&path, false).is_whitelist(),
934 "expected source match for {path} with capture_config={capture_config}"
935 );
936 }
937 }
938 }
939
940 #[test]
941 fn walk_types_match_typescript_declaration_files() {
942 let types = build_walk_types(true);
943 for path in [
944 "src/env.d.ts",
945 "packages/app/types/generated.d.mts",
946 "packages/app/types/compat.d.cts",
947 ] {
948 assert!(
949 types.matched(path, false).is_whitelist(),
950 "expected declaration source match for {path}"
951 );
952 }
953 }
954
955 #[test]
956 fn walk_types_reject_source_extension_near_misses() {
957 for capture_config in [false, true] {
958 let types = build_walk_types(capture_config);
959 for path in [
960 "src/component.tsx.bak",
961 "src/component.tsxmap",
962 "src/component.TS",
963 "src/component.gqlx",
964 "src/component.htm",
965 "src/component",
966 "assets/component.png",
967 ] {
968 assert!(
969 types.matched(path, false).is_ignore(),
970 "expected non-source rejection for {path} with capture_config={capture_config}"
971 );
972 }
973 }
974 }
975
976 #[test]
977 fn walk_types_keep_config_candidate_selection_separate() {
978 assert!(
979 build_walk_types(true)
980 .matched("packages/app/tsconfig.json", false)
981 .is_whitelist()
982 );
983 assert!(
984 build_walk_types(false)
985 .matched("packages/app/tsconfig.json", false)
986 .is_ignore()
987 );
988 }
989
990 #[test]
991 fn source_extensions_include_typescript() {
992 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
993 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
994 assert!(SOURCE_EXTENSIONS.contains(&"mts"));
995 assert!(SOURCE_EXTENSIONS.contains(&"cts"));
996 assert!(SOURCE_EXTENSIONS.contains(&"gts"));
997 }
998
999 #[test]
1000 fn source_extensions_include_javascript() {
1001 assert!(SOURCE_EXTENSIONS.contains(&"js"));
1002 assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
1003 assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
1004 assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
1005 assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
1006 }
1007
1008 #[test]
1009 fn source_extensions_include_sfc_formats() {
1010 assert!(SOURCE_EXTENSIONS.contains(&"vue"));
1011 assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
1012 assert!(SOURCE_EXTENSIONS.contains(&"astro"));
1013 }
1014
1015 #[test]
1016 fn source_extensions_include_styles() {
1017 assert!(SOURCE_EXTENSIONS.contains(&"css"));
1018 assert!(SOURCE_EXTENSIONS.contains(&"scss"));
1019 assert!(SOURCE_EXTENSIONS.contains(&"sass"));
1020 assert!(SOURCE_EXTENSIONS.contains(&"less"));
1021 }
1022
1023 #[test]
1024 fn source_extensions_exclude_non_source() {
1025 assert!(!SOURCE_EXTENSIONS.contains(&"json"));
1026 assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
1027 assert!(!SOURCE_EXTENSIONS.contains(&"md"));
1028 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1029 assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
1030 }
1031
1032 #[test]
1033 fn source_extensions_include_html() {
1034 assert!(SOURCE_EXTENSIONS.contains(&"html"));
1035 }
1036
1037 #[test]
1038 fn source_extensions_include_graphql_documents() {
1039 assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
1040 assert!(SOURCE_EXTENSIONS.contains(&"gql"));
1041 }
1042
1043 fn build_production_glob_set() -> globset::GlobSet {
1044 let mut builder = globset::GlobSetBuilder::new();
1045 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
1046 builder.add(
1047 globset::GlobBuilder::new(pattern)
1048 .literal_separator(true)
1049 .build()
1050 .expect("valid glob pattern"),
1051 );
1052 }
1053 builder.build().expect("valid glob set")
1054 }
1055
1056 #[test]
1057 fn production_excludes_test_files() {
1058 let set = build_production_glob_set();
1059 assert!(set.is_match("src/Button.test.ts"));
1060 assert!(set.is_match("src/utils.spec.tsx"));
1061 assert!(set.is_match("src/__tests__/helper.ts"));
1062 assert!(!set.is_match("src/Button.ts"));
1063 assert!(!set.is_match("src/utils.tsx"));
1064 }
1065
1066 #[test]
1067 fn production_excludes_story_files() {
1068 let set = build_production_glob_set();
1069 assert!(set.is_match("src/Button.stories.tsx"));
1070 assert!(set.is_match("src/Card.story.ts"));
1071 assert!(!set.is_match("src/Button.tsx"));
1072 }
1073
1074 #[test]
1075 fn production_excludes_config_files_at_root_only() {
1076 let set = build_production_glob_set();
1077 assert!(set.is_match("vitest.config.ts"));
1078 assert!(set.is_match("jest.config.js"));
1079 assert!(!set.is_match("src/app/app.config.ts"));
1080 assert!(!set.is_match("src/app/app.config.server.ts"));
1081 assert!(!set.is_match("packages/foo/vitest.config.ts"));
1082 assert!(!set.is_match("src/config.ts"));
1083 }
1084
1085 #[test]
1086 fn production_patterns_are_valid_globs() {
1087 let _ = build_production_glob_set();
1088 }
1089
1090 #[test]
1091 fn disallowed_hidden_dirs_idea() {
1092 assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
1093 }
1094
1095 #[test]
1096 fn source_extensions_include_mdx() {
1097 assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
1098 }
1099
1100 #[test]
1101 fn source_extensions_exclude_image_and_data_formats() {
1102 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1103 assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
1104 assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
1105 assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
1106 assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
1107 assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
1108 }
1109
1110 #[test]
1111 fn is_declaration_file_matches_dts_variants() {
1112 assert!(is_declaration_file(Path::new("env.d.ts")));
1113 assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
1114 assert!(is_declaration_file(Path::new("mod.d.mts")));
1115 assert!(is_declaration_file(Path::new("compat.d.cts")));
1116 assert!(!is_declaration_file(Path::new("index.ts")));
1117 assert!(!is_declaration_file(Path::new("component.tsx")));
1118 assert!(!is_declaration_file(Path::new("notes.d.txt")));
1119 }
1120
1121 #[test]
1122 fn format_size_mb_renders_one_decimal() {
1123 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1124 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1125 assert_eq!(format_size_mb(0), "0.0 MB");
1126 }
1127
1128 #[test]
1129 fn partition_by_size_no_limit_keeps_all() {
1130 let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
1131 let (kept, skipped) = partition_by_size(raw, None);
1132 assert_eq!(kept.len(), 2);
1133 assert!(skipped.is_empty());
1134 }
1135
1136 #[test]
1137 fn partition_by_size_skips_strictly_over_limit() {
1138 let raw = vec![
1139 (PathBuf::from("under.ts"), 99),
1140 (PathBuf::from("exact.ts"), 100),
1141 (PathBuf::from("over.ts"), 101),
1142 ];
1143 let (kept, skipped) = partition_by_size(raw, Some(100));
1144 let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
1145 assert!(kept_has("under.ts"));
1146 assert!(
1147 kept_has("exact.ts"),
1148 "a file exactly at the limit is kept (skip is strictly-greater)"
1149 );
1150 assert_eq!(skipped.len(), 1);
1151 assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
1152 }
1153
1154 #[test]
1155 fn partition_by_size_exempts_declaration_files() {
1156 let raw = vec![
1157 (PathBuf::from("huge.ts"), 10_000),
1158 (PathBuf::from("auto-imports.d.ts"), 10_000),
1159 ];
1160 let (kept, skipped) = partition_by_size(raw, Some(100));
1161 assert!(
1162 kept.iter()
1163 .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
1164 "declaration files are exempt from the size skip regardless of size"
1165 );
1166 assert_eq!(skipped.len(), 1);
1167 assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
1168 }
1169
1170 fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
1171 DiscoveredFile {
1172 id: FileId(0),
1173 path: PathBuf::from(path),
1174 size_bytes,
1175 }
1176 }
1177
1178 #[test]
1179 fn largest_files_note_below_threshold_is_none() {
1180 let files = [disco("a.ts", 100), disco("b.ts", 200)];
1181 assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
1182 }
1183
1184 #[test]
1185 fn largest_files_note_single_file_uses_singular() {
1186 let files = [disco("big.ts", 5 * 1024 * 1024)];
1187 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1188 assert!(
1189 note.contains("discovered 1 file;"),
1190 "singular noun on the single-big-file path (issue #1086 regression): {note}"
1191 );
1192 assert!(!note.contains("discovered 1 files"));
1193 assert!(note.contains("big.ts (5.0 MB)"));
1194 }
1195
1196 #[test]
1197 fn largest_files_note_filters_sub_floor_files() {
1198 let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
1199 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1200 assert!(note.contains("discovered 2 files;"));
1201 assert!(note.contains("big.ts (5.0 MB)"));
1202 assert!(
1203 !note.contains("tiny.ts"),
1204 "sub-floor files are not listed as `0.0 MB` chaff: {note}"
1205 );
1206 }
1207
1208 #[test]
1209 fn largest_files_note_large_set_no_big_file_omits_list() {
1210 let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
1211 .map(|i| disco(&format!("f{i}.ts"), 100))
1212 .collect();
1213 let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
1214 assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
1215 assert!(
1216 !note.contains("largest:"),
1217 "no sub-floor `largest:` list when no file clears the floor: {note}"
1218 );
1219 }
1220
1221 mod discover_files_integration {
1222 use std::path::PathBuf;
1223
1224 use fallow_config::{
1225 DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
1226 RulesConfig,
1227 };
1228
1229 use super::*;
1230
1231 fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
1233 FallowConfig {
1234 production: production.into(),
1235 ..Default::default()
1236 }
1237 .resolve(root, OutputFormat::Human, 1, true, true, None)
1238 }
1239
1240 fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
1243 files
1244 .iter()
1245 .map(|f| {
1246 f.path
1247 .strip_prefix(root)
1248 .unwrap_or(&f.path)
1249 .to_string_lossy()
1250 .replace('\\', "/")
1251 })
1252 .collect()
1253 }
1254
1255 #[cfg(unix)]
1256 fn symlink_file(target: &Path, link: &Path) {
1257 std::os::unix::fs::symlink(target, link).expect("create file symlink");
1258 }
1259
1260 #[cfg(windows)]
1261 fn symlink_file(target: &Path, link: &Path) {
1262 std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1263 }
1264
1265 #[cfg(unix)]
1266 fn symlink_dir(target: &Path, link: &Path) {
1267 std::os::unix::fs::symlink(target, link).expect("create directory symlink");
1268 }
1269
1270 #[cfg(windows)]
1271 fn symlink_dir(target: &Path, link: &Path) {
1272 std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
1273 }
1274
1275 #[test]
1276 fn source_symlinks_must_target_regular_files_inside_root() {
1277 let dir = tempfile::tempdir().expect("create project");
1278 let outside = tempfile::tempdir().expect("create outside dir");
1279 let src = dir.path().join("src");
1280 std::fs::create_dir_all(&src).unwrap();
1281 std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
1282 std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
1283 std::fs::write(
1284 outside.path().join("outside-target.ts"),
1285 "export const outside = 1;",
1286 )
1287 .unwrap();
1288 std::fs::create_dir_all(src.join("directory-target")).unwrap();
1289
1290 symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
1291 symlink_file(
1292 &outside.path().join("outside-target.ts"),
1293 &src.join("outside-link.ts"),
1294 );
1295 symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
1296 symlink_dir(
1297 &src.join("directory-target"),
1298 &src.join("directory-link.ts"),
1299 );
1300
1301 let config = make_config(dir.path().to_path_buf(), false);
1302 let names = file_names(&discover_files(&config), dir.path());
1303
1304 assert!(names.contains(&"src/regular.ts".to_string()));
1305 assert!(names.contains(&"src/inside-target.ts".to_string()));
1306 assert!(names.contains(&"src/inside-link.ts".to_string()));
1307 assert!(!names.contains(&"src/outside-link.ts".to_string()));
1308 assert!(!names.contains(&"src/broken-link.ts".to_string()));
1309 assert!(!names.contains(&"src/directory-link.ts".to_string()));
1310 }
1311
1312 #[test]
1313 fn discovers_source_files_with_valid_extensions() {
1314 let dir = tempfile::tempdir().expect("create temp dir");
1315 let src = dir.path().join("src");
1316 std::fs::create_dir_all(&src).unwrap();
1317
1318 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1319 std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
1320 std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
1321 std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
1322 std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
1323 std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
1324 std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
1325 std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
1326
1327 let config = make_config(dir.path().to_path_buf(), false);
1328 let files = discover_files(&config);
1329 let names = file_names(&files, dir.path());
1330
1331 assert!(names.contains(&"src/app.ts".to_string()));
1332 assert!(names.contains(&"src/component.tsx".to_string()));
1333 assert!(names.contains(&"src/utils.js".to_string()));
1334 assert!(names.contains(&"src/helper.jsx".to_string()));
1335 assert!(names.contains(&"src/config.mjs".to_string()));
1336 assert!(names.contains(&"src/legacy.cjs".to_string()));
1337 assert!(names.contains(&"src/types.mts".to_string()));
1338 assert!(names.contains(&"src/compat.cts".to_string()));
1339 }
1340
1341 #[test]
1342 fn compact_source_glob_preserves_discovered_file_inventory() {
1343 let dir = tempfile::tempdir().expect("create temp dir");
1344 let nested = dir.path().join("packages/ui/src/nested");
1345 std::fs::create_dir_all(&nested).unwrap();
1346
1347 let mut expected = Vec::new();
1348 for (index, extension) in SOURCE_EXTENSIONS.iter().enumerate() {
1349 let relative = format!("packages/ui/src/nested/source-{index}.{extension}");
1350 std::fs::write(dir.path().join(&relative), "export const value = 1;").unwrap();
1351 expected.push(relative);
1352 }
1353 for relative in [
1354 "packages/ui/src/nested/env.d.ts",
1355 "packages/ui/src/nested/generated.d.mts",
1356 "packages/ui/src/nested/compat.d.cts",
1357 ] {
1358 std::fs::write(dir.path().join(relative), "export type Value = string;").unwrap();
1359 expected.push(relative.to_string());
1360 }
1361 let rejected = [
1362 "packages/ui/src/nested/component.tsx.bak",
1363 "packages/ui/src/nested/component.tsxmap",
1364 "packages/ui/src/nested/component.TS",
1365 "packages/ui/src/nested/component.gqlx",
1366 "packages/ui/src/nested/component.htm",
1367 "packages/ui/src/nested/component",
1368 "packages/ui/src/nested/component.png",
1369 ];
1370 for relative in rejected {
1371 std::fs::write(dir.path().join(relative), "not source").unwrap();
1372 }
1373
1374 let config = make_config(dir.path().to_path_buf(), false);
1375 let names = file_names(&discover_files(&config), dir.path());
1376
1377 for relative in expected {
1378 assert!(
1379 names.contains(&relative),
1380 "missing supported source {relative}"
1381 );
1382 }
1383 for relative in rejected {
1384 assert!(
1385 !names.iter().any(|name| name == relative),
1386 "unexpected near-miss source {relative}"
1387 );
1388 }
1389 }
1390
1391 #[test]
1392 fn excludes_non_source_extensions() {
1393 let dir = tempfile::tempdir().expect("create temp dir");
1394 let src = dir.path().join("src");
1395 std::fs::create_dir_all(&src).unwrap();
1396
1397 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1398
1399 std::fs::write(src.join("data.json"), "{}").unwrap();
1400 std::fs::write(src.join("readme.md"), "# Hello").unwrap();
1401 std::fs::write(src.join("notes.txt"), "notes").unwrap();
1402 std::fs::write(src.join("logo.png"), [0u8; 8]).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, "only the .ts file should be discovered");
1409 assert!(names.contains(&"src/app.ts".to_string()));
1410 }
1411
1412 #[test]
1413 fn excludes_disallowed_hidden_directories() {
1414 let dir = tempfile::tempdir().expect("create temp dir");
1415
1416 let git_dir = dir.path().join(".git");
1417 std::fs::create_dir_all(&git_dir).unwrap();
1418 std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
1419
1420 let idea_dir = dir.path().join(".idea");
1421 std::fs::create_dir_all(&idea_dir).unwrap();
1422 std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
1423
1424 let cache_dir = dir.path().join(".cache");
1425 std::fs::create_dir_all(&cache_dir).unwrap();
1426 std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
1427
1428 let src = dir.path().join("src");
1429 std::fs::create_dir_all(&src).unwrap();
1430 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1431
1432 let config = make_config(dir.path().to_path_buf(), false);
1433 let files = discover_files(&config);
1434 let names = file_names(&files, dir.path());
1435
1436 assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
1437 assert!(names.contains(&"src/app.ts".to_string()));
1438 }
1439
1440 #[test]
1441 fn includes_allowed_hidden_directories() {
1442 let dir = tempfile::tempdir().expect("create temp dir");
1443
1444 let storybook = dir.path().join(".storybook");
1445 std::fs::create_dir_all(&storybook).unwrap();
1446 std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
1447
1448 let github = dir.path().join(".github");
1449 std::fs::create_dir_all(&github).unwrap();
1450 std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
1451
1452 let changeset = dir.path().join(".changeset");
1453 std::fs::create_dir_all(&changeset).unwrap();
1454 std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
1455
1456 let config = make_config(dir.path().to_path_buf(), false);
1457 let files = discover_files(&config);
1458 let names = file_names(&files, dir.path());
1459
1460 assert!(
1461 names.contains(&".storybook/main.ts".to_string()),
1462 "files in .storybook should be discovered"
1463 );
1464 assert!(
1465 names.contains(&".github/actions.js".to_string()),
1466 "files in .github should be discovered"
1467 );
1468 assert!(
1469 names.contains(&".changeset/config.js".to_string()),
1470 "files in .changeset should be discovered"
1471 );
1472 }
1473
1474 #[test]
1475 fn default_discovery_excludes_client_and_server_hidden_directories() {
1476 let dir = tempfile::tempdir().expect("create temp dir");
1477 let app = dir.path().join("app");
1478 std::fs::create_dir_all(app.join(".client")).unwrap();
1479 std::fs::create_dir_all(app.join(".server")).unwrap();
1480 std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
1481 std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
1482 std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
1483
1484 let config = make_config(dir.path().to_path_buf(), false);
1485 let files = discover_files(&config);
1486 let names = file_names(&files, dir.path());
1487
1488 assert!(names.contains(&"app/root.tsx".to_string()));
1489 assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
1490 assert!(!names.contains(&"app/.server/db.ts".to_string()));
1491 }
1492
1493 #[test]
1494 fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
1495 let dir = tempfile::tempdir().expect("create temp dir");
1496 let package = dir.path().join("packages/app");
1497 std::fs::create_dir_all(package.join("app/.client")).unwrap();
1498 std::fs::create_dir_all(package.join("app/.server")).unwrap();
1499 std::fs::write(
1500 package.join("app/.client/analytics.ts"),
1501 "export const track = () => {};",
1502 )
1503 .unwrap();
1504 std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
1505
1506 let config = make_config(dir.path().to_path_buf(), false);
1507 let scopes = [HiddenDirScope::new(
1508 package,
1509 vec![".client".to_string(), ".server".to_string()],
1510 )];
1511 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1512 let names = file_names(&files, dir.path());
1513
1514 assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
1515 assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
1516 }
1517
1518 #[test]
1519 fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
1520 let dir = tempfile::tempdir().expect("create temp dir");
1521 let active = dir.path().join("packages/active");
1522 let inactive = dir.path().join("packages/inactive");
1523 std::fs::create_dir_all(active.join("app/.server")).unwrap();
1524 std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
1525 std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
1526 std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
1527
1528 let config = make_config(dir.path().to_path_buf(), false);
1529 let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
1530 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1531 let names = file_names(&files, dir.path());
1532
1533 assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
1534 assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
1535 }
1536
1537 #[test]
1538 fn excludes_root_build_directory() {
1539 let dir = tempfile::tempdir().expect("create temp dir");
1540
1541 std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
1542
1543 let build_dir = dir.path().join("build");
1544 std::fs::create_dir_all(&build_dir).unwrap();
1545 std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
1546
1547 let src = dir.path().join("src");
1548 std::fs::create_dir_all(&src).unwrap();
1549 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1550
1551 let config = make_config(dir.path().to_path_buf(), false);
1552 let files = discover_files(&config);
1553 let names = file_names(&files, dir.path());
1554
1555 assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
1556 assert!(names.contains(&"src/app.ts".to_string()));
1557 }
1558
1559 #[test]
1560 fn includes_nested_build_directory() {
1561 let dir = tempfile::tempdir().expect("create temp dir");
1562
1563 let nested_build = dir.path().join("src").join("build");
1564 std::fs::create_dir_all(&nested_build).unwrap();
1565 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1566
1567 let config = make_config(dir.path().to_path_buf(), false);
1568 let files = discover_files(&config);
1569 let names = file_names(&files, dir.path());
1570
1571 assert!(
1572 names.contains(&"src/build/helper.ts".to_string()),
1573 "nested build/ directories should be included"
1574 );
1575 }
1576
1577 #[test]
1578 #[expect(
1579 clippy::cast_possible_truncation,
1580 reason = "test file counts are trivially small"
1581 )]
1582 fn file_ids_are_sequential_after_sorting() {
1583 let dir = tempfile::tempdir().expect("create temp dir");
1584 let src = dir.path().join("src");
1585 std::fs::create_dir_all(&src).unwrap();
1586
1587 std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
1588 std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
1589 std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
1590
1591 let config = make_config(dir.path().to_path_buf(), false);
1592 let files = discover_files(&config);
1593
1594 for (idx, file) in files.iter().enumerate() {
1595 assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
1596 }
1597
1598 for pair in files.windows(2) {
1599 assert!(
1600 pair[0].path < pair[1].path,
1601 "files should be sorted by path"
1602 );
1603 }
1604 }
1605
1606 #[test]
1607 fn production_mode_excludes_test_files() {
1608 let dir = tempfile::tempdir().expect("create temp dir");
1609 let src = dir.path().join("src");
1610 std::fs::create_dir_all(&src).unwrap();
1611
1612 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1613 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1614 std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
1615 std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
1616
1617 let config = make_config(dir.path().to_path_buf(), true);
1618 let files = discover_files(&config);
1619 let names = file_names(&files, dir.path());
1620
1621 assert!(
1622 names.contains(&"src/app.ts".to_string()),
1623 "source files should be included in production mode"
1624 );
1625 assert!(
1626 !names.contains(&"src/app.test.ts".to_string()),
1627 "test files should be excluded in production mode"
1628 );
1629 assert!(
1630 !names.contains(&"src/app.spec.ts".to_string()),
1631 "spec files should be excluded in production mode"
1632 );
1633 assert!(
1634 !names.contains(&"src/app.stories.tsx".to_string()),
1635 "story files should be excluded in production mode"
1636 );
1637 }
1638
1639 #[test]
1640 fn non_production_mode_includes_test_files() {
1641 let dir = tempfile::tempdir().expect("create temp dir");
1642 let src = dir.path().join("src");
1643 std::fs::create_dir_all(&src).unwrap();
1644
1645 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1646 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").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!(names.contains(&"src/app.ts".to_string()));
1653 assert!(
1654 names.contains(&"src/app.test.ts".to_string()),
1655 "test files should be included in non-production mode"
1656 );
1657 }
1658
1659 #[test]
1660 fn empty_directory_returns_no_files() {
1661 let dir = tempfile::tempdir().expect("create temp dir");
1662 let config = make_config(dir.path().to_path_buf(), false);
1663 let files = discover_files(&config);
1664 assert!(files.is_empty(), "empty project should discover no files");
1665 }
1666
1667 #[test]
1668 fn hidden_files_not_discovered_as_source() {
1669 let dir = tempfile::tempdir().expect("create temp dir");
1670
1671 std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
1672 std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
1673 std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
1674
1675 let src = dir.path().join("src");
1676 std::fs::create_dir_all(&src).unwrap();
1677 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1678
1679 let config = make_config(dir.path().to_path_buf(), false);
1680 let files = discover_files(&config);
1681 let names = file_names(&files, dir.path());
1682
1683 assert!(
1684 !names.contains(&".env".to_string()),
1685 ".env should not be discovered"
1686 );
1687 assert!(
1688 !names.contains(&".gitignore".to_string()),
1689 ".gitignore should not be discovered"
1690 );
1691 }
1692
1693 fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
1695 FallowConfig {
1696 type_aware: fallow_config::TypeAwareConfig::default(),
1697 schema: None,
1698 extends: vec![],
1699 entry: vec![],
1700 ignore_patterns: ignores,
1701 ignore_findings: vec![],
1702 framework: vec![],
1703 workspaces: None,
1704 ignore_dependencies: vec![],
1705 ignore_unresolved_imports: vec![],
1706 ignore_exports: vec![],
1707 ignore_catalog_references: vec![],
1708 ignore_dependency_overrides: vec![],
1709 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
1710 ),
1711 used_class_members: vec![],
1712 ignore_decorators: vec![],
1713 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1714 duplicates: DuplicatesConfig::default(),
1715 health: HealthConfig::default(),
1716 rules: RulesConfig::default(),
1717 boundaries: fallow_config::BoundaryConfig::default(),
1718 production: false.into(),
1719 plugins: vec![],
1720 rule_packs: vec![],
1721 dynamically_loaded: vec![],
1722 overrides: vec![],
1723 regression: None,
1724 audit: fallow_config::AuditConfig::default(),
1725 codeowners: None,
1726 public_packages: vec![],
1727 flags: FlagsConfig::default(),
1728 security: fallow_config::SecurityConfig::default(),
1729 fix: fallow_config::FixConfig::default(),
1730 resolve: ResolveConfig::default(),
1731 sealed: false,
1732 include_entry_exports: false,
1733 auto_imports: false,
1734 cache: fallow_config::CacheConfig::default(),
1735 }
1736 .resolve(root, OutputFormat::Human, 1, true, true, None)
1737 }
1738
1739 #[test]
1740 fn custom_ignore_patterns_exclude_matching_files() {
1741 let dir = tempfile::tempdir().expect("create temp dir");
1742
1743 let generated = dir.path().join("src").join("api").join("generated");
1744 std::fs::create_dir_all(&generated).unwrap();
1745 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1746
1747 let client = dir.path().join("src").join("api").join("client");
1748 std::fs::create_dir_all(&client).unwrap();
1749 std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
1750
1751 let src = dir.path().join("src");
1752 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1753
1754 let config = make_config_with_ignores(
1755 dir.path().to_path_buf(),
1756 vec![
1757 "src/api/generated/**".to_string(),
1758 "src/api/client/**".to_string(),
1759 ],
1760 );
1761 let files = discover_files(&config);
1762 let names = file_names(&files, dir.path());
1763
1764 assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
1765 assert!(names.contains(&"src/index.ts".to_string()));
1766 }
1767
1768 #[test]
1769 fn leading_dot_ignore_patterns_exclude_matching_files() {
1770 let dir = tempfile::tempdir().expect("create temp dir");
1771
1772 let generated = dir.path().join("src").join("generated");
1773 std::fs::create_dir_all(&generated).unwrap();
1774 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1775
1776 let src = dir.path().join("src");
1777 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1778
1779 let config = make_config_with_ignores(
1780 dir.path().to_path_buf(),
1781 vec!["./src/generated/**".to_string()],
1782 );
1783 let files = discover_files(&config);
1784 let names = file_names(&files, dir.path());
1785
1786 assert_eq!(names, vec!["src/index.ts"]);
1787 }
1788
1789 #[test]
1790 fn default_ignore_patterns_exclude_node_modules_and_dist() {
1791 let dir = tempfile::tempdir().expect("create temp dir");
1792
1793 let nm = dir.path().join("node_modules").join("lodash");
1794 std::fs::create_dir_all(&nm).unwrap();
1795 std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
1796
1797 let dist = dir.path().join("dist");
1798 std::fs::create_dir_all(&dist).unwrap();
1799 std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
1800
1801 let src = dir.path().join("src");
1802 std::fs::create_dir_all(&src).unwrap();
1803 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1804
1805 let config = make_config(dir.path().to_path_buf(), false);
1806 let files = discover_files(&config);
1807 let names = file_names(&files, dir.path());
1808
1809 assert_eq!(names.len(), 1);
1810 assert!(names.contains(&"src/index.ts".to_string()));
1811 }
1812
1813 #[test]
1814 fn default_ignore_patterns_exclude_root_build() {
1815 let dir = tempfile::tempdir().expect("create temp dir");
1816
1817 let build = dir.path().join("build");
1818 std::fs::create_dir_all(&build).unwrap();
1819 std::fs::write(build.join("output.js"), "// built").unwrap();
1820
1821 let nested_build = dir.path().join("src").join("build");
1822 std::fs::create_dir_all(&nested_build).unwrap();
1823 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1824
1825 let src = dir.path().join("src");
1826 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1827
1828 let config = make_config(dir.path().to_path_buf(), false);
1829 let files = discover_files(&config);
1830 let names = file_names(&files, dir.path());
1831
1832 assert_eq!(
1833 names.len(),
1834 2,
1835 "root build/ excluded, nested kept: {names:?}"
1836 );
1837 assert!(names.contains(&"src/index.ts".to_string()));
1838 assert!(names.contains(&"src/build/helper.ts".to_string()));
1839 }
1840
1841 fn make_config_with_max_file_size(
1843 root: PathBuf,
1844 max_file_size_bytes: Option<u64>,
1845 ) -> ResolvedConfig {
1846 let mut config = make_config(root, false);
1847 config.max_file_size_bytes = max_file_size_bytes;
1848 config
1849 }
1850
1851 #[test]
1852 fn skips_files_over_max_file_size() {
1853 let dir = tempfile::tempdir().expect("create temp dir");
1854 let src = dir.path().join("src");
1855 std::fs::create_dir_all(&src).unwrap();
1856 std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
1857 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1858
1859 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1860 let files = discover_files(&config);
1861 let names = file_names(&files, dir.path());
1862
1863 assert!(names.contains(&"src/small.ts".to_string()));
1864 assert!(
1865 !names.contains(&"src/huge.ts".to_string()),
1866 "a file over the size limit must not be discovered"
1867 );
1868 }
1869
1870 #[test]
1871 fn declaration_files_exempt_from_size_skip() {
1872 let dir = tempfile::tempdir().expect("create temp dir");
1873 let src = dir.path().join("src");
1874 std::fs::create_dir_all(&src).unwrap();
1875 std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
1876 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1877
1878 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1879 let files = discover_files(&config);
1880 let names = file_names(&files, dir.path());
1881
1882 assert!(
1883 names.contains(&"src/auto-imports.d.ts".to_string()),
1884 "a large .d.ts is exempt from the skip (reachability root for global types)"
1885 );
1886 assert!(!names.contains(&"src/huge.ts".to_string()));
1887 }
1888
1889 #[test]
1890 fn unlimited_size_keeps_large_files() {
1891 let dir = tempfile::tempdir().expect("create temp dir");
1892 let src = dir.path().join("src");
1893 std::fs::create_dir_all(&src).unwrap();
1894 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1895
1896 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1897 let files = discover_files(&config);
1898 let names = file_names(&files, dir.path());
1899
1900 assert!(
1901 names.contains(&"src/huge.ts".to_string()),
1902 "no limit keeps every file"
1903 );
1904 }
1905
1906 #[test]
1907 fn skipped_file_recorded_in_workspace_diagnostics() {
1908 let dir = tempfile::tempdir().expect("create temp dir");
1909 let src = dir.path().join("src");
1910 std::fs::create_dir_all(&src).unwrap();
1911 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1912
1913 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1914 let _ = discover_files(&config);
1915
1916 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1917 let skipped: Vec<_> = diagnostics
1918 .iter()
1919 .filter(|d| {
1920 matches!(
1921 d.kind,
1922 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1923 )
1924 })
1925 .collect();
1926 assert_eq!(
1927 skipped.len(),
1928 1,
1929 "the skipped file is recorded in workspace diagnostics for JSON output"
1930 );
1931 assert!(skipped[0].path.ends_with("src/huge.ts"));
1932 assert!(
1933 matches!(
1934 skipped[0].kind,
1935 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
1936 if size_bytes == 5_000
1937 ),
1938 "the recorded diagnostic carries the on-disk byte size"
1939 );
1940 }
1941
1942 #[test]
1943 fn skips_large_one_line_js_as_minified_generated_output() {
1944 let dir = tempfile::tempdir().expect("create temp dir");
1945 let src = dir.path().join("src");
1946 std::fs::create_dir_all(&src).unwrap();
1947 let asset = src.join("index-abc123.js");
1948 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1949
1950 let config = make_config(dir.path().to_path_buf(), false);
1951 let files = discover_files(&config);
1952 let names = file_names(&files, dir.path());
1953
1954 assert!(
1955 !names.contains(&"src/index-abc123.js".to_string()),
1956 "large one-line JS assets should be skipped before parsing"
1957 );
1958
1959 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1960 assert!(
1961 diagnostics.iter().any(|diag| {
1962 diag.path.ends_with("src/index-abc123.js")
1963 && matches!(
1964 diag.kind,
1965 fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
1966 )
1967 }),
1968 "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
1969 );
1970 }
1971
1972 #[test]
1973 fn unlimited_size_keeps_large_one_line_js() {
1974 let dir = tempfile::tempdir().expect("create temp dir");
1975 let src = dir.path().join("src");
1976 std::fs::create_dir_all(&src).unwrap();
1977 let asset = src.join("index-abc123.js");
1978 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1979
1980 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1981 let files = discover_files(&config);
1982 let names = file_names(&files, dir.path());
1983
1984 assert!(
1985 names.contains(&"src/index-abc123.js".to_string()),
1986 "--max-file-size 0 should opt out of generated JS skipping"
1987 );
1988 }
1989
1990 #[test]
1991 fn keeps_large_multiline_js() {
1992 let dir = tempfile::tempdir().expect("create temp dir");
1993 let src = dir.path().join("src");
1994 std::fs::create_dir_all(&src).unwrap();
1995 let asset = src.join("handwritten.js");
1996 let mut content = String::new();
1997 while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
1998 content.push_str("export const value = 1;\n");
1999 }
2000 std::fs::write(&asset, content).unwrap();
2001
2002 let config = make_config(dir.path().to_path_buf(), false);
2003 let files = discover_files(&config);
2004 let names = file_names(&files, dir.path());
2005
2006 assert!(
2007 names.contains(&"src/handwritten.js".to_string()),
2008 "large multiline JS should not be treated as a generated minified asset"
2009 );
2010 }
2011 }
2012}