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