1use std::path::{Component, Path, PathBuf};
2
3use super::parse_scripts::extract_script_file_refs;
4use super::walk::SOURCE_EXTENSIONS;
5use fallow_config::{EntryPointRole, PackageJson, ResolvedConfig};
6use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
7use rustc_hash::FxHashMap;
8
9const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
13const SKIPPED_ENTRY_WARNING_PREVIEW: usize = 5;
14
15fn format_skipped_entry_warning(skipped_entries: &FxHashMap<String, usize>) -> Option<String> {
16 if skipped_entries.is_empty() {
17 return None;
18 }
19
20 let mut entries = skipped_entries
21 .iter()
22 .map(|(path, count)| (path.as_str(), *count))
23 .collect::<Vec<_>>();
24 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
25
26 let preview = entries
27 .iter()
28 .take(SKIPPED_ENTRY_WARNING_PREVIEW)
29 .map(|(path, count)| {
30 if *count > 1 {
31 format!("{path} ({count}x)")
32 } else {
33 (*path).to_owned()
34 }
35 })
36 .collect::<Vec<_>>();
37
38 let omitted = entries.len().saturating_sub(SKIPPED_ENTRY_WARNING_PREVIEW);
39 let tail = if omitted > 0 {
40 format!(" (and {omitted} more)")
41 } else {
42 String::new()
43 };
44 let total = entries.iter().map(|(_, count)| *count).sum::<usize>();
45 let noun = if total == 1 {
46 "package.json entry point"
47 } else {
48 "package.json entry points"
49 };
50
51 Some(format!(
52 "Skipped {total} {noun} outside project root or containing parent directory traversal: {}{tail}",
53 preview.join(", ")
54 ))
55}
56
57pub fn warn_skipped_entry_summary(skipped_entries: &FxHashMap<String, usize>) {
58 if let Some(message) = format_skipped_entry_warning(skipped_entries) {
59 tracing::warn!("{message}");
60 }
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct CategorizedEntryPoints {
66 pub all: Vec<EntryPoint>,
67 pub runtime: Vec<EntryPoint>,
68 pub test: Vec<EntryPoint>,
69}
70
71impl CategorizedEntryPoints {
72 pub fn push_runtime(&mut self, entry: EntryPoint) {
73 self.runtime.push(entry.clone());
74 self.all.push(entry);
75 }
76
77 pub fn push_test(&mut self, entry: EntryPoint) {
78 self.test.push(entry.clone());
79 self.all.push(entry);
80 }
81
82 pub fn push_support(&mut self, entry: EntryPoint) {
83 self.all.push(entry);
84 }
85
86 pub fn extend_runtime<I>(&mut self, entries: I)
87 where
88 I: IntoIterator<Item = EntryPoint>,
89 {
90 for entry in entries {
91 self.push_runtime(entry);
92 }
93 }
94
95 pub fn extend_test<I>(&mut self, entries: I)
96 where
97 I: IntoIterator<Item = EntryPoint>,
98 {
99 for entry in entries {
100 self.push_test(entry);
101 }
102 }
103
104 pub fn extend_support<I>(&mut self, entries: I)
105 where
106 I: IntoIterator<Item = EntryPoint>,
107 {
108 for entry in entries {
109 self.push_support(entry);
110 }
111 }
112
113 pub fn extend(&mut self, other: Self) {
114 self.all.extend(other.all);
115 self.runtime.extend(other.runtime);
116 self.test.extend(other.test);
117 }
118
119 #[must_use]
120 pub fn dedup(mut self) -> Self {
121 dedup_entry_paths(&mut self.all);
122 dedup_entry_paths(&mut self.runtime);
123 dedup_entry_paths(&mut self.test);
124 self
125 }
126}
127
128fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
129 entries.sort_by(|a, b| a.path.cmp(&b.path));
130 entries.dedup_by(|a, b| a.path == b.path);
131}
132
133#[derive(Debug, Default)]
134pub struct EntryPointDiscovery {
135 pub entries: Vec<EntryPoint>,
136 pub skipped_entries: FxHashMap<String, usize>,
137}
138
139fn resolve_entry_path_with_tracking(
146 base: &Path,
147 entry: &str,
148 canonical_root: &Path,
149 source: EntryPointSource,
150 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
151) -> Option<EntryPoint> {
152 if entry.contains('*') {
155 return None;
156 }
157
158 if entry_has_parent_dir(entry) {
159 if let Some(skipped_entries) = skipped_entries.as_mut() {
160 *skipped_entries.entry(entry.to_owned()).or_default() += 1;
161 } else {
162 tracing::warn!(path = %entry, "Skipping entry point containing parent directory traversal");
163 }
164 return None;
165 }
166
167 let resolved = base.join(entry);
168
169 if let Some(source_path) = try_output_to_source_path(base, entry) {
174 return validated_entry_point(
175 &source_path,
176 canonical_root,
177 entry,
178 source,
179 skipped_entries.as_deref_mut(),
180 );
181 }
182
183 if is_entry_in_output_dir(entry)
191 && let Some(source_path) = try_source_index_fallback(base)
192 {
193 tracing::info!(
194 entry = %entry,
195 fallback = %source_path.display(),
196 "package.json entry resolves to an ignored output directory; falling back to source index"
197 );
198 return validated_entry_point(
199 &source_path,
200 canonical_root,
201 entry,
202 source,
203 skipped_entries.as_deref_mut(),
204 );
205 }
206
207 if resolved.is_file() {
208 return validated_entry_point(
209 &resolved,
210 canonical_root,
211 entry,
212 source,
213 skipped_entries.as_deref_mut(),
214 );
215 }
216
217 for ext in SOURCE_EXTENSIONS {
219 let with_ext = resolved.with_extension(ext);
220 if with_ext.is_file() {
221 return validated_entry_point(
222 &with_ext,
223 canonical_root,
224 entry,
225 source,
226 skipped_entries.as_deref_mut(),
227 );
228 }
229 }
230 None
231}
232
233fn entry_has_parent_dir(entry: &str) -> bool {
234 Path::new(entry)
235 .components()
236 .any(|component| matches!(component, Component::ParentDir))
237}
238
239fn validated_entry_point(
240 candidate: &Path,
241 canonical_root: &Path,
242 entry: &str,
243 source: EntryPointSource,
244 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
245) -> Option<EntryPoint> {
246 let canonical_candidate = match dunce::canonicalize(candidate) {
247 Ok(path) => path,
248 Err(err) => {
249 tracing::warn!(
250 path = %candidate.display(),
251 %entry,
252 error = %err,
253 "Skipping entry point that could not be canonicalized"
254 );
255 return None;
256 }
257 };
258
259 if !canonical_candidate.starts_with(canonical_root) {
260 if let Some(skipped_entries) = skipped_entries.as_mut() {
261 *skipped_entries.entry(entry.to_owned()).or_default() += 1;
262 } else {
263 tracing::warn!(
264 path = %candidate.display(),
265 %entry,
266 "Skipping entry point outside project root"
267 );
268 }
269 return None;
270 }
271
272 Some(EntryPoint {
273 path: candidate.to_path_buf(),
274 source,
275 })
276}
277
278pub fn resolve_entry_path(
279 base: &Path,
280 entry: &str,
281 canonical_root: &Path,
282 source: EntryPointSource,
283) -> Option<EntryPoint> {
284 resolve_entry_path_with_tracking(base, entry, canonical_root, source, None)
285}
286fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
298 let entry_path = Path::new(entry);
299 let components: Vec<_> = entry_path.components().collect();
300
301 let output_pos = components.iter().rposition(|c| {
303 if let std::path::Component::Normal(s) = c
304 && let Some(name) = s.to_str()
305 {
306 return OUTPUT_DIRS.contains(&name);
307 }
308 false
309 })?;
310
311 let prefix: PathBuf = components[..output_pos]
313 .iter()
314 .filter(|c| !matches!(c, std::path::Component::CurDir))
315 .collect();
316
317 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
319
320 for ext in SOURCE_EXTENSIONS {
322 let source_candidate = base
323 .join(&prefix)
324 .join("src")
325 .join(suffix.with_extension(ext));
326 if source_candidate.exists() {
327 return Some(source_candidate);
328 }
329 }
330
331 None
332}
333
334const SOURCE_INDEX_FALLBACK_STEMS: &[&str] = &["src/index", "src/main", "index", "main"];
337
338fn is_entry_in_output_dir(entry: &str) -> bool {
343 Path::new(entry).components().any(|c| {
344 matches!(
345 c,
346 std::path::Component::Normal(s)
347 if s.to_str().is_some_and(|name| OUTPUT_DIRS.contains(&name))
348 )
349 })
350}
351
352fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
359 for stem in SOURCE_INDEX_FALLBACK_STEMS {
360 for ext in SOURCE_EXTENSIONS {
361 let candidate = base.join(format!("{stem}.{ext}"));
362 if candidate.is_file() {
363 return Some(candidate);
364 }
365 }
366 }
367 None
368}
369
370const DEFAULT_INDEX_PATTERNS: &[&str] = &[
372 "src/index.{ts,tsx,js,jsx}",
373 "src/main.{ts,tsx,js,jsx}",
374 "index.{ts,tsx,js,jsx}",
375 "main.{ts,tsx,js,jsx}",
376];
377
378fn apply_default_fallback(
383 files: &[DiscoveredFile],
384 root: &Path,
385 ws_filter: Option<&Path>,
386) -> Vec<EntryPoint> {
387 let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
388 .iter()
389 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
390 .collect();
391
392 let mut entries = Vec::new();
393 for file in files {
394 if let Some(ws_root) = ws_filter
396 && file.path.strip_prefix(ws_root).is_err()
397 {
398 continue;
399 }
400 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
401 let relative_str = relative.to_string_lossy();
402 if default_matchers
403 .iter()
404 .any(|m| m.is_match(relative_str.as_ref()))
405 {
406 entries.push(EntryPoint {
407 path: file.path.clone(),
408 source: EntryPointSource::DefaultIndex,
409 });
410 }
411 }
412 entries
413}
414
415pub fn discover_entry_points_with_warnings(
417 config: &ResolvedConfig,
418 files: &[DiscoveredFile],
419) -> EntryPointDiscovery {
420 let _span = tracing::info_span!("discover_entry_points").entered();
421 let mut discovery = EntryPointDiscovery::default();
422
423 let relative_paths: Vec<String> = files
425 .iter()
426 .map(|f| {
427 f.path
428 .strip_prefix(&config.root)
429 .unwrap_or(&f.path)
430 .to_string_lossy()
431 .into_owned()
432 })
433 .collect();
434
435 {
438 let mut builder = globset::GlobSetBuilder::new();
439 for pattern in &config.entry_patterns {
440 if let Ok(glob) = globset::Glob::new(pattern) {
441 builder.add(glob);
442 }
443 }
444 if let Ok(glob_set) = builder.build()
445 && !glob_set.is_empty()
446 {
447 for (idx, rel) in relative_paths.iter().enumerate() {
448 if glob_set.is_match(rel) {
449 discovery.entries.push(EntryPoint {
450 path: files[idx].path.clone(),
451 source: EntryPointSource::ManualEntry,
452 });
453 }
454 }
455 }
456 }
457
458 let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
461 let pkg_path = config.root.join("package.json");
462 let root_pkg = PackageJson::load(&pkg_path).ok();
463 if let Some(pkg) = &root_pkg {
464 for entry_path in pkg.entry_points() {
465 if let Some(ep) = resolve_entry_path_with_tracking(
466 &config.root,
467 &entry_path,
468 &canonical_root,
469 EntryPointSource::PackageJsonMain,
470 Some(&mut discovery.skipped_entries),
471 ) {
472 discovery.entries.push(ep);
473 }
474 }
475
476 if let Some(scripts) = &pkg.scripts {
478 for script_value in scripts.values() {
479 for file_ref in extract_script_file_refs(script_value) {
480 if let Some(ep) = resolve_entry_path_with_tracking(
481 &config.root,
482 &file_ref,
483 &canonical_root,
484 EntryPointSource::PackageJsonScript,
485 Some(&mut discovery.skipped_entries),
486 ) {
487 discovery.entries.push(ep);
488 }
489 }
490 }
491 }
492
493 }
495
496 let exports_dirs = root_pkg
500 .map(|pkg| pkg.exports_subdirectories())
501 .unwrap_or_default();
502 discover_nested_package_entries(
503 &config.root,
504 files,
505 &mut discovery.entries,
506 &canonical_root,
507 &exports_dirs,
508 &mut discovery.skipped_entries,
509 );
510
511 if discovery.entries.is_empty() {
513 discovery.entries = apply_default_fallback(files, &config.root, None);
514 }
515
516 discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
518 discovery.entries.dedup_by(|a, b| a.path == b.path);
519
520 discovery
521}
522
523pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
524 let discovery = discover_entry_points_with_warnings(config, files);
525 warn_skipped_entry_summary(&discovery.skipped_entries);
526 discovery.entries
527}
528
529fn discover_nested_package_entries(
539 root: &Path,
540 _files: &[DiscoveredFile],
541 entries: &mut Vec<EntryPoint>,
542 canonical_root: &Path,
543 exports_subdirectories: &[String],
544 skipped_entries: &mut FxHashMap<String, usize>,
545) {
546 let mut visited = rustc_hash::FxHashSet::default();
547
548 let search_dirs = [
550 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
551 ];
552 for dir_name in &search_dirs {
553 let search_dir = root.join(dir_name);
554 if !search_dir.is_dir() {
555 continue;
556 }
557 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
558 continue;
559 };
560 for entry in read_dir.flatten() {
561 let pkg_dir = entry.path();
562 if visited.insert(pkg_dir.clone()) {
563 collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
564 }
565 }
566 }
567
568 for dir_name in exports_subdirectories {
570 let pkg_dir = root.join(dir_name);
571 if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
572 collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
573 }
574 }
575}
576
577fn collect_nested_package_entries(
579 pkg_dir: &Path,
580 entries: &mut Vec<EntryPoint>,
581 canonical_root: &Path,
582 skipped_entries: &mut FxHashMap<String, usize>,
583) {
584 let pkg_path = pkg_dir.join("package.json");
585 if !pkg_path.exists() {
586 return;
587 }
588 let Ok(pkg) = PackageJson::load(&pkg_path) else {
589 return;
590 };
591 for entry_path in pkg.entry_points() {
592 if entry_path.contains('*') {
593 expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
594 } else if let Some(ep) = resolve_entry_path_with_tracking(
595 pkg_dir,
596 &entry_path,
597 canonical_root,
598 EntryPointSource::PackageJsonExports,
599 Some(&mut *skipped_entries),
600 ) {
601 entries.push(ep);
602 }
603 }
604 if let Some(scripts) = &pkg.scripts {
605 for script_value in scripts.values() {
606 for file_ref in extract_script_file_refs(script_value) {
607 if let Some(ep) = resolve_entry_path_with_tracking(
608 pkg_dir,
609 &file_ref,
610 canonical_root,
611 EntryPointSource::PackageJsonScript,
612 Some(&mut *skipped_entries),
613 ) {
614 entries.push(ep);
615 }
616 }
617 }
618 }
619}
620
621fn expand_wildcard_entries(
627 base: &Path,
628 pattern: &str,
629 canonical_root: &Path,
630 entries: &mut Vec<EntryPoint>,
631) {
632 let full_pattern = base.join(pattern).to_string_lossy().to_string();
633 let Ok(matches) = glob::glob(&full_pattern) else {
634 return;
635 };
636 for path_result in matches {
637 let Ok(path) = path_result else {
638 continue;
639 };
640 if let Ok(canonical) = dunce::canonicalize(&path)
641 && canonical.starts_with(canonical_root)
642 {
643 entries.push(EntryPoint {
644 path,
645 source: EntryPointSource::PackageJsonExports,
646 });
647 }
648 }
649}
650
651#[must_use]
653pub fn discover_workspace_entry_points_with_warnings(
654 ws_root: &Path,
655 _config: &ResolvedConfig,
656 all_files: &[DiscoveredFile],
657) -> EntryPointDiscovery {
658 let mut discovery = EntryPointDiscovery::default();
659
660 let pkg_path = ws_root.join("package.json");
661 if let Ok(pkg) = PackageJson::load(&pkg_path) {
662 let canonical_ws_root =
663 dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
664 for entry_path in pkg.entry_points() {
665 if entry_path.contains('*') {
666 expand_wildcard_entries(
667 ws_root,
668 &entry_path,
669 &canonical_ws_root,
670 &mut discovery.entries,
671 );
672 } else if let Some(ep) = resolve_entry_path_with_tracking(
673 ws_root,
674 &entry_path,
675 &canonical_ws_root,
676 EntryPointSource::PackageJsonMain,
677 Some(&mut discovery.skipped_entries),
678 ) {
679 discovery.entries.push(ep);
680 }
681 }
682
683 if let Some(scripts) = &pkg.scripts {
685 for script_value in scripts.values() {
686 for file_ref in extract_script_file_refs(script_value) {
687 if let Some(ep) = resolve_entry_path_with_tracking(
688 ws_root,
689 &file_ref,
690 &canonical_ws_root,
691 EntryPointSource::PackageJsonScript,
692 Some(&mut discovery.skipped_entries),
693 ) {
694 discovery.entries.push(ep);
695 }
696 }
697 }
698 }
699
700 }
702
703 if discovery.entries.is_empty() {
705 discovery.entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
706 }
707
708 discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
709 discovery.entries.dedup_by(|a, b| a.path == b.path);
710 discovery
711}
712
713#[must_use]
714pub fn discover_workspace_entry_points(
715 ws_root: &Path,
716 config: &ResolvedConfig,
717 all_files: &[DiscoveredFile],
718) -> Vec<EntryPoint> {
719 let discovery = discover_workspace_entry_points_with_warnings(ws_root, config, all_files);
720 warn_skipped_entry_summary(&discovery.skipped_entries);
721 discovery.entries
722}
723
724#[must_use]
729pub fn discover_plugin_entry_points(
730 plugin_result: &crate::plugins::AggregatedPluginResult,
731 config: &ResolvedConfig,
732 files: &[DiscoveredFile],
733) -> Vec<EntryPoint> {
734 discover_plugin_entry_point_sets(plugin_result, config, files).all
735}
736
737#[must_use]
739pub fn discover_plugin_entry_point_sets(
740 plugin_result: &crate::plugins::AggregatedPluginResult,
741 config: &ResolvedConfig,
742 files: &[DiscoveredFile],
743) -> CategorizedEntryPoints {
744 let mut entries = CategorizedEntryPoints::default();
745
746 let relative_paths: Vec<String> = files
748 .iter()
749 .map(|f| {
750 f.path
751 .strip_prefix(&config.root)
752 .unwrap_or(&f.path)
753 .to_string_lossy()
754 .into_owned()
755 })
756 .collect();
757
758 let mut builder = globset::GlobSetBuilder::new();
761 let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
762 for (rule, pname) in &plugin_result.entry_patterns {
763 if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
764 builder.add(include);
765 glob_meta.push(compiled);
766 }
767 }
768 for (pattern, pname) in plugin_result
769 .discovered_always_used
770 .iter()
771 .chain(plugin_result.always_used.iter())
772 .chain(plugin_result.fixture_patterns.iter())
773 {
774 if let Ok(glob) = globset::GlobBuilder::new(pattern)
775 .literal_separator(true)
776 .build()
777 {
778 builder.add(glob);
779 if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
780 &crate::plugins::PathRule::new(pattern.clone()),
781 "support entry pattern",
782 ) {
783 glob_meta.push(CompiledEntryRule {
784 path,
785 plugin_name: pname,
786 role: EntryPointRole::Support,
787 });
788 }
789 }
790 }
791 if let Ok(glob_set) = builder.build()
792 && !glob_set.is_empty()
793 {
794 for (idx, rel) in relative_paths.iter().enumerate() {
795 let matches: Vec<usize> = glob_set
796 .matches(rel)
797 .into_iter()
798 .filter(|match_idx| glob_meta[*match_idx].matches(rel))
799 .collect();
800 if !matches.is_empty() {
801 let name = glob_meta[matches[0]].plugin_name;
802 let entry = EntryPoint {
803 path: files[idx].path.clone(),
804 source: EntryPointSource::Plugin {
805 name: name.to_string(),
806 },
807 };
808
809 let mut has_runtime = false;
810 let mut has_test = false;
811 let mut has_support = false;
812 for match_idx in matches {
813 match glob_meta[match_idx].role {
814 EntryPointRole::Runtime => has_runtime = true,
815 EntryPointRole::Test => has_test = true,
816 EntryPointRole::Support => has_support = true,
817 }
818 }
819
820 if has_runtime {
821 entries.push_runtime(entry.clone());
822 }
823 if has_test {
824 entries.push_test(entry.clone());
825 }
826 if has_support || (!has_runtime && !has_test) {
827 entries.push_support(entry);
828 }
829 }
830 }
831 }
832
833 for (setup_file, pname) in &plugin_result.setup_files {
835 let resolved = if setup_file.is_absolute() {
836 setup_file.clone()
837 } else {
838 config.root.join(setup_file)
839 };
840 if resolved.exists() {
841 entries.push_support(EntryPoint {
842 path: resolved,
843 source: EntryPointSource::Plugin {
844 name: pname.clone(),
845 },
846 });
847 } else {
848 for ext in SOURCE_EXTENSIONS {
850 let with_ext = resolved.with_extension(ext);
851 if with_ext.exists() {
852 entries.push_support(EntryPoint {
853 path: with_ext,
854 source: EntryPointSource::Plugin {
855 name: pname.clone(),
856 },
857 });
858 break;
859 }
860 }
861 }
862 }
863
864 entries.dedup()
865}
866
867#[must_use]
872pub fn discover_dynamically_loaded_entry_points(
873 config: &ResolvedConfig,
874 files: &[DiscoveredFile],
875) -> Vec<EntryPoint> {
876 if config.dynamically_loaded.is_empty() {
877 return Vec::new();
878 }
879
880 let mut builder = globset::GlobSetBuilder::new();
881 for pattern in &config.dynamically_loaded {
882 if let Ok(glob) = globset::Glob::new(pattern) {
883 builder.add(glob);
884 }
885 }
886 let Ok(glob_set) = builder.build() else {
887 return Vec::new();
888 };
889 if glob_set.is_empty() {
890 return Vec::new();
891 }
892
893 let mut entries = Vec::new();
894 for file in files {
895 let rel = file
896 .path
897 .strip_prefix(&config.root)
898 .unwrap_or(&file.path)
899 .to_string_lossy();
900 if glob_set.is_match(rel.as_ref()) {
901 entries.push(EntryPoint {
902 path: file.path.clone(),
903 source: EntryPointSource::DynamicallyLoaded,
904 });
905 }
906 }
907 entries
908}
909
910struct CompiledEntryRule<'a> {
911 path: crate::plugins::CompiledPathRule,
912 plugin_name: &'a str,
913 role: EntryPointRole,
914}
915
916impl CompiledEntryRule<'_> {
917 fn matches(&self, path: &str) -> bool {
918 self.path.matches(path)
919 }
920}
921
922fn compile_entry_rule<'a>(
923 rule: &'a crate::plugins::PathRule,
924 plugin_name: &'a str,
925 plugin_result: &'a crate::plugins::AggregatedPluginResult,
926) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
927 let include = match globset::GlobBuilder::new(&rule.pattern)
928 .literal_separator(true)
929 .build()
930 {
931 Ok(glob) => glob,
932 Err(err) => {
933 tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
934 return None;
935 }
936 };
937 let role = plugin_result
938 .entry_point_roles
939 .get(plugin_name)
940 .copied()
941 .unwrap_or(EntryPointRole::Support);
942 Some((
943 include,
944 CompiledEntryRule {
945 path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
946 plugin_name,
947 role,
948 },
949 ))
950}
951
952#[must_use]
954pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
955 if patterns.is_empty() {
956 return None;
957 }
958 let mut builder = globset::GlobSetBuilder::new();
959 for pattern in patterns {
960 if let Ok(glob) = globset::GlobBuilder::new(pattern)
961 .literal_separator(true)
962 .build()
963 {
964 builder.add(glob);
965 }
966 }
967 builder.build().ok()
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
974 use fallow_types::discover::FileId;
975 use proptest::prelude::*;
976
977 proptest! {
978 #[test]
980 fn glob_patterns_never_panic_on_compile(
981 prefix in "[a-zA-Z0-9_]{1,20}",
982 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
983 ) {
984 let pattern = format!("**/{prefix}*.{ext}");
985 let result = globset::Glob::new(&pattern);
987 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
988 }
989
990 #[test]
992 fn non_source_extensions_not_in_list(
993 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
994 ) {
995 prop_assert!(
996 !SOURCE_EXTENSIONS.contains(&ext),
997 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
998 );
999 }
1000
1001 #[test]
1003 fn compile_glob_set_no_panic(
1004 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
1005 ) {
1006 let _ = compile_glob_set(&patterns);
1008 }
1009 }
1010
1011 #[test]
1013 fn compile_glob_set_empty_input() {
1014 assert!(
1015 compile_glob_set(&[]).is_none(),
1016 "empty patterns should return None"
1017 );
1018 }
1019
1020 #[test]
1021 fn compile_glob_set_valid_patterns() {
1022 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
1023 let set = compile_glob_set(&patterns);
1024 assert!(set.is_some(), "valid patterns should compile");
1025 let set = set.unwrap();
1026 assert!(set.is_match("src/foo.ts"));
1027 assert!(set.is_match("src/bar.js"));
1028 assert!(!set.is_match("src/bar.py"));
1029 }
1030
1031 #[test]
1032 fn compile_glob_set_keeps_star_within_a_single_path_segment() {
1033 let patterns = vec!["composables/*.{ts,js}".to_string()];
1034 let set = compile_glob_set(&patterns).expect("pattern should compile");
1035
1036 assert!(set.is_match("composables/useFoo.ts"));
1037 assert!(!set.is_match("composables/nested/useFoo.ts"));
1038 }
1039
1040 #[test]
1041 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
1042 let dir = tempfile::tempdir().expect("create temp dir");
1043 let root = dir.path();
1044 std::fs::create_dir_all(root.join("src")).unwrap();
1045 std::fs::create_dir_all(root.join("tests")).unwrap();
1046 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
1047 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
1048 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
1049
1050 let config = FallowConfig {
1051 schema: None,
1052 extends: vec![],
1053 entry: vec![],
1054 ignore_patterns: vec![],
1055 framework: vec![],
1056 workspaces: None,
1057 ignore_dependencies: vec![],
1058 ignore_exports: vec![],
1059 used_class_members: vec![],
1060 duplicates: fallow_config::DuplicatesConfig::default(),
1061 health: fallow_config::HealthConfig::default(),
1062 rules: RulesConfig::default(),
1063 boundaries: fallow_config::BoundaryConfig::default(),
1064 production: false,
1065 plugins: vec![],
1066 dynamically_loaded: vec![],
1067 overrides: vec![],
1068 regression: None,
1069 audit: fallow_config::AuditConfig::default(),
1070 codeowners: None,
1071 public_packages: vec![],
1072 flags: fallow_config::FlagsConfig::default(),
1073 resolve: fallow_config::ResolveConfig::default(),
1074 sealed: false,
1075 }
1076 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
1077
1078 let files = vec![
1079 DiscoveredFile {
1080 id: FileId(0),
1081 path: root.join("src/runtime.ts"),
1082 size_bytes: 1,
1083 },
1084 DiscoveredFile {
1085 id: FileId(1),
1086 path: root.join("src/setup.ts"),
1087 size_bytes: 1,
1088 },
1089 DiscoveredFile {
1090 id: FileId(2),
1091 path: root.join("tests/app.test.ts"),
1092 size_bytes: 1,
1093 },
1094 ];
1095
1096 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1097 plugin_result.entry_patterns.push((
1098 crate::plugins::PathRule::new("src/runtime.ts"),
1099 "runtime-plugin".to_string(),
1100 ));
1101 plugin_result.entry_patterns.push((
1102 crate::plugins::PathRule::new("tests/app.test.ts"),
1103 "test-plugin".to_string(),
1104 ));
1105 plugin_result
1106 .always_used
1107 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1108 plugin_result
1109 .entry_point_roles
1110 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1111 plugin_result
1112 .entry_point_roles
1113 .insert("test-plugin".to_string(), EntryPointRole::Test);
1114 plugin_result
1115 .entry_point_roles
1116 .insert("support-plugin".to_string(), EntryPointRole::Support);
1117
1118 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1119
1120 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1121 assert!(
1122 entries.runtime[0].path.ends_with("src/runtime.ts"),
1123 "runtime entry should stay runtime-only"
1124 );
1125 assert_eq!(entries.test.len(), 1, "expected one test entry");
1126 assert!(
1127 entries.test[0].path.ends_with("tests/app.test.ts"),
1128 "test entry should stay test-only"
1129 );
1130 assert_eq!(
1131 entries.all.len(),
1132 3,
1133 "support entries should stay in all entries"
1134 );
1135 assert!(
1136 entries
1137 .all
1138 .iter()
1139 .any(|entry| entry.path.ends_with("src/setup.ts")),
1140 "support entries should remain in the overall entry-point set"
1141 );
1142 assert!(
1143 !entries
1144 .runtime
1145 .iter()
1146 .any(|entry| entry.path.ends_with("src/setup.ts")),
1147 "support entries should not bleed into runtime reachability"
1148 );
1149 assert!(
1150 !entries
1151 .test
1152 .iter()
1153 .any(|entry| entry.path.ends_with("src/setup.ts")),
1154 "support entries should not bleed into test reachability"
1155 );
1156 }
1157
1158 #[test]
1159 fn plugin_entry_point_rules_respect_exclusions() {
1160 let dir = tempfile::tempdir().expect("create temp dir");
1161 let root = dir.path();
1162 std::fs::create_dir_all(root.join("app/pages")).unwrap();
1163 std::fs::write(
1164 root.join("app/pages/index.tsx"),
1165 "export default function Page() { return null; }",
1166 )
1167 .unwrap();
1168 std::fs::write(
1169 root.join("app/pages/-helper.ts"),
1170 "export const helper = 1;",
1171 )
1172 .unwrap();
1173
1174 let config = FallowConfig {
1175 schema: None,
1176 extends: vec![],
1177 entry: vec![],
1178 ignore_patterns: vec![],
1179 framework: vec![],
1180 workspaces: None,
1181 ignore_dependencies: vec![],
1182 ignore_exports: vec![],
1183 used_class_members: vec![],
1184 duplicates: fallow_config::DuplicatesConfig::default(),
1185 health: fallow_config::HealthConfig::default(),
1186 rules: RulesConfig::default(),
1187 boundaries: fallow_config::BoundaryConfig::default(),
1188 production: false,
1189 plugins: vec![],
1190 dynamically_loaded: vec![],
1191 overrides: vec![],
1192 regression: None,
1193 audit: fallow_config::AuditConfig::default(),
1194 codeowners: None,
1195 public_packages: vec![],
1196 flags: fallow_config::FlagsConfig::default(),
1197 resolve: fallow_config::ResolveConfig::default(),
1198 sealed: false,
1199 }
1200 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
1201
1202 let files = vec![
1203 DiscoveredFile {
1204 id: FileId(0),
1205 path: root.join("app/pages/index.tsx"),
1206 size_bytes: 1,
1207 },
1208 DiscoveredFile {
1209 id: FileId(1),
1210 path: root.join("app/pages/-helper.ts"),
1211 size_bytes: 1,
1212 },
1213 ];
1214
1215 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1216 plugin_result.entry_patterns.push((
1217 crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1218 .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1219 "tanstack-router".to_string(),
1220 ));
1221 plugin_result
1222 .entry_point_roles
1223 .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1224
1225 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1226 let entry_paths: Vec<_> = entries
1227 .all
1228 .iter()
1229 .map(|entry| {
1230 entry
1231 .path
1232 .strip_prefix(root)
1233 .unwrap()
1234 .to_string_lossy()
1235 .into_owned()
1236 })
1237 .collect();
1238
1239 assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1240 assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1241 }
1242
1243 mod resolve_entry_path_tests {
1245 use super::*;
1246
1247 #[test]
1248 fn resolves_existing_file() {
1249 let dir = tempfile::tempdir().expect("create temp dir");
1250 let src = dir.path().join("src");
1251 std::fs::create_dir_all(&src).unwrap();
1252 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1253
1254 let canonical = dunce::canonicalize(dir.path()).unwrap();
1255 let result = resolve_entry_path(
1256 dir.path(),
1257 "src/index.ts",
1258 &canonical,
1259 EntryPointSource::PackageJsonMain,
1260 );
1261 assert!(result.is_some(), "should resolve an existing file");
1262 assert!(result.unwrap().path.ends_with("src/index.ts"));
1263 }
1264
1265 #[test]
1266 fn resolves_with_extension_fallback() {
1267 let dir = tempfile::tempdir().expect("create temp dir");
1268 let canonical = dunce::canonicalize(dir.path()).unwrap();
1270 let src = canonical.join("src");
1271 std::fs::create_dir_all(&src).unwrap();
1272 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1273
1274 let result = resolve_entry_path(
1276 &canonical,
1277 "src/index",
1278 &canonical,
1279 EntryPointSource::PackageJsonMain,
1280 );
1281 assert!(
1282 result.is_some(),
1283 "should resolve via extension fallback when exact path doesn't exist"
1284 );
1285 let ep = result.unwrap();
1286 assert!(
1287 ep.path.to_string_lossy().contains("index.ts"),
1288 "should find index.ts via extension fallback"
1289 );
1290 }
1291
1292 #[test]
1293 fn returns_none_for_nonexistent_file() {
1294 let dir = tempfile::tempdir().expect("create temp dir");
1295 let canonical = dunce::canonicalize(dir.path()).unwrap();
1296 let result = resolve_entry_path(
1297 dir.path(),
1298 "does/not/exist.ts",
1299 &canonical,
1300 EntryPointSource::PackageJsonMain,
1301 );
1302 assert!(result.is_none(), "should return None for nonexistent files");
1303 }
1304
1305 #[test]
1306 fn maps_dist_output_to_src() {
1307 let dir = tempfile::tempdir().expect("create temp dir");
1308 let src = dir.path().join("src");
1309 std::fs::create_dir_all(&src).unwrap();
1310 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1311
1312 let dist = dir.path().join("dist");
1314 std::fs::create_dir_all(&dist).unwrap();
1315 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1316
1317 let canonical = dunce::canonicalize(dir.path()).unwrap();
1318 let result = resolve_entry_path(
1319 dir.path(),
1320 "./dist/utils.js",
1321 &canonical,
1322 EntryPointSource::PackageJsonExports,
1323 );
1324 assert!(result.is_some(), "should resolve dist/ path to src/");
1325 let ep = result.unwrap();
1326 assert!(
1327 ep.path
1328 .to_string_lossy()
1329 .replace('\\', "/")
1330 .contains("src/utils.ts"),
1331 "should map ./dist/utils.js to src/utils.ts"
1332 );
1333 }
1334
1335 #[test]
1336 fn maps_build_output_to_src() {
1337 let dir = tempfile::tempdir().expect("create temp dir");
1338 let canonical = dunce::canonicalize(dir.path()).unwrap();
1340 let src = canonical.join("src");
1341 std::fs::create_dir_all(&src).unwrap();
1342 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1343
1344 let result = resolve_entry_path(
1345 &canonical,
1346 "./build/index.js",
1347 &canonical,
1348 EntryPointSource::PackageJsonExports,
1349 );
1350 assert!(result.is_some(), "should map build/ output to src/");
1351 let ep = result.unwrap();
1352 assert!(
1353 ep.path
1354 .to_string_lossy()
1355 .replace('\\', "/")
1356 .contains("src/index.tsx"),
1357 "should map ./build/index.js to src/index.tsx"
1358 );
1359 }
1360
1361 #[test]
1362 fn preserves_entry_point_source() {
1363 let dir = tempfile::tempdir().expect("create temp dir");
1364 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1365
1366 let canonical = dunce::canonicalize(dir.path()).unwrap();
1367 let result = resolve_entry_path(
1368 dir.path(),
1369 "index.ts",
1370 &canonical,
1371 EntryPointSource::PackageJsonScript,
1372 );
1373 assert!(result.is_some());
1374 assert!(
1375 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1376 "should preserve the source kind"
1377 );
1378 }
1379 #[test]
1380 fn tracks_skipped_entries_without_logging_each_repeat() {
1381 let dir = tempfile::tempdir().expect("create temp dir");
1382 let canonical = dunce::canonicalize(dir.path()).unwrap();
1383 let mut skipped_entries = FxHashMap::default();
1384
1385 let result = resolve_entry_path_with_tracking(
1386 dir.path(),
1387 "../scripts/build.js",
1388 &canonical,
1389 EntryPointSource::PackageJsonScript,
1390 Some(&mut skipped_entries),
1391 );
1392
1393 assert!(result.is_none(), "unsafe entry should be skipped");
1394 assert_eq!(
1395 skipped_entries.get("../scripts/build.js"),
1396 Some(&1),
1397 "warning tracker should count the skipped path"
1398 );
1399 }
1400
1401 #[test]
1402 fn formats_skipped_entry_warning_with_counts() {
1403 let mut skipped_entries = FxHashMap::default();
1404 skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
1405 skipped_entries.insert("../utils/bar.js".to_owned(), 2);
1406
1407 let warning =
1408 format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
1409
1410 assert_eq!(
1411 warning,
1412 "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
1413 );
1414 }
1415
1416 #[test]
1417 fn rejects_parent_dir_escape_for_exact_file() {
1418 let sandbox = tempfile::tempdir().expect("create sandbox");
1419 let root = sandbox.path().join("project");
1420 std::fs::create_dir_all(&root).unwrap();
1421 std::fs::write(
1422 sandbox.path().join("escape.ts"),
1423 "export const escape = true;",
1424 )
1425 .unwrap();
1426
1427 let canonical = dunce::canonicalize(&root).unwrap();
1428 let result = resolve_entry_path(
1429 &root,
1430 "../escape.ts",
1431 &canonical,
1432 EntryPointSource::PackageJsonMain,
1433 );
1434
1435 assert!(
1436 result.is_none(),
1437 "should reject exact paths that escape the root"
1438 );
1439 }
1440
1441 #[test]
1442 fn rejects_parent_dir_escape_via_extension_fallback() {
1443 let sandbox = tempfile::tempdir().expect("create sandbox");
1444 let root = sandbox.path().join("project");
1445 std::fs::create_dir_all(&root).unwrap();
1446 std::fs::write(
1447 sandbox.path().join("escape.ts"),
1448 "export const escape = true;",
1449 )
1450 .unwrap();
1451
1452 let canonical = dunce::canonicalize(&root).unwrap();
1453 let result = resolve_entry_path(
1454 &root,
1455 "../escape",
1456 &canonical,
1457 EntryPointSource::PackageJsonMain,
1458 );
1459
1460 assert!(
1461 result.is_none(),
1462 "should reject extension fallback paths that escape the root"
1463 );
1464 }
1465 }
1466
1467 mod output_to_source_tests {
1469 use super::*;
1470
1471 #[test]
1472 fn maps_dist_to_src_with_ts_extension() {
1473 let dir = tempfile::tempdir().expect("create temp dir");
1474 let src = dir.path().join("src");
1475 std::fs::create_dir_all(&src).unwrap();
1476 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1477
1478 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1479 assert!(result.is_some());
1480 assert!(
1481 result
1482 .unwrap()
1483 .to_string_lossy()
1484 .replace('\\', "/")
1485 .contains("src/utils.ts")
1486 );
1487 }
1488
1489 #[test]
1490 fn returns_none_when_no_source_file_exists() {
1491 let dir = tempfile::tempdir().expect("create temp dir");
1492 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1494 assert!(result.is_none());
1495 }
1496
1497 #[test]
1498 fn ignores_non_output_directories() {
1499 let dir = tempfile::tempdir().expect("create temp dir");
1500 let src = dir.path().join("src");
1501 std::fs::create_dir_all(&src).unwrap();
1502 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1503
1504 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1506 assert!(result.is_none());
1507 }
1508
1509 #[test]
1510 fn maps_nested_output_path_preserving_prefix() {
1511 let dir = tempfile::tempdir().expect("create temp dir");
1512 let modules_src = dir.path().join("modules").join("src");
1513 std::fs::create_dir_all(&modules_src).unwrap();
1514 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1515
1516 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1517 assert!(result.is_some());
1518 assert!(
1519 result
1520 .unwrap()
1521 .to_string_lossy()
1522 .replace('\\', "/")
1523 .contains("modules/src/helper.ts")
1524 );
1525 }
1526 }
1527
1528 mod source_index_fallback_tests {
1530 use super::*;
1531
1532 #[test]
1533 fn detects_dist_entry_in_output_dir() {
1534 assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1535 assert!(is_entry_in_output_dir("dist/index.js"));
1536 assert!(is_entry_in_output_dir("./build/index.js"));
1537 assert!(is_entry_in_output_dir("./out/main.js"));
1538 assert!(is_entry_in_output_dir("./esm/index.js"));
1539 assert!(is_entry_in_output_dir("./cjs/index.js"));
1540 }
1541
1542 #[test]
1543 fn rejects_non_output_entry_paths() {
1544 assert!(!is_entry_in_output_dir("./src/index.ts"));
1545 assert!(!is_entry_in_output_dir("src/main.ts"));
1546 assert!(!is_entry_in_output_dir("./index.js"));
1547 assert!(!is_entry_in_output_dir(""));
1548 }
1549
1550 #[test]
1551 fn rejects_substring_match_for_output_dir() {
1552 assert!(!is_entry_in_output_dir("./distro/index.js"));
1554 assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1555 }
1556
1557 #[test]
1558 fn finds_src_index_ts() {
1559 let dir = tempfile::tempdir().expect("create temp dir");
1560 let src = dir.path().join("src");
1561 std::fs::create_dir_all(&src).unwrap();
1562 let index_path = src.join("index.ts");
1563 std::fs::write(&index_path, "export const a = 1;").unwrap();
1564
1565 let result = try_source_index_fallback(dir.path());
1566 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1567 }
1568
1569 #[test]
1570 fn finds_src_index_tsx_when_ts_missing() {
1571 let dir = tempfile::tempdir().expect("create temp dir");
1572 let src = dir.path().join("src");
1573 std::fs::create_dir_all(&src).unwrap();
1574 let index_path = src.join("index.tsx");
1575 std::fs::write(&index_path, "export default 1;").unwrap();
1576
1577 let result = try_source_index_fallback(dir.path());
1578 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1579 }
1580
1581 #[test]
1582 fn prefers_src_index_over_root_index() {
1583 let dir = tempfile::tempdir().expect("create temp dir");
1586 let src = dir.path().join("src");
1587 std::fs::create_dir_all(&src).unwrap();
1588 let src_index = src.join("index.ts");
1589 std::fs::write(&src_index, "export const a = 1;").unwrap();
1590 let root_index = dir.path().join("index.ts");
1591 std::fs::write(&root_index, "export const b = 2;").unwrap();
1592
1593 let result = try_source_index_fallback(dir.path());
1594 assert_eq!(result.as_deref(), Some(src_index.as_path()));
1595 }
1596
1597 #[test]
1598 fn falls_back_to_src_main() {
1599 let dir = tempfile::tempdir().expect("create temp dir");
1600 let src = dir.path().join("src");
1601 std::fs::create_dir_all(&src).unwrap();
1602 let main_path = src.join("main.ts");
1603 std::fs::write(&main_path, "export const a = 1;").unwrap();
1604
1605 let result = try_source_index_fallback(dir.path());
1606 assert_eq!(result.as_deref(), Some(main_path.as_path()));
1607 }
1608
1609 #[test]
1610 fn falls_back_to_root_index_when_no_src() {
1611 let dir = tempfile::tempdir().expect("create temp dir");
1612 let index_path = dir.path().join("index.js");
1613 std::fs::write(&index_path, "module.exports = {};").unwrap();
1614
1615 let result = try_source_index_fallback(dir.path());
1616 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1617 }
1618
1619 #[test]
1620 fn returns_none_when_nothing_matches() {
1621 let dir = tempfile::tempdir().expect("create temp dir");
1622 let result = try_source_index_fallback(dir.path());
1623 assert!(result.is_none());
1624 }
1625
1626 #[test]
1627 fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1628 let dir = tempfile::tempdir().expect("create temp dir");
1629 let canonical = dunce::canonicalize(dir.path()).unwrap();
1630
1631 let dist_dir = canonical.join("dist").join("esm2022");
1636 std::fs::create_dir_all(&dist_dir).unwrap();
1637 std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1638
1639 let src = canonical.join("src");
1640 std::fs::create_dir_all(&src).unwrap();
1641 let src_index = src.join("index.ts");
1642 std::fs::write(&src_index, "export const x = 1;").unwrap();
1643
1644 let result = resolve_entry_path(
1645 &canonical,
1646 "./dist/esm2022/index.js",
1647 &canonical,
1648 EntryPointSource::PackageJsonMain,
1649 );
1650 assert!(result.is_some());
1651 let entry = result.unwrap();
1652 assert_eq!(entry.path, src_index);
1653 }
1654
1655 #[test]
1656 fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1657 let dir = tempfile::tempdir().expect("create temp dir");
1660 let canonical = dunce::canonicalize(dir.path()).unwrap();
1661
1662 let src_mirror = canonical.join("src").join("esm2022");
1663 std::fs::create_dir_all(&src_mirror).unwrap();
1664 let mirror_index = src_mirror.join("index.ts");
1665 std::fs::write(&mirror_index, "export const x = 1;").unwrap();
1666
1667 let src_index = canonical.join("src").join("index.ts");
1669 std::fs::write(&src_index, "export const y = 2;").unwrap();
1670
1671 let result = resolve_entry_path(
1672 &canonical,
1673 "./dist/esm2022/index.js",
1674 &canonical,
1675 EntryPointSource::PackageJsonMain,
1676 );
1677 assert_eq!(result.map(|e| e.path), Some(mirror_index));
1678 }
1679 }
1680
1681 mod default_fallback_tests {
1683 use super::*;
1684
1685 #[test]
1686 fn finds_src_index_ts_as_fallback() {
1687 let dir = tempfile::tempdir().expect("create temp dir");
1688 let src = dir.path().join("src");
1689 std::fs::create_dir_all(&src).unwrap();
1690 let index_path = src.join("index.ts");
1691 std::fs::write(&index_path, "export const a = 1;").unwrap();
1692
1693 let files = vec![DiscoveredFile {
1694 id: FileId(0),
1695 path: index_path.clone(),
1696 size_bytes: 20,
1697 }];
1698
1699 let entries = apply_default_fallback(&files, dir.path(), None);
1700 assert_eq!(entries.len(), 1);
1701 assert_eq!(entries[0].path, index_path);
1702 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1703 }
1704
1705 #[test]
1706 fn finds_root_index_js_as_fallback() {
1707 let dir = tempfile::tempdir().expect("create temp dir");
1708 let index_path = dir.path().join("index.js");
1709 std::fs::write(&index_path, "module.exports = {};").unwrap();
1710
1711 let files = vec![DiscoveredFile {
1712 id: FileId(0),
1713 path: index_path.clone(),
1714 size_bytes: 21,
1715 }];
1716
1717 let entries = apply_default_fallback(&files, dir.path(), None);
1718 assert_eq!(entries.len(), 1);
1719 assert_eq!(entries[0].path, index_path);
1720 }
1721
1722 #[test]
1723 fn returns_empty_when_no_index_file() {
1724 let dir = tempfile::tempdir().expect("create temp dir");
1725 let other_path = dir.path().join("src").join("utils.ts");
1726
1727 let files = vec![DiscoveredFile {
1728 id: FileId(0),
1729 path: other_path,
1730 size_bytes: 10,
1731 }];
1732
1733 let entries = apply_default_fallback(&files, dir.path(), None);
1734 assert!(
1735 entries.is_empty(),
1736 "non-index files should not match default fallback"
1737 );
1738 }
1739
1740 #[test]
1741 fn workspace_filter_restricts_scope() {
1742 let dir = tempfile::tempdir().expect("create temp dir");
1743 let ws_a = dir.path().join("packages").join("a").join("src");
1744 std::fs::create_dir_all(&ws_a).unwrap();
1745 let ws_b = dir.path().join("packages").join("b").join("src");
1746 std::fs::create_dir_all(&ws_b).unwrap();
1747
1748 let index_a = ws_a.join("index.ts");
1749 let index_b = ws_b.join("index.ts");
1750
1751 let files = vec![
1752 DiscoveredFile {
1753 id: FileId(0),
1754 path: index_a.clone(),
1755 size_bytes: 10,
1756 },
1757 DiscoveredFile {
1758 id: FileId(1),
1759 path: index_b,
1760 size_bytes: 10,
1761 },
1762 ];
1763
1764 let ws_root = dir.path().join("packages").join("a");
1766 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1767 assert_eq!(entries.len(), 1);
1768 assert_eq!(entries[0].path, index_a);
1769 }
1770 }
1771
1772 mod wildcard_entry_tests {
1774 use super::*;
1775
1776 #[test]
1777 fn expands_wildcard_css_entries() {
1778 let dir = tempfile::tempdir().expect("create temp dir");
1781 let themes = dir.path().join("src").join("themes");
1782 std::fs::create_dir_all(&themes).unwrap();
1783 std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
1784 std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
1785
1786 let canonical = dunce::canonicalize(dir.path()).unwrap();
1787 let mut entries = Vec::new();
1788 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1789
1790 assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
1791 let paths: Vec<String> = entries
1792 .iter()
1793 .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
1794 .collect();
1795 assert!(paths.contains(&"dark.css".to_string()));
1796 assert!(paths.contains(&"light.css".to_string()));
1797 assert!(
1798 entries
1799 .iter()
1800 .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
1801 );
1802 }
1803
1804 #[test]
1805 fn wildcard_does_not_match_nonexistent_files() {
1806 let dir = tempfile::tempdir().expect("create temp dir");
1807 std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
1809
1810 let canonical = dunce::canonicalize(dir.path()).unwrap();
1811 let mut entries = Vec::new();
1812 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1813
1814 assert!(
1815 entries.is_empty(),
1816 "should return empty when no files match the wildcard"
1817 );
1818 }
1819
1820 #[test]
1821 fn wildcard_only_matches_specified_extension() {
1822 let dir = tempfile::tempdir().expect("create temp dir");
1824 let themes = dir.path().join("src").join("themes");
1825 std::fs::create_dir_all(&themes).unwrap();
1826 std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
1827 std::fs::write(themes.join("index.ts"), "export {};").unwrap();
1828
1829 let canonical = dunce::canonicalize(dir.path()).unwrap();
1830 let mut entries = Vec::new();
1831 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1832
1833 assert_eq!(entries.len(), 1, "should only match CSS files");
1834 assert!(
1835 entries[0]
1836 .path
1837 .file_name()
1838 .unwrap()
1839 .to_string_lossy()
1840 .ends_with(".css")
1841 );
1842 }
1843 }
1844}