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 let source_glob = format!("*.{{{}}}", SOURCE_EXTENSIONS.join(","));
582 types_builder
583 .add("source", &source_glob)
584 .expect("valid glob");
585 types_builder.select("source");
586 if capture_config {
587 for glob in config_candidate_basename_globs() {
588 let _ = types_builder.add("config", glob);
592 }
593 types_builder.select("config");
594 }
595 types_builder.build().expect("valid types")
596}
597
598fn build_source_walk_builder(
602 config: &ResolvedConfig,
603 additional_hidden_dir_scopes: &[HiddenDirScope],
604 capture_config: bool,
605) -> WalkBuilder {
606 let mut walk_builder = WalkBuilder::new(&config.root);
607 walk_builder
608 .hidden(false)
609 .git_ignore(true)
610 .git_global(true)
611 .git_exclude(true)
612 .types(build_walk_types(capture_config))
613 .threads(config.threads);
614 if additional_hidden_dir_scopes.is_empty() {
615 walk_builder.filter_entry(is_allowed_hidden);
616 } else {
617 let scopes = additional_hidden_dir_scopes.to_vec();
618 walk_builder.filter_entry(move |entry| is_allowed_hidden_with_scopes(entry, &scopes));
619 }
620 walk_builder
621}
622
623fn build_production_excludes(config: &ResolvedConfig) -> Option<globset::GlobSet> {
625 if !config.production {
626 return None;
627 }
628 let mut builder = globset::GlobSetBuilder::new();
629 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
630 if let Ok(glob) = globset::GlobBuilder::new(pattern)
631 .literal_separator(true)
632 .build()
633 {
634 builder.add(glob);
635 }
636 }
637 builder.build().ok()
638}
639
640pub fn discover_files_with_additional_hidden_dirs(
646 config: &ResolvedConfig,
647 additional_hidden_dir_scopes: &[HiddenDirScope],
648) -> Vec<DiscoveredFile> {
649 discover_files_and_config_candidates(config, additional_hidden_dir_scopes).0
650}
651
652#[expect(
668 clippy::cast_possible_truncation,
669 reason = "file count is bounded by project size, well under u32::MAX"
670)]
671#[expect(clippy::expect_used, reason = "the collector lock must remain usable")]
672pub fn discover_files_and_config_candidates(
673 config: &ResolvedConfig,
674 additional_hidden_dir_scopes: &[HiddenDirScope],
675) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
676 let _span = tracing::info_span!("discover_files").entered();
677
678 let capture_config = !config.production;
679 let walk_builder =
680 build_source_walk_builder(config, additional_hidden_dir_scopes, capture_config);
681 let production_excludes = build_production_excludes(config);
682 let canonical_root = config.root.canonicalize().ok();
683
684 let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
685 let config_collected: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
686 let mut visitor_builder = FileVisitorBuilder {
687 root: &config.root,
688 canonical_root: canonical_root.as_deref(),
689 ignore_patterns: &config.ignore_patterns,
690 production_excludes: &production_excludes,
691 shared: &collected,
692 config_shared: capture_config.then_some(&config_collected),
693 };
694 walk_builder.build_parallel().visit(&mut visitor_builder);
695
696 let mut raw = collected
697 .into_inner()
698 .expect("walk collector lock poisoned");
699 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
707
708 let mut config_candidates = config_collected
709 .into_inner()
710 .expect("walk config collector lock poisoned");
711 config_candidates.sort_unstable();
712
713 fallow_config::clear_source_discovery_diagnostics(&config.root);
717 let (kept, skipped) = partition_by_size(raw, config.max_file_size_bytes);
718 report_skipped_large_files(config, &skipped);
719 let (kept, skipped_minified) =
720 partition_minified_generated_js(kept, config.max_file_size_bytes);
721 report_skipped_minified_files(config, &skipped_minified);
722
723 let files: Vec<DiscoveredFile> = kept
724 .into_iter()
725 .enumerate()
726 .map(|(idx, (path, size_bytes))| DiscoveredFile {
727 id: FileId(idx as u32),
728 path,
729 size_bytes,
730 })
731 .collect();
732
733 note_largest_files(config, &files);
734
735 (files, config_candidates)
736}
737
738#[cfg(test)]
739mod tests {
740 use std::ffi::OsStr;
741
742 use super::*;
743
744 fn assign_file_ids(mut raw: Vec<(std::path::PathBuf, u64)>) -> Vec<DiscoveredFile> {
747 raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
748 raw.into_iter()
749 .enumerate()
750 .map(|(idx, (path, size_bytes))| DiscoveredFile {
751 id: FileId(idx as u32),
752 path,
753 size_bytes,
754 })
755 .collect()
756 }
757
758 #[test]
764 fn file_id_assignment_is_deterministic_for_identical_file_set() {
765 let paths = [
766 "/project/src/z.ts",
767 "/project/src/a.ts",
768 "/project/src/components/Button.tsx",
769 "/project/src/components/Button.module.css",
770 "/project/index.ts",
771 ];
772
773 let walk_one: Vec<(std::path::PathBuf, u64)> = paths
775 .iter()
776 .map(|p| (std::path::PathBuf::from(p), 10))
777 .collect();
778 let mut walk_two = walk_one.clone();
779 walk_two.reverse();
780
781 let files_one = assign_file_ids(walk_one);
782 let files_two = assign_file_ids(walk_two);
783
784 assert_eq!(files_one.len(), files_two.len());
786 for (a, b) in files_one.iter().zip(files_two.iter()) {
787 assert_eq!(a.id, b.id);
788 assert_eq!(a.path, b.path);
789 }
790
791 for (idx, file) in files_one.iter().enumerate() {
794 assert_eq!(file.id, FileId(idx as u32));
795 }
796 assert_eq!(
797 files_one[0].path,
798 std::path::PathBuf::from("/project/index.ts")
799 );
800 }
801
802 #[test]
803 fn file_id_assignment_recomputes_after_rename_or_delete() {
804 let before = assign_file_ids(vec![
805 (std::path::PathBuf::from("/project/src/a.ts"), 10),
806 (std::path::PathBuf::from("/project/src/b.ts"), 10),
807 (std::path::PathBuf::from("/project/src/c.ts"), 10),
808 ]);
809 let after_delete = assign_file_ids(vec![
810 (std::path::PathBuf::from("/project/src/a.ts"), 10),
811 (std::path::PathBuf::from("/project/src/c.ts"), 10),
812 ]);
813 let after_rename = assign_file_ids(vec![
814 (std::path::PathBuf::from("/project/src/a.ts"), 10),
815 (std::path::PathBuf::from("/project/src/c.ts"), 10),
816 (std::path::PathBuf::from("/project/src/d.ts"), 10),
817 ]);
818
819 assert_eq!(before[0].id, FileId(0));
820 assert_eq!(before[1].id, FileId(1));
821 assert_eq!(before[2].id, FileId(2));
822 assert_eq!(after_delete[0].id, FileId(0));
823 assert_eq!(after_delete[1].id, FileId(1));
824 assert_eq!(
825 after_delete[1].path,
826 std::path::PathBuf::from("/project/src/c.ts")
827 );
828 assert_eq!(after_rename[0].id, FileId(0));
829 assert_eq!(after_rename[1].id, FileId(1));
830 assert_eq!(
831 after_rename[1].path,
832 std::path::PathBuf::from("/project/src/c.ts")
833 );
834 assert_eq!(after_rename[2].id, FileId(2));
835 assert_eq!(
836 after_rename[2].path,
837 std::path::PathBuf::from("/project/src/d.ts")
838 );
839 }
840
841 #[test]
842 fn allowed_hidden_dirs() {
843 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
844 assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
845 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
846 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
847 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
848 }
849
850 #[test]
851 fn disallowed_hidden_dirs() {
852 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
853 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
854 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
855 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
856 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
857 }
858
859 #[test]
860 fn non_hidden_dirs_not_in_allowlist() {
861 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
862 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
863 }
864
865 #[test]
866 fn walk_types_match_every_supported_source_extension() {
867 for capture_config in [false, true] {
868 let types = build_walk_types(capture_config);
869 for extension in SOURCE_EXTENSIONS {
870 let path = format!("packages/ui/src/nested/component.{extension}");
871 assert!(
872 types.matched(&path, false).is_whitelist(),
873 "expected source match for {path} with capture_config={capture_config}"
874 );
875 }
876 }
877 }
878
879 #[test]
880 fn walk_types_match_typescript_declaration_files() {
881 let types = build_walk_types(true);
882 for path in [
883 "src/env.d.ts",
884 "packages/app/types/generated.d.mts",
885 "packages/app/types/compat.d.cts",
886 ] {
887 assert!(
888 types.matched(path, false).is_whitelist(),
889 "expected declaration source match for {path}"
890 );
891 }
892 }
893
894 #[test]
895 fn walk_types_reject_source_extension_near_misses() {
896 for capture_config in [false, true] {
897 let types = build_walk_types(capture_config);
898 for path in [
899 "src/component.tsx.bak",
900 "src/component.tsxmap",
901 "src/component.TS",
902 "src/component.gqlx",
903 "src/component.htm",
904 "src/component",
905 "assets/component.png",
906 ] {
907 assert!(
908 types.matched(path, false).is_ignore(),
909 "expected non-source rejection for {path} with capture_config={capture_config}"
910 );
911 }
912 }
913 }
914
915 #[test]
916 fn walk_types_keep_config_candidate_selection_separate() {
917 assert!(
918 build_walk_types(true)
919 .matched("packages/app/tsconfig.json", false)
920 .is_whitelist()
921 );
922 assert!(
923 build_walk_types(false)
924 .matched("packages/app/tsconfig.json", false)
925 .is_ignore()
926 );
927 }
928
929 #[test]
930 fn source_extensions_include_typescript() {
931 assert!(SOURCE_EXTENSIONS.contains(&"ts"));
932 assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
933 assert!(SOURCE_EXTENSIONS.contains(&"mts"));
934 assert!(SOURCE_EXTENSIONS.contains(&"cts"));
935 assert!(SOURCE_EXTENSIONS.contains(&"gts"));
936 }
937
938 #[test]
939 fn source_extensions_include_javascript() {
940 assert!(SOURCE_EXTENSIONS.contains(&"js"));
941 assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
942 assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
943 assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
944 assert!(SOURCE_EXTENSIONS.contains(&"gjs"));
945 }
946
947 #[test]
948 fn source_extensions_include_sfc_formats() {
949 assert!(SOURCE_EXTENSIONS.contains(&"vue"));
950 assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
951 assert!(SOURCE_EXTENSIONS.contains(&"astro"));
952 }
953
954 #[test]
955 fn source_extensions_include_styles() {
956 assert!(SOURCE_EXTENSIONS.contains(&"css"));
957 assert!(SOURCE_EXTENSIONS.contains(&"scss"));
958 assert!(SOURCE_EXTENSIONS.contains(&"sass"));
959 assert!(SOURCE_EXTENSIONS.contains(&"less"));
960 }
961
962 #[test]
963 fn source_extensions_exclude_non_source() {
964 assert!(!SOURCE_EXTENSIONS.contains(&"json"));
965 assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
966 assert!(!SOURCE_EXTENSIONS.contains(&"md"));
967 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
968 assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
969 }
970
971 #[test]
972 fn source_extensions_include_html() {
973 assert!(SOURCE_EXTENSIONS.contains(&"html"));
974 }
975
976 #[test]
977 fn source_extensions_include_graphql_documents() {
978 assert!(SOURCE_EXTENSIONS.contains(&"graphql"));
979 assert!(SOURCE_EXTENSIONS.contains(&"gql"));
980 }
981
982 fn build_production_glob_set() -> globset::GlobSet {
983 let mut builder = globset::GlobSetBuilder::new();
984 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
985 builder.add(
986 globset::GlobBuilder::new(pattern)
987 .literal_separator(true)
988 .build()
989 .expect("valid glob pattern"),
990 );
991 }
992 builder.build().expect("valid glob set")
993 }
994
995 #[test]
996 fn production_excludes_test_files() {
997 let set = build_production_glob_set();
998 assert!(set.is_match("src/Button.test.ts"));
999 assert!(set.is_match("src/utils.spec.tsx"));
1000 assert!(set.is_match("src/__tests__/helper.ts"));
1001 assert!(!set.is_match("src/Button.ts"));
1002 assert!(!set.is_match("src/utils.tsx"));
1003 }
1004
1005 #[test]
1006 fn production_excludes_story_files() {
1007 let set = build_production_glob_set();
1008 assert!(set.is_match("src/Button.stories.tsx"));
1009 assert!(set.is_match("src/Card.story.ts"));
1010 assert!(!set.is_match("src/Button.tsx"));
1011 }
1012
1013 #[test]
1014 fn production_excludes_config_files_at_root_only() {
1015 let set = build_production_glob_set();
1016 assert!(set.is_match("vitest.config.ts"));
1017 assert!(set.is_match("jest.config.js"));
1018 assert!(!set.is_match("src/app/app.config.ts"));
1019 assert!(!set.is_match("src/app/app.config.server.ts"));
1020 assert!(!set.is_match("packages/foo/vitest.config.ts"));
1021 assert!(!set.is_match("src/config.ts"));
1022 }
1023
1024 #[test]
1025 fn production_patterns_are_valid_globs() {
1026 let _ = build_production_glob_set();
1027 }
1028
1029 #[test]
1030 fn disallowed_hidden_dirs_idea() {
1031 assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
1032 }
1033
1034 #[test]
1035 fn source_extensions_include_mdx() {
1036 assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
1037 }
1038
1039 #[test]
1040 fn source_extensions_exclude_image_and_data_formats() {
1041 assert!(!SOURCE_EXTENSIONS.contains(&"png"));
1042 assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
1043 assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
1044 assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
1045 assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
1046 assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
1047 }
1048
1049 #[test]
1050 fn is_declaration_file_matches_dts_variants() {
1051 assert!(is_declaration_file(Path::new("env.d.ts")));
1052 assert!(is_declaration_file(Path::new("src/auto-imports.d.ts")));
1053 assert!(is_declaration_file(Path::new("mod.d.mts")));
1054 assert!(is_declaration_file(Path::new("compat.d.cts")));
1055 assert!(!is_declaration_file(Path::new("index.ts")));
1056 assert!(!is_declaration_file(Path::new("component.tsx")));
1057 assert!(!is_declaration_file(Path::new("notes.d.txt")));
1058 }
1059
1060 #[test]
1061 fn format_size_mb_renders_one_decimal() {
1062 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1063 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1064 assert_eq!(format_size_mb(0), "0.0 MB");
1065 }
1066
1067 #[test]
1068 fn partition_by_size_no_limit_keeps_all() {
1069 let raw = vec![(PathBuf::from("a.ts"), 10), (PathBuf::from("b.ts"), 10_000)];
1070 let (kept, skipped) = partition_by_size(raw, None);
1071 assert_eq!(kept.len(), 2);
1072 assert!(skipped.is_empty());
1073 }
1074
1075 #[test]
1076 fn partition_by_size_skips_strictly_over_limit() {
1077 let raw = vec![
1078 (PathBuf::from("under.ts"), 99),
1079 (PathBuf::from("exact.ts"), 100),
1080 (PathBuf::from("over.ts"), 101),
1081 ];
1082 let (kept, skipped) = partition_by_size(raw, Some(100));
1083 let kept_has = |name: &str| kept.iter().any(|(p, _)| p.as_path() == Path::new(name));
1084 assert!(kept_has("under.ts"));
1085 assert!(
1086 kept_has("exact.ts"),
1087 "a file exactly at the limit is kept (skip is strictly-greater)"
1088 );
1089 assert_eq!(skipped.len(), 1);
1090 assert_eq!(skipped[0].0, PathBuf::from("over.ts"));
1091 }
1092
1093 #[test]
1094 fn partition_by_size_exempts_declaration_files() {
1095 let raw = vec![
1096 (PathBuf::from("huge.ts"), 10_000),
1097 (PathBuf::from("auto-imports.d.ts"), 10_000),
1098 ];
1099 let (kept, skipped) = partition_by_size(raw, Some(100));
1100 assert!(
1101 kept.iter()
1102 .any(|(p, _)| p.as_path() == Path::new("auto-imports.d.ts")),
1103 "declaration files are exempt from the size skip regardless of size"
1104 );
1105 assert_eq!(skipped.len(), 1);
1106 assert_eq!(skipped[0].0, PathBuf::from("huge.ts"));
1107 }
1108
1109 fn disco(path: &str, size_bytes: u64) -> DiscoveredFile {
1110 DiscoveredFile {
1111 id: FileId(0),
1112 path: PathBuf::from(path),
1113 size_bytes,
1114 }
1115 }
1116
1117 #[test]
1118 fn largest_files_note_below_threshold_is_none() {
1119 let files = [disco("a.ts", 100), disco("b.ts", 200)];
1120 assert!(build_largest_files_note(Path::new("/p"), &files).is_none());
1121 }
1122
1123 #[test]
1124 fn largest_files_note_single_file_uses_singular() {
1125 let files = [disco("big.ts", 5 * 1024 * 1024)];
1126 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1127 assert!(
1128 note.contains("discovered 1 file;"),
1129 "singular noun on the single-big-file path (issue #1086 regression): {note}"
1130 );
1131 assert!(!note.contains("discovered 1 files"));
1132 assert!(note.contains("big.ts (5.0 MB)"));
1133 }
1134
1135 #[test]
1136 fn largest_files_note_filters_sub_floor_files() {
1137 let files = [disco("big.ts", 5 * 1024 * 1024), disco("tiny.ts", 10)];
1138 let note = build_largest_files_note(Path::new("/p"), &files).expect("note fires");
1139 assert!(note.contains("discovered 2 files;"));
1140 assert!(note.contains("big.ts (5.0 MB)"));
1141 assert!(
1142 !note.contains("tiny.ts"),
1143 "sub-floor files are not listed as `0.0 MB` chaff: {note}"
1144 );
1145 }
1146
1147 #[test]
1148 fn largest_files_note_large_set_no_big_file_omits_list() {
1149 let files: Vec<DiscoveredFile> = (0..=LARGE_SET_THRESHOLD)
1150 .map(|i| disco(&format!("f{i}.ts"), 100))
1151 .collect();
1152 let note = build_largest_files_note(Path::new("/p"), &files).expect("large set fires");
1153 assert!(note.contains(&format!("discovered {} files", LARGE_SET_THRESHOLD + 1)));
1154 assert!(
1155 !note.contains("largest:"),
1156 "no sub-floor `largest:` list when no file clears the floor: {note}"
1157 );
1158 }
1159
1160 mod discover_files_integration {
1161 use std::path::PathBuf;
1162
1163 use fallow_config::{
1164 DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, ResolveConfig,
1165 RulesConfig,
1166 };
1167
1168 use super::*;
1169
1170 fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
1172 FallowConfig {
1173 production: production.into(),
1174 ..Default::default()
1175 }
1176 .resolve(root, OutputFormat::Human, 1, true, true, None)
1177 }
1178
1179 fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
1182 files
1183 .iter()
1184 .map(|f| {
1185 f.path
1186 .strip_prefix(root)
1187 .unwrap_or(&f.path)
1188 .to_string_lossy()
1189 .replace('\\', "/")
1190 })
1191 .collect()
1192 }
1193
1194 #[cfg(unix)]
1195 fn symlink_file(target: &Path, link: &Path) {
1196 std::os::unix::fs::symlink(target, link).expect("create file symlink");
1197 }
1198
1199 #[cfg(windows)]
1200 fn symlink_file(target: &Path, link: &Path) {
1201 std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1202 }
1203
1204 #[cfg(unix)]
1205 fn symlink_dir(target: &Path, link: &Path) {
1206 std::os::unix::fs::symlink(target, link).expect("create directory symlink");
1207 }
1208
1209 #[cfg(windows)]
1210 fn symlink_dir(target: &Path, link: &Path) {
1211 std::os::windows::fs::symlink_dir(target, link).expect("create directory symlink");
1212 }
1213
1214 #[test]
1215 fn source_symlinks_must_target_regular_files_inside_root() {
1216 let dir = tempfile::tempdir().expect("create project");
1217 let outside = tempfile::tempdir().expect("create outside dir");
1218 let src = dir.path().join("src");
1219 std::fs::create_dir_all(&src).unwrap();
1220 std::fs::write(src.join("regular.ts"), "export const regular = 1;").unwrap();
1221 std::fs::write(src.join("inside-target.ts"), "export const inside = 1;").unwrap();
1222 std::fs::write(
1223 outside.path().join("outside-target.ts"),
1224 "export const outside = 1;",
1225 )
1226 .unwrap();
1227 std::fs::create_dir_all(src.join("directory-target")).unwrap();
1228
1229 symlink_file(&src.join("inside-target.ts"), &src.join("inside-link.ts"));
1230 symlink_file(
1231 &outside.path().join("outside-target.ts"),
1232 &src.join("outside-link.ts"),
1233 );
1234 symlink_file(&src.join("missing-target.ts"), &src.join("broken-link.ts"));
1235 symlink_dir(
1236 &src.join("directory-target"),
1237 &src.join("directory-link.ts"),
1238 );
1239
1240 let config = make_config(dir.path().to_path_buf(), false);
1241 let names = file_names(&discover_files(&config), dir.path());
1242
1243 assert!(names.contains(&"src/regular.ts".to_string()));
1244 assert!(names.contains(&"src/inside-target.ts".to_string()));
1245 assert!(names.contains(&"src/inside-link.ts".to_string()));
1246 assert!(!names.contains(&"src/outside-link.ts".to_string()));
1247 assert!(!names.contains(&"src/broken-link.ts".to_string()));
1248 assert!(!names.contains(&"src/directory-link.ts".to_string()));
1249 }
1250
1251 #[test]
1252 fn discovers_source_files_with_valid_extensions() {
1253 let dir = tempfile::tempdir().expect("create temp dir");
1254 let src = dir.path().join("src");
1255 std::fs::create_dir_all(&src).unwrap();
1256
1257 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1258 std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
1259 std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
1260 std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
1261 std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
1262 std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
1263 std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
1264 std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
1265
1266 let config = make_config(dir.path().to_path_buf(), false);
1267 let files = discover_files(&config);
1268 let names = file_names(&files, dir.path());
1269
1270 assert!(names.contains(&"src/app.ts".to_string()));
1271 assert!(names.contains(&"src/component.tsx".to_string()));
1272 assert!(names.contains(&"src/utils.js".to_string()));
1273 assert!(names.contains(&"src/helper.jsx".to_string()));
1274 assert!(names.contains(&"src/config.mjs".to_string()));
1275 assert!(names.contains(&"src/legacy.cjs".to_string()));
1276 assert!(names.contains(&"src/types.mts".to_string()));
1277 assert!(names.contains(&"src/compat.cts".to_string()));
1278 }
1279
1280 #[test]
1281 fn compact_source_glob_preserves_discovered_file_inventory() {
1282 let dir = tempfile::tempdir().expect("create temp dir");
1283 let nested = dir.path().join("packages/ui/src/nested");
1284 std::fs::create_dir_all(&nested).unwrap();
1285
1286 let mut expected = Vec::new();
1287 for (index, extension) in SOURCE_EXTENSIONS.iter().enumerate() {
1288 let relative = format!("packages/ui/src/nested/source-{index}.{extension}");
1289 std::fs::write(dir.path().join(&relative), "export const value = 1;").unwrap();
1290 expected.push(relative);
1291 }
1292 for relative in [
1293 "packages/ui/src/nested/env.d.ts",
1294 "packages/ui/src/nested/generated.d.mts",
1295 "packages/ui/src/nested/compat.d.cts",
1296 ] {
1297 std::fs::write(dir.path().join(relative), "export type Value = string;").unwrap();
1298 expected.push(relative.to_string());
1299 }
1300 let rejected = [
1301 "packages/ui/src/nested/component.tsx.bak",
1302 "packages/ui/src/nested/component.tsxmap",
1303 "packages/ui/src/nested/component.TS",
1304 "packages/ui/src/nested/component.gqlx",
1305 "packages/ui/src/nested/component.htm",
1306 "packages/ui/src/nested/component",
1307 "packages/ui/src/nested/component.png",
1308 ];
1309 for relative in rejected {
1310 std::fs::write(dir.path().join(relative), "not source").unwrap();
1311 }
1312
1313 let config = make_config(dir.path().to_path_buf(), false);
1314 let names = file_names(&discover_files(&config), dir.path());
1315
1316 for relative in expected {
1317 assert!(
1318 names.contains(&relative),
1319 "missing supported source {relative}"
1320 );
1321 }
1322 for relative in rejected {
1323 assert!(
1324 !names.iter().any(|name| name == relative),
1325 "unexpected near-miss source {relative}"
1326 );
1327 }
1328 }
1329
1330 #[test]
1331 fn excludes_non_source_extensions() {
1332 let dir = tempfile::tempdir().expect("create temp dir");
1333 let src = dir.path().join("src");
1334 std::fs::create_dir_all(&src).unwrap();
1335
1336 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1337
1338 std::fs::write(src.join("data.json"), "{}").unwrap();
1339 std::fs::write(src.join("readme.md"), "# Hello").unwrap();
1340 std::fs::write(src.join("notes.txt"), "notes").unwrap();
1341 std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
1342
1343 let config = make_config(dir.path().to_path_buf(), false);
1344 let files = discover_files(&config);
1345 let names = file_names(&files, dir.path());
1346
1347 assert_eq!(names.len(), 1, "only the .ts file should be discovered");
1348 assert!(names.contains(&"src/app.ts".to_string()));
1349 }
1350
1351 #[test]
1352 fn excludes_disallowed_hidden_directories() {
1353 let dir = tempfile::tempdir().expect("create temp dir");
1354
1355 let git_dir = dir.path().join(".git");
1356 std::fs::create_dir_all(&git_dir).unwrap();
1357 std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
1358
1359 let idea_dir = dir.path().join(".idea");
1360 std::fs::create_dir_all(&idea_dir).unwrap();
1361 std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
1362
1363 let cache_dir = dir.path().join(".cache");
1364 std::fs::create_dir_all(&cache_dir).unwrap();
1365 std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
1366
1367 let src = dir.path().join("src");
1368 std::fs::create_dir_all(&src).unwrap();
1369 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1370
1371 let config = make_config(dir.path().to_path_buf(), false);
1372 let files = discover_files(&config);
1373 let names = file_names(&files, dir.path());
1374
1375 assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
1376 assert!(names.contains(&"src/app.ts".to_string()));
1377 }
1378
1379 #[test]
1380 fn includes_allowed_hidden_directories() {
1381 let dir = tempfile::tempdir().expect("create temp dir");
1382
1383 let storybook = dir.path().join(".storybook");
1384 std::fs::create_dir_all(&storybook).unwrap();
1385 std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
1386
1387 let github = dir.path().join(".github");
1388 std::fs::create_dir_all(&github).unwrap();
1389 std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
1390
1391 let changeset = dir.path().join(".changeset");
1392 std::fs::create_dir_all(&changeset).unwrap();
1393 std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
1394
1395 let config = make_config(dir.path().to_path_buf(), false);
1396 let files = discover_files(&config);
1397 let names = file_names(&files, dir.path());
1398
1399 assert!(
1400 names.contains(&".storybook/main.ts".to_string()),
1401 "files in .storybook should be discovered"
1402 );
1403 assert!(
1404 names.contains(&".github/actions.js".to_string()),
1405 "files in .github should be discovered"
1406 );
1407 assert!(
1408 names.contains(&".changeset/config.js".to_string()),
1409 "files in .changeset should be discovered"
1410 );
1411 }
1412
1413 #[test]
1414 fn default_discovery_excludes_client_and_server_hidden_directories() {
1415 let dir = tempfile::tempdir().expect("create temp dir");
1416 let app = dir.path().join("app");
1417 std::fs::create_dir_all(app.join(".client")).unwrap();
1418 std::fs::create_dir_all(app.join(".server")).unwrap();
1419 std::fs::write(app.join(".client/analytics.ts"), "export const a = 1;").unwrap();
1420 std::fs::write(app.join(".server/db.ts"), "export const db = {};").unwrap();
1421 std::fs::write(app.join("root.tsx"), "export default function Root() {}").unwrap();
1422
1423 let config = make_config(dir.path().to_path_buf(), false);
1424 let files = discover_files(&config);
1425 let names = file_names(&files, dir.path());
1426
1427 assert!(names.contains(&"app/root.tsx".to_string()));
1428 assert!(!names.contains(&"app/.client/analytics.ts".to_string()));
1429 assert!(!names.contains(&"app/.server/db.ts".to_string()));
1430 }
1431
1432 #[test]
1433 fn scoped_hidden_dirs_include_client_and_server_under_package_root() {
1434 let dir = tempfile::tempdir().expect("create temp dir");
1435 let package = dir.path().join("packages/app");
1436 std::fs::create_dir_all(package.join("app/.client")).unwrap();
1437 std::fs::create_dir_all(package.join("app/.server")).unwrap();
1438 std::fs::write(
1439 package.join("app/.client/analytics.ts"),
1440 "export const track = () => {};",
1441 )
1442 .unwrap();
1443 std::fs::write(package.join("app/.server/db.ts"), "export const db = {};").unwrap();
1444
1445 let config = make_config(dir.path().to_path_buf(), false);
1446 let scopes = [HiddenDirScope::new(
1447 package,
1448 vec![".client".to_string(), ".server".to_string()],
1449 )];
1450 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1451 let names = file_names(&files, dir.path());
1452
1453 assert!(names.contains(&"packages/app/app/.client/analytics.ts".to_string()));
1454 assert!(names.contains(&"packages/app/app/.server/db.ts".to_string()));
1455 }
1456
1457 #[test]
1458 fn scoped_hidden_dirs_do_not_include_unscoped_packages() {
1459 let dir = tempfile::tempdir().expect("create temp dir");
1460 let active = dir.path().join("packages/active");
1461 let inactive = dir.path().join("packages/inactive");
1462 std::fs::create_dir_all(active.join("app/.server")).unwrap();
1463 std::fs::create_dir_all(inactive.join("app/.server")).unwrap();
1464 std::fs::write(active.join("app/.server/db.ts"), "export const db = {};").unwrap();
1465 std::fs::write(inactive.join("app/.server/db.ts"), "export const db = {};").unwrap();
1466
1467 let config = make_config(dir.path().to_path_buf(), false);
1468 let scopes = [HiddenDirScope::new(active, vec![".server".to_string()])];
1469 let files = discover_files_with_additional_hidden_dirs(&config, &scopes);
1470 let names = file_names(&files, dir.path());
1471
1472 assert!(names.contains(&"packages/active/app/.server/db.ts".to_string()));
1473 assert!(!names.contains(&"packages/inactive/app/.server/db.ts".to_string()));
1474 }
1475
1476 #[test]
1477 fn excludes_root_build_directory() {
1478 let dir = tempfile::tempdir().expect("create temp dir");
1479
1480 std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
1481
1482 let build_dir = dir.path().join("build");
1483 std::fs::create_dir_all(&build_dir).unwrap();
1484 std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
1485
1486 let src = dir.path().join("src");
1487 std::fs::create_dir_all(&src).unwrap();
1488 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1489
1490 let config = make_config(dir.path().to_path_buf(), false);
1491 let files = discover_files(&config);
1492 let names = file_names(&files, dir.path());
1493
1494 assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
1495 assert!(names.contains(&"src/app.ts".to_string()));
1496 }
1497
1498 #[test]
1499 fn includes_nested_build_directory() {
1500 let dir = tempfile::tempdir().expect("create temp dir");
1501
1502 let nested_build = dir.path().join("src").join("build");
1503 std::fs::create_dir_all(&nested_build).unwrap();
1504 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1505
1506 let config = make_config(dir.path().to_path_buf(), false);
1507 let files = discover_files(&config);
1508 let names = file_names(&files, dir.path());
1509
1510 assert!(
1511 names.contains(&"src/build/helper.ts".to_string()),
1512 "nested build/ directories should be included"
1513 );
1514 }
1515
1516 #[test]
1517 #[expect(
1518 clippy::cast_possible_truncation,
1519 reason = "test file counts are trivially small"
1520 )]
1521 fn file_ids_are_sequential_after_sorting() {
1522 let dir = tempfile::tempdir().expect("create temp dir");
1523 let src = dir.path().join("src");
1524 std::fs::create_dir_all(&src).unwrap();
1525
1526 std::fs::write(src.join("z_last.ts"), "export const z = 1;").unwrap();
1527 std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
1528 std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
1529
1530 let config = make_config(dir.path().to_path_buf(), false);
1531 let files = discover_files(&config);
1532
1533 for (idx, file) in files.iter().enumerate() {
1534 assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
1535 }
1536
1537 for pair in files.windows(2) {
1538 assert!(
1539 pair[0].path < pair[1].path,
1540 "files should be sorted by path"
1541 );
1542 }
1543 }
1544
1545 #[test]
1546 fn production_mode_excludes_test_files() {
1547 let dir = tempfile::tempdir().expect("create temp dir");
1548 let src = dir.path().join("src");
1549 std::fs::create_dir_all(&src).unwrap();
1550
1551 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1552 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1553 std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
1554 std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
1555
1556 let config = make_config(dir.path().to_path_buf(), true);
1557 let files = discover_files(&config);
1558 let names = file_names(&files, dir.path());
1559
1560 assert!(
1561 names.contains(&"src/app.ts".to_string()),
1562 "source files should be included in production mode"
1563 );
1564 assert!(
1565 !names.contains(&"src/app.test.ts".to_string()),
1566 "test files should be excluded in production mode"
1567 );
1568 assert!(
1569 !names.contains(&"src/app.spec.ts".to_string()),
1570 "spec files should be excluded in production mode"
1571 );
1572 assert!(
1573 !names.contains(&"src/app.stories.tsx".to_string()),
1574 "story files should be excluded in production mode"
1575 );
1576 }
1577
1578 #[test]
1579 fn non_production_mode_includes_test_files() {
1580 let dir = tempfile::tempdir().expect("create temp dir");
1581 let src = dir.path().join("src");
1582 std::fs::create_dir_all(&src).unwrap();
1583
1584 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1585 std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
1586
1587 let config = make_config(dir.path().to_path_buf(), false);
1588 let files = discover_files(&config);
1589 let names = file_names(&files, dir.path());
1590
1591 assert!(names.contains(&"src/app.ts".to_string()));
1592 assert!(
1593 names.contains(&"src/app.test.ts".to_string()),
1594 "test files should be included in non-production mode"
1595 );
1596 }
1597
1598 #[test]
1599 fn empty_directory_returns_no_files() {
1600 let dir = tempfile::tempdir().expect("create temp dir");
1601 let config = make_config(dir.path().to_path_buf(), false);
1602 let files = discover_files(&config);
1603 assert!(files.is_empty(), "empty project should discover no files");
1604 }
1605
1606 #[test]
1607 fn hidden_files_not_discovered_as_source() {
1608 let dir = tempfile::tempdir().expect("create temp dir");
1609
1610 std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
1611 std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
1612 std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
1613
1614 let src = dir.path().join("src");
1615 std::fs::create_dir_all(&src).unwrap();
1616 std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
1617
1618 let config = make_config(dir.path().to_path_buf(), false);
1619 let files = discover_files(&config);
1620 let names = file_names(&files, dir.path());
1621
1622 assert!(
1623 !names.contains(&".env".to_string()),
1624 ".env should not be discovered"
1625 );
1626 assert!(
1627 !names.contains(&".gitignore".to_string()),
1628 ".gitignore should not be discovered"
1629 );
1630 }
1631
1632 fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
1634 FallowConfig {
1635 type_aware: fallow_config::TypeAwareConfig::default(),
1636 schema: None,
1637 extends: vec![],
1638 entry: vec![],
1639 ignore_patterns: ignores,
1640 ignore_findings: vec![],
1641 framework: vec![],
1642 workspaces: None,
1643 ignore_dependencies: vec![],
1644 ignore_unresolved_imports: vec![],
1645 ignore_exports: vec![],
1646 ignore_catalog_references: vec![],
1647 ignore_dependency_overrides: vec![],
1648 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(
1649 ),
1650 used_class_members: vec![],
1651 ignore_decorators: vec![],
1652 unused_component_props: fallow_config::UnusedComponentPropsConfig::default(),
1653 duplicates: DuplicatesConfig::default(),
1654 health: HealthConfig::default(),
1655 rules: RulesConfig::default(),
1656 boundaries: fallow_config::BoundaryConfig::default(),
1657 production: false.into(),
1658 plugins: vec![],
1659 rule_packs: vec![],
1660 dynamically_loaded: vec![],
1661 overrides: vec![],
1662 regression: None,
1663 audit: fallow_config::AuditConfig::default(),
1664 codeowners: None,
1665 public_packages: vec![],
1666 flags: FlagsConfig::default(),
1667 security: fallow_config::SecurityConfig::default(),
1668 fix: fallow_config::FixConfig::default(),
1669 resolve: ResolveConfig::default(),
1670 sealed: false,
1671 include_entry_exports: false,
1672 auto_imports: false,
1673 cache: fallow_config::CacheConfig::default(),
1674 }
1675 .resolve(root, OutputFormat::Human, 1, true, true, None)
1676 }
1677
1678 #[test]
1679 fn custom_ignore_patterns_exclude_matching_files() {
1680 let dir = tempfile::tempdir().expect("create temp dir");
1681
1682 let generated = dir.path().join("src").join("api").join("generated");
1683 std::fs::create_dir_all(&generated).unwrap();
1684 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1685
1686 let client = dir.path().join("src").join("api").join("client");
1687 std::fs::create_dir_all(&client).unwrap();
1688 std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
1689
1690 let src = dir.path().join("src");
1691 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1692
1693 let config = make_config_with_ignores(
1694 dir.path().to_path_buf(),
1695 vec![
1696 "src/api/generated/**".to_string(),
1697 "src/api/client/**".to_string(),
1698 ],
1699 );
1700 let files = discover_files(&config);
1701 let names = file_names(&files, dir.path());
1702
1703 assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
1704 assert!(names.contains(&"src/index.ts".to_string()));
1705 }
1706
1707 #[test]
1708 fn leading_dot_ignore_patterns_exclude_matching_files() {
1709 let dir = tempfile::tempdir().expect("create temp dir");
1710
1711 let generated = dir.path().join("src").join("generated");
1712 std::fs::create_dir_all(&generated).unwrap();
1713 std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
1714
1715 let src = dir.path().join("src");
1716 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1717
1718 let config = make_config_with_ignores(
1719 dir.path().to_path_buf(),
1720 vec!["./src/generated/**".to_string()],
1721 );
1722 let files = discover_files(&config);
1723 let names = file_names(&files, dir.path());
1724
1725 assert_eq!(names, vec!["src/index.ts"]);
1726 }
1727
1728 #[test]
1729 fn default_ignore_patterns_exclude_node_modules_and_dist() {
1730 let dir = tempfile::tempdir().expect("create temp dir");
1731
1732 let nm = dir.path().join("node_modules").join("lodash");
1733 std::fs::create_dir_all(&nm).unwrap();
1734 std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
1735
1736 let dist = dir.path().join("dist");
1737 std::fs::create_dir_all(&dist).unwrap();
1738 std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
1739
1740 let src = dir.path().join("src");
1741 std::fs::create_dir_all(&src).unwrap();
1742 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1743
1744 let config = make_config(dir.path().to_path_buf(), false);
1745 let files = discover_files(&config);
1746 let names = file_names(&files, dir.path());
1747
1748 assert_eq!(names.len(), 1);
1749 assert!(names.contains(&"src/index.ts".to_string()));
1750 }
1751
1752 #[test]
1753 fn default_ignore_patterns_exclude_root_build() {
1754 let dir = tempfile::tempdir().expect("create temp dir");
1755
1756 let build = dir.path().join("build");
1757 std::fs::create_dir_all(&build).unwrap();
1758 std::fs::write(build.join("output.js"), "// built").unwrap();
1759
1760 let nested_build = dir.path().join("src").join("build");
1761 std::fs::create_dir_all(&nested_build).unwrap();
1762 std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
1763
1764 let src = dir.path().join("src");
1765 std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
1766
1767 let config = make_config(dir.path().to_path_buf(), false);
1768 let files = discover_files(&config);
1769 let names = file_names(&files, dir.path());
1770
1771 assert_eq!(
1772 names.len(),
1773 2,
1774 "root build/ excluded, nested kept: {names:?}"
1775 );
1776 assert!(names.contains(&"src/index.ts".to_string()));
1777 assert!(names.contains(&"src/build/helper.ts".to_string()));
1778 }
1779
1780 fn make_config_with_max_file_size(
1782 root: PathBuf,
1783 max_file_size_bytes: Option<u64>,
1784 ) -> ResolvedConfig {
1785 let mut config = make_config(root, false);
1786 config.max_file_size_bytes = max_file_size_bytes;
1787 config
1788 }
1789
1790 #[test]
1791 fn skips_files_over_max_file_size() {
1792 let dir = tempfile::tempdir().expect("create temp dir");
1793 let src = dir.path().join("src");
1794 std::fs::create_dir_all(&src).unwrap();
1795 std::fs::write(src.join("small.ts"), "export const a = 1;").unwrap();
1796 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1797
1798 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1799 let files = discover_files(&config);
1800 let names = file_names(&files, dir.path());
1801
1802 assert!(names.contains(&"src/small.ts".to_string()));
1803 assert!(
1804 !names.contains(&"src/huge.ts".to_string()),
1805 "a file over the size limit must not be discovered"
1806 );
1807 }
1808
1809 #[test]
1810 fn declaration_files_exempt_from_size_skip() {
1811 let dir = tempfile::tempdir().expect("create temp dir");
1812 let src = dir.path().join("src");
1813 std::fs::create_dir_all(&src).unwrap();
1814 std::fs::write(src.join("auto-imports.d.ts"), "x".repeat(5_000)).unwrap();
1815 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1816
1817 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1818 let files = discover_files(&config);
1819 let names = file_names(&files, dir.path());
1820
1821 assert!(
1822 names.contains(&"src/auto-imports.d.ts".to_string()),
1823 "a large .d.ts is exempt from the skip (reachability root for global types)"
1824 );
1825 assert!(!names.contains(&"src/huge.ts".to_string()));
1826 }
1827
1828 #[test]
1829 fn unlimited_size_keeps_large_files() {
1830 let dir = tempfile::tempdir().expect("create temp dir");
1831 let src = dir.path().join("src");
1832 std::fs::create_dir_all(&src).unwrap();
1833 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1834
1835 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1836 let files = discover_files(&config);
1837 let names = file_names(&files, dir.path());
1838
1839 assert!(
1840 names.contains(&"src/huge.ts".to_string()),
1841 "no limit keeps every file"
1842 );
1843 }
1844
1845 #[test]
1846 fn skipped_file_recorded_in_workspace_diagnostics() {
1847 let dir = tempfile::tempdir().expect("create temp dir");
1848 let src = dir.path().join("src");
1849 std::fs::create_dir_all(&src).unwrap();
1850 std::fs::write(src.join("huge.ts"), "x".repeat(5_000)).unwrap();
1851
1852 let config = make_config_with_max_file_size(dir.path().to_path_buf(), Some(1_000));
1853 let _ = discover_files(&config);
1854
1855 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1856 let skipped: Vec<_> = diagnostics
1857 .iter()
1858 .filter(|d| {
1859 matches!(
1860 d.kind,
1861 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1862 )
1863 })
1864 .collect();
1865 assert_eq!(
1866 skipped.len(),
1867 1,
1868 "the skipped file is recorded in workspace diagnostics for JSON output"
1869 );
1870 assert!(skipped[0].path.ends_with("src/huge.ts"));
1871 assert!(
1872 matches!(
1873 skipped[0].kind,
1874 fallow_config::WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes }
1875 if size_bytes == 5_000
1876 ),
1877 "the recorded diagnostic carries the on-disk byte size"
1878 );
1879 }
1880
1881 #[test]
1882 fn skips_large_one_line_js_as_minified_generated_output() {
1883 let dir = tempfile::tempdir().expect("create temp dir");
1884 let src = dir.path().join("src");
1885 std::fs::create_dir_all(&src).unwrap();
1886 let asset = src.join("index-abc123.js");
1887 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1888
1889 let config = make_config(dir.path().to_path_buf(), false);
1890 let files = discover_files(&config);
1891 let names = file_names(&files, dir.path());
1892
1893 assert!(
1894 !names.contains(&"src/index-abc123.js".to_string()),
1895 "large one-line JS assets should be skipped before parsing"
1896 );
1897
1898 let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
1899 assert!(
1900 diagnostics.iter().any(|diag| {
1901 diag.path.ends_with("src/index-abc123.js")
1902 && matches!(
1903 diag.kind,
1904 fallow_config::WorkspaceDiagnosticKind::SkippedMinifiedFile { .. }
1905 )
1906 }),
1907 "the skipped minified asset is recorded for JSON output: {diagnostics:?}"
1908 );
1909 }
1910
1911 #[test]
1912 fn unlimited_size_keeps_large_one_line_js() {
1913 let dir = tempfile::tempdir().expect("create temp dir");
1914 let src = dir.path().join("src");
1915 std::fs::create_dir_all(&src).unwrap();
1916 let asset = src.join("index-abc123.js");
1917 std::fs::write(&asset, "x".repeat(MINIFIED_FILE_SKIP_BYTES as usize + 1)).unwrap();
1918
1919 let config = make_config_with_max_file_size(dir.path().to_path_buf(), None);
1920 let files = discover_files(&config);
1921 let names = file_names(&files, dir.path());
1922
1923 assert!(
1924 names.contains(&"src/index-abc123.js".to_string()),
1925 "--max-file-size 0 should opt out of generated JS skipping"
1926 );
1927 }
1928
1929 #[test]
1930 fn keeps_large_multiline_js() {
1931 let dir = tempfile::tempdir().expect("create temp dir");
1932 let src = dir.path().join("src");
1933 std::fs::create_dir_all(&src).unwrap();
1934 let asset = src.join("handwritten.js");
1935 let mut content = String::new();
1936 while content.len() <= MINIFIED_FILE_SKIP_BYTES as usize + 1 {
1937 content.push_str("export const value = 1;\n");
1938 }
1939 std::fs::write(&asset, content).unwrap();
1940
1941 let config = make_config(dir.path().to_path_buf(), false);
1942 let files = discover_files(&config);
1943 let names = file_names(&files, dir.path());
1944
1945 assert!(
1946 names.contains(&"src/handwritten.js".to_string()),
1947 "large multiline JS should not be treated as a generated minified asset"
1948 );
1949 }
1950 }
1951}