1use std::path::{Path, PathBuf};
2
3use fallow_config::{EntryPointRole, PackageJson, ResolvedConfig};
4use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
5
6use super::parse_scripts::extract_script_file_refs;
7use super::walk::SOURCE_EXTENSIONS;
8
9const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
13
14#[derive(Debug, Clone, Default)]
16pub struct CategorizedEntryPoints {
17 pub all: Vec<EntryPoint>,
18 pub runtime: Vec<EntryPoint>,
19 pub test: Vec<EntryPoint>,
20}
21
22impl CategorizedEntryPoints {
23 pub fn push_runtime(&mut self, entry: EntryPoint) {
24 self.runtime.push(entry.clone());
25 self.all.push(entry);
26 }
27
28 pub fn push_test(&mut self, entry: EntryPoint) {
29 self.test.push(entry.clone());
30 self.all.push(entry);
31 }
32
33 pub fn push_support(&mut self, entry: EntryPoint) {
34 self.all.push(entry);
35 }
36
37 pub fn extend_runtime<I>(&mut self, entries: I)
38 where
39 I: IntoIterator<Item = EntryPoint>,
40 {
41 for entry in entries {
42 self.push_runtime(entry);
43 }
44 }
45
46 pub fn extend_test<I>(&mut self, entries: I)
47 where
48 I: IntoIterator<Item = EntryPoint>,
49 {
50 for entry in entries {
51 self.push_test(entry);
52 }
53 }
54
55 pub fn extend_support<I>(&mut self, entries: I)
56 where
57 I: IntoIterator<Item = EntryPoint>,
58 {
59 for entry in entries {
60 self.push_support(entry);
61 }
62 }
63
64 pub fn extend(&mut self, other: Self) {
65 self.all.extend(other.all);
66 self.runtime.extend(other.runtime);
67 self.test.extend(other.test);
68 }
69
70 #[must_use]
71 pub fn dedup(mut self) -> Self {
72 dedup_entry_paths(&mut self.all);
73 dedup_entry_paths(&mut self.runtime);
74 dedup_entry_paths(&mut self.test);
75 self
76 }
77}
78
79fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
80 entries.sort_by(|a, b| a.path.cmp(&b.path));
81 entries.dedup_by(|a, b| a.path == b.path);
82}
83
84pub fn resolve_entry_path(
91 base: &Path,
92 entry: &str,
93 canonical_root: &Path,
94 source: EntryPointSource,
95) -> Option<EntryPoint> {
96 if entry.contains('*') {
99 return None;
100 }
101
102 let resolved = base.join(entry);
103 let canonical_resolved = dunce::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
105 if !canonical_resolved.starts_with(canonical_root) {
106 tracing::warn!(path = %entry, "Skipping entry point outside project root");
107 return None;
108 }
109
110 if let Some(source_path) = try_output_to_source_path(base, entry) {
115 if let Ok(canonical_source) = dunce::canonicalize(&source_path)
117 && canonical_source.starts_with(canonical_root)
118 {
119 return Some(EntryPoint {
120 path: source_path,
121 source,
122 });
123 }
124 }
125
126 if resolved.exists() {
127 return Some(EntryPoint {
128 path: resolved,
129 source,
130 });
131 }
132 for ext in SOURCE_EXTENSIONS {
134 let with_ext = resolved.with_extension(ext);
135 if with_ext.exists() {
136 return Some(EntryPoint {
137 path: with_ext,
138 source,
139 });
140 }
141 }
142 None
143}
144
145fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
157 let entry_path = Path::new(entry);
158 let components: Vec<_> = entry_path.components().collect();
159
160 let output_pos = components.iter().rposition(|c| {
162 if let std::path::Component::Normal(s) = c
163 && let Some(name) = s.to_str()
164 {
165 return OUTPUT_DIRS.contains(&name);
166 }
167 false
168 })?;
169
170 let prefix: PathBuf = components[..output_pos]
172 .iter()
173 .filter(|c| !matches!(c, std::path::Component::CurDir))
174 .collect();
175
176 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
178
179 for ext in SOURCE_EXTENSIONS {
181 let source_candidate = base
182 .join(&prefix)
183 .join("src")
184 .join(suffix.with_extension(ext));
185 if source_candidate.exists() {
186 return Some(source_candidate);
187 }
188 }
189
190 None
191}
192
193const DEFAULT_INDEX_PATTERNS: &[&str] = &[
195 "src/index.{ts,tsx,js,jsx}",
196 "src/main.{ts,tsx,js,jsx}",
197 "index.{ts,tsx,js,jsx}",
198 "main.{ts,tsx,js,jsx}",
199];
200
201fn apply_default_fallback(
206 files: &[DiscoveredFile],
207 root: &Path,
208 ws_filter: Option<&Path>,
209) -> Vec<EntryPoint> {
210 let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
211 .iter()
212 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
213 .collect();
214
215 let mut entries = Vec::new();
216 for file in files {
217 if let Some(ws_root) = ws_filter
219 && file.path.strip_prefix(ws_root).is_err()
220 {
221 continue;
222 }
223 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
224 let relative_str = relative.to_string_lossy();
225 if default_matchers
226 .iter()
227 .any(|m| m.is_match(relative_str.as_ref()))
228 {
229 entries.push(EntryPoint {
230 path: file.path.clone(),
231 source: EntryPointSource::DefaultIndex,
232 });
233 }
234 }
235 entries
236}
237
238pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
240 let _span = tracing::info_span!("discover_entry_points").entered();
241 let mut entries = Vec::new();
242
243 let relative_paths: Vec<String> = files
245 .iter()
246 .map(|f| {
247 f.path
248 .strip_prefix(&config.root)
249 .unwrap_or(&f.path)
250 .to_string_lossy()
251 .into_owned()
252 })
253 .collect();
254
255 {
258 let mut builder = globset::GlobSetBuilder::new();
259 for pattern in &config.entry_patterns {
260 if let Ok(glob) = globset::Glob::new(pattern) {
261 builder.add(glob);
262 }
263 }
264 if let Ok(glob_set) = builder.build()
265 && !glob_set.is_empty()
266 {
267 for (idx, rel) in relative_paths.iter().enumerate() {
268 if glob_set.is_match(rel) {
269 entries.push(EntryPoint {
270 path: files[idx].path.clone(),
271 source: EntryPointSource::ManualEntry,
272 });
273 }
274 }
275 }
276 }
277
278 let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
281 let pkg_path = config.root.join("package.json");
282 let root_pkg = PackageJson::load(&pkg_path).ok();
283 if let Some(pkg) = &root_pkg {
284 for entry_path in pkg.entry_points() {
285 if let Some(ep) = resolve_entry_path(
286 &config.root,
287 &entry_path,
288 &canonical_root,
289 EntryPointSource::PackageJsonMain,
290 ) {
291 entries.push(ep);
292 }
293 }
294
295 if let Some(scripts) = &pkg.scripts {
297 for script_value in scripts.values() {
298 for file_ref in extract_script_file_refs(script_value) {
299 if let Some(ep) = resolve_entry_path(
300 &config.root,
301 &file_ref,
302 &canonical_root,
303 EntryPointSource::PackageJsonScript,
304 ) {
305 entries.push(ep);
306 }
307 }
308 }
309 }
310
311 }
313
314 let exports_dirs = root_pkg
318 .map(|pkg| pkg.exports_subdirectories())
319 .unwrap_or_default();
320 discover_nested_package_entries(
321 &config.root,
322 files,
323 &mut entries,
324 &canonical_root,
325 &exports_dirs,
326 );
327
328 if entries.is_empty() {
330 entries = apply_default_fallback(files, &config.root, None);
331 }
332
333 entries.sort_by(|a, b| a.path.cmp(&b.path));
335 entries.dedup_by(|a, b| a.path == b.path);
336
337 entries
338}
339
340fn discover_nested_package_entries(
350 root: &Path,
351 _files: &[DiscoveredFile],
352 entries: &mut Vec<EntryPoint>,
353 canonical_root: &Path,
354 exports_subdirectories: &[String],
355) {
356 let mut visited = rustc_hash::FxHashSet::default();
357
358 let search_dirs = [
360 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
361 ];
362 for dir_name in &search_dirs {
363 let search_dir = root.join(dir_name);
364 if !search_dir.is_dir() {
365 continue;
366 }
367 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
368 continue;
369 };
370 for entry in read_dir.flatten() {
371 let pkg_dir = entry.path();
372 if visited.insert(pkg_dir.clone()) {
373 collect_nested_package_entries(&pkg_dir, entries, canonical_root);
374 }
375 }
376 }
377
378 for dir_name in exports_subdirectories {
380 let pkg_dir = root.join(dir_name);
381 if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
382 collect_nested_package_entries(&pkg_dir, entries, canonical_root);
383 }
384 }
385}
386
387fn collect_nested_package_entries(
389 pkg_dir: &Path,
390 entries: &mut Vec<EntryPoint>,
391 canonical_root: &Path,
392) {
393 let pkg_path = pkg_dir.join("package.json");
394 if !pkg_path.exists() {
395 return;
396 }
397 let Ok(pkg) = PackageJson::load(&pkg_path) else {
398 return;
399 };
400 for entry_path in pkg.entry_points() {
401 if entry_path.contains('*') {
402 expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
403 } else if let Some(ep) = resolve_entry_path(
404 pkg_dir,
405 &entry_path,
406 canonical_root,
407 EntryPointSource::PackageJsonExports,
408 ) {
409 entries.push(ep);
410 }
411 }
412 if let Some(scripts) = &pkg.scripts {
413 for script_value in scripts.values() {
414 for file_ref in extract_script_file_refs(script_value) {
415 if let Some(ep) = resolve_entry_path(
416 pkg_dir,
417 &file_ref,
418 canonical_root,
419 EntryPointSource::PackageJsonScript,
420 ) {
421 entries.push(ep);
422 }
423 }
424 }
425 }
426}
427
428fn expand_wildcard_entries(
434 base: &Path,
435 pattern: &str,
436 canonical_root: &Path,
437 entries: &mut Vec<EntryPoint>,
438) {
439 let full_pattern = base.join(pattern).to_string_lossy().to_string();
440 let Ok(matches) = glob::glob(&full_pattern) else {
441 return;
442 };
443 for path_result in matches {
444 let Ok(path) = path_result else {
445 continue;
446 };
447 if let Ok(canonical) = dunce::canonicalize(&path)
448 && canonical.starts_with(canonical_root)
449 {
450 entries.push(EntryPoint {
451 path,
452 source: EntryPointSource::PackageJsonExports,
453 });
454 }
455 }
456}
457
458#[must_use]
460pub fn discover_workspace_entry_points(
461 ws_root: &Path,
462 _config: &ResolvedConfig,
463 all_files: &[DiscoveredFile],
464) -> Vec<EntryPoint> {
465 let mut entries = Vec::new();
466
467 let pkg_path = ws_root.join("package.json");
468 if let Ok(pkg) = PackageJson::load(&pkg_path) {
469 let canonical_ws_root =
470 dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
471 for entry_path in pkg.entry_points() {
472 if entry_path.contains('*') {
473 expand_wildcard_entries(ws_root, &entry_path, &canonical_ws_root, &mut entries);
474 } else if let Some(ep) = resolve_entry_path(
475 ws_root,
476 &entry_path,
477 &canonical_ws_root,
478 EntryPointSource::PackageJsonMain,
479 ) {
480 entries.push(ep);
481 }
482 }
483
484 if let Some(scripts) = &pkg.scripts {
486 for script_value in scripts.values() {
487 for file_ref in extract_script_file_refs(script_value) {
488 if let Some(ep) = resolve_entry_path(
489 ws_root,
490 &file_ref,
491 &canonical_ws_root,
492 EntryPointSource::PackageJsonScript,
493 ) {
494 entries.push(ep);
495 }
496 }
497 }
498 }
499
500 }
502
503 if entries.is_empty() {
505 entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
506 }
507
508 entries.sort_by(|a, b| a.path.cmp(&b.path));
509 entries.dedup_by(|a, b| a.path == b.path);
510 entries
511}
512
513#[must_use]
518pub fn discover_plugin_entry_points(
519 plugin_result: &crate::plugins::AggregatedPluginResult,
520 config: &ResolvedConfig,
521 files: &[DiscoveredFile],
522) -> Vec<EntryPoint> {
523 discover_plugin_entry_point_sets(plugin_result, config, files).all
524}
525
526#[must_use]
528pub fn discover_plugin_entry_point_sets(
529 plugin_result: &crate::plugins::AggregatedPluginResult,
530 config: &ResolvedConfig,
531 files: &[DiscoveredFile],
532) -> CategorizedEntryPoints {
533 let mut entries = CategorizedEntryPoints::default();
534
535 let relative_paths: Vec<String> = files
537 .iter()
538 .map(|f| {
539 f.path
540 .strip_prefix(&config.root)
541 .unwrap_or(&f.path)
542 .to_string_lossy()
543 .into_owned()
544 })
545 .collect();
546
547 let mut builder = globset::GlobSetBuilder::new();
551 let mut glob_meta: Vec<(&str, EntryPointRole)> = Vec::new();
552 for (pattern, pname) in &plugin_result.entry_patterns {
553 if let Ok(glob) = globset::GlobBuilder::new(pattern)
554 .literal_separator(true)
555 .build()
556 {
557 builder.add(glob);
558 let role = plugin_result
559 .entry_point_roles
560 .get(pname)
561 .copied()
562 .unwrap_or(EntryPointRole::Support);
563 glob_meta.push((pname, role));
564 }
565 }
566 for (pattern, pname) in plugin_result
567 .discovered_always_used
568 .iter()
569 .chain(plugin_result.always_used.iter())
570 .chain(plugin_result.fixture_patterns.iter())
571 {
572 if let Ok(glob) = globset::GlobBuilder::new(pattern)
573 .literal_separator(true)
574 .build()
575 {
576 builder.add(glob);
577 glob_meta.push((pname, EntryPointRole::Support));
578 }
579 }
580 if let Ok(glob_set) = builder.build()
581 && !glob_set.is_empty()
582 {
583 for (idx, rel) in relative_paths.iter().enumerate() {
584 let matches = glob_set.matches(rel);
585 if !matches.is_empty() {
586 let (name, _) = glob_meta[matches[0]];
587 let entry = EntryPoint {
588 path: files[idx].path.clone(),
589 source: EntryPointSource::Plugin {
590 name: name.to_string(),
591 },
592 };
593
594 let mut has_runtime = false;
595 let mut has_test = false;
596 let mut has_support = false;
597 for match_idx in matches {
598 match glob_meta[match_idx].1 {
599 EntryPointRole::Runtime => has_runtime = true,
600 EntryPointRole::Test => has_test = true,
601 EntryPointRole::Support => has_support = true,
602 }
603 }
604
605 if has_runtime {
606 entries.push_runtime(entry.clone());
607 }
608 if has_test {
609 entries.push_test(entry.clone());
610 }
611 if has_support || (!has_runtime && !has_test) {
612 entries.push_support(entry);
613 }
614 }
615 }
616 }
617
618 for (setup_file, pname) in &plugin_result.setup_files {
620 let resolved = if setup_file.is_absolute() {
621 setup_file.clone()
622 } else {
623 config.root.join(setup_file)
624 };
625 if resolved.exists() {
626 entries.push_support(EntryPoint {
627 path: resolved,
628 source: EntryPointSource::Plugin {
629 name: pname.clone(),
630 },
631 });
632 } else {
633 for ext in SOURCE_EXTENSIONS {
635 let with_ext = resolved.with_extension(ext);
636 if with_ext.exists() {
637 entries.push_support(EntryPoint {
638 path: with_ext,
639 source: EntryPointSource::Plugin {
640 name: pname.clone(),
641 },
642 });
643 break;
644 }
645 }
646 }
647 }
648
649 entries.dedup()
650}
651
652#[must_use]
657pub fn discover_dynamically_loaded_entry_points(
658 config: &ResolvedConfig,
659 files: &[DiscoveredFile],
660) -> Vec<EntryPoint> {
661 if config.dynamically_loaded.is_empty() {
662 return Vec::new();
663 }
664
665 let mut builder = globset::GlobSetBuilder::new();
666 for pattern in &config.dynamically_loaded {
667 if let Ok(glob) = globset::Glob::new(pattern) {
668 builder.add(glob);
669 }
670 }
671 let Ok(glob_set) = builder.build() else {
672 return Vec::new();
673 };
674 if glob_set.is_empty() {
675 return Vec::new();
676 }
677
678 let mut entries = Vec::new();
679 for file in files {
680 let rel = file
681 .path
682 .strip_prefix(&config.root)
683 .unwrap_or(&file.path)
684 .to_string_lossy();
685 if glob_set.is_match(rel.as_ref()) {
686 entries.push(EntryPoint {
687 path: file.path.clone(),
688 source: EntryPointSource::DynamicallyLoaded,
689 });
690 }
691 }
692 entries
693}
694
695#[must_use]
697pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
698 if patterns.is_empty() {
699 return None;
700 }
701 let mut builder = globset::GlobSetBuilder::new();
702 for pattern in patterns {
703 if let Ok(glob) = globset::GlobBuilder::new(pattern)
704 .literal_separator(true)
705 .build()
706 {
707 builder.add(glob);
708 }
709 }
710 builder.build().ok()
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
717 use fallow_types::discover::FileId;
718 use proptest::prelude::*;
719
720 proptest! {
721 #[test]
723 fn glob_patterns_never_panic_on_compile(
724 prefix in "[a-zA-Z0-9_]{1,20}",
725 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
726 ) {
727 let pattern = format!("**/{prefix}*.{ext}");
728 let result = globset::Glob::new(&pattern);
730 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
731 }
732
733 #[test]
735 fn non_source_extensions_not_in_list(
736 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
737 ) {
738 prop_assert!(
739 !SOURCE_EXTENSIONS.contains(&ext),
740 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
741 );
742 }
743
744 #[test]
746 fn compile_glob_set_no_panic(
747 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
748 ) {
749 let _ = compile_glob_set(&patterns);
751 }
752 }
753
754 #[test]
756 fn compile_glob_set_empty_input() {
757 assert!(
758 compile_glob_set(&[]).is_none(),
759 "empty patterns should return None"
760 );
761 }
762
763 #[test]
764 fn compile_glob_set_valid_patterns() {
765 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
766 let set = compile_glob_set(&patterns);
767 assert!(set.is_some(), "valid patterns should compile");
768 let set = set.unwrap();
769 assert!(set.is_match("src/foo.ts"));
770 assert!(set.is_match("src/bar.js"));
771 assert!(!set.is_match("src/bar.py"));
772 }
773
774 #[test]
775 fn compile_glob_set_keeps_star_within_a_single_path_segment() {
776 let patterns = vec!["composables/*.{ts,js}".to_string()];
777 let set = compile_glob_set(&patterns).expect("pattern should compile");
778
779 assert!(set.is_match("composables/useFoo.ts"));
780 assert!(!set.is_match("composables/nested/useFoo.ts"));
781 }
782
783 #[test]
784 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
785 let dir = tempfile::tempdir().expect("create temp dir");
786 let root = dir.path();
787 std::fs::create_dir_all(root.join("src")).unwrap();
788 std::fs::create_dir_all(root.join("tests")).unwrap();
789 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
790 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
791 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
792
793 let config = FallowConfig {
794 schema: None,
795 extends: vec![],
796 entry: vec![],
797 ignore_patterns: vec![],
798 framework: vec![],
799 workspaces: None,
800 ignore_dependencies: vec![],
801 ignore_exports: vec![],
802 duplicates: fallow_config::DuplicatesConfig::default(),
803 health: fallow_config::HealthConfig::default(),
804 rules: RulesConfig::default(),
805 boundaries: fallow_config::BoundaryConfig::default(),
806 production: false,
807 plugins: vec![],
808 dynamically_loaded: vec![],
809 overrides: vec![],
810 regression: None,
811 codeowners: None,
812 public_packages: vec![],
813 }
814 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
815
816 let files = vec![
817 DiscoveredFile {
818 id: FileId(0),
819 path: root.join("src/runtime.ts"),
820 size_bytes: 1,
821 },
822 DiscoveredFile {
823 id: FileId(1),
824 path: root.join("src/setup.ts"),
825 size_bytes: 1,
826 },
827 DiscoveredFile {
828 id: FileId(2),
829 path: root.join("tests/app.test.ts"),
830 size_bytes: 1,
831 },
832 ];
833
834 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
835 plugin_result
836 .entry_patterns
837 .push(("src/runtime.ts".to_string(), "runtime-plugin".to_string()));
838 plugin_result
839 .entry_patterns
840 .push(("tests/app.test.ts".to_string(), "test-plugin".to_string()));
841 plugin_result
842 .always_used
843 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
844 plugin_result
845 .entry_point_roles
846 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
847 plugin_result
848 .entry_point_roles
849 .insert("test-plugin".to_string(), EntryPointRole::Test);
850 plugin_result
851 .entry_point_roles
852 .insert("support-plugin".to_string(), EntryPointRole::Support);
853
854 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
855
856 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
857 assert!(
858 entries.runtime[0].path.ends_with("src/runtime.ts"),
859 "runtime entry should stay runtime-only"
860 );
861 assert_eq!(entries.test.len(), 1, "expected one test entry");
862 assert!(
863 entries.test[0].path.ends_with("tests/app.test.ts"),
864 "test entry should stay test-only"
865 );
866 assert_eq!(
867 entries.all.len(),
868 3,
869 "support entries should stay in all entries"
870 );
871 assert!(
872 entries
873 .all
874 .iter()
875 .any(|entry| entry.path.ends_with("src/setup.ts")),
876 "support entries should remain in the overall entry-point set"
877 );
878 assert!(
879 !entries
880 .runtime
881 .iter()
882 .any(|entry| entry.path.ends_with("src/setup.ts")),
883 "support entries should not bleed into runtime reachability"
884 );
885 assert!(
886 !entries
887 .test
888 .iter()
889 .any(|entry| entry.path.ends_with("src/setup.ts")),
890 "support entries should not bleed into test reachability"
891 );
892 }
893
894 mod resolve_entry_path_tests {
896 use super::*;
897
898 #[test]
899 fn resolves_existing_file() {
900 let dir = tempfile::tempdir().expect("create temp dir");
901 let src = dir.path().join("src");
902 std::fs::create_dir_all(&src).unwrap();
903 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
904
905 let canonical = dunce::canonicalize(dir.path()).unwrap();
906 let result = resolve_entry_path(
907 dir.path(),
908 "src/index.ts",
909 &canonical,
910 EntryPointSource::PackageJsonMain,
911 );
912 assert!(result.is_some(), "should resolve an existing file");
913 assert!(result.unwrap().path.ends_with("src/index.ts"));
914 }
915
916 #[test]
917 fn resolves_with_extension_fallback() {
918 let dir = tempfile::tempdir().expect("create temp dir");
919 let canonical = dunce::canonicalize(dir.path()).unwrap();
921 let src = canonical.join("src");
922 std::fs::create_dir_all(&src).unwrap();
923 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
924
925 let result = resolve_entry_path(
927 &canonical,
928 "src/index",
929 &canonical,
930 EntryPointSource::PackageJsonMain,
931 );
932 assert!(
933 result.is_some(),
934 "should resolve via extension fallback when exact path doesn't exist"
935 );
936 let ep = result.unwrap();
937 assert!(
938 ep.path.to_string_lossy().contains("index.ts"),
939 "should find index.ts via extension fallback"
940 );
941 }
942
943 #[test]
944 fn returns_none_for_nonexistent_file() {
945 let dir = tempfile::tempdir().expect("create temp dir");
946 let canonical = dunce::canonicalize(dir.path()).unwrap();
947 let result = resolve_entry_path(
948 dir.path(),
949 "does/not/exist.ts",
950 &canonical,
951 EntryPointSource::PackageJsonMain,
952 );
953 assert!(result.is_none(), "should return None for nonexistent files");
954 }
955
956 #[test]
957 fn maps_dist_output_to_src() {
958 let dir = tempfile::tempdir().expect("create temp dir");
959 let src = dir.path().join("src");
960 std::fs::create_dir_all(&src).unwrap();
961 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
962
963 let dist = dir.path().join("dist");
965 std::fs::create_dir_all(&dist).unwrap();
966 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
967
968 let canonical = dunce::canonicalize(dir.path()).unwrap();
969 let result = resolve_entry_path(
970 dir.path(),
971 "./dist/utils.js",
972 &canonical,
973 EntryPointSource::PackageJsonExports,
974 );
975 assert!(result.is_some(), "should resolve dist/ path to src/");
976 let ep = result.unwrap();
977 assert!(
978 ep.path
979 .to_string_lossy()
980 .replace('\\', "/")
981 .contains("src/utils.ts"),
982 "should map ./dist/utils.js to src/utils.ts"
983 );
984 }
985
986 #[test]
987 fn maps_build_output_to_src() {
988 let dir = tempfile::tempdir().expect("create temp dir");
989 let canonical = dunce::canonicalize(dir.path()).unwrap();
991 let src = canonical.join("src");
992 std::fs::create_dir_all(&src).unwrap();
993 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
994
995 let result = resolve_entry_path(
996 &canonical,
997 "./build/index.js",
998 &canonical,
999 EntryPointSource::PackageJsonExports,
1000 );
1001 assert!(result.is_some(), "should map build/ output to src/");
1002 let ep = result.unwrap();
1003 assert!(
1004 ep.path
1005 .to_string_lossy()
1006 .replace('\\', "/")
1007 .contains("src/index.tsx"),
1008 "should map ./build/index.js to src/index.tsx"
1009 );
1010 }
1011
1012 #[test]
1013 fn preserves_entry_point_source() {
1014 let dir = tempfile::tempdir().expect("create temp dir");
1015 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1016
1017 let canonical = dunce::canonicalize(dir.path()).unwrap();
1018 let result = resolve_entry_path(
1019 dir.path(),
1020 "index.ts",
1021 &canonical,
1022 EntryPointSource::PackageJsonScript,
1023 );
1024 assert!(result.is_some());
1025 assert!(
1026 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1027 "should preserve the source kind"
1028 );
1029 }
1030 }
1031
1032 mod output_to_source_tests {
1034 use super::*;
1035
1036 #[test]
1037 fn maps_dist_to_src_with_ts_extension() {
1038 let dir = tempfile::tempdir().expect("create temp dir");
1039 let src = dir.path().join("src");
1040 std::fs::create_dir_all(&src).unwrap();
1041 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1042
1043 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1044 assert!(result.is_some());
1045 assert!(
1046 result
1047 .unwrap()
1048 .to_string_lossy()
1049 .replace('\\', "/")
1050 .contains("src/utils.ts")
1051 );
1052 }
1053
1054 #[test]
1055 fn returns_none_when_no_source_file_exists() {
1056 let dir = tempfile::tempdir().expect("create temp dir");
1057 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1059 assert!(result.is_none());
1060 }
1061
1062 #[test]
1063 fn ignores_non_output_directories() {
1064 let dir = tempfile::tempdir().expect("create temp dir");
1065 let src = dir.path().join("src");
1066 std::fs::create_dir_all(&src).unwrap();
1067 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1068
1069 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1071 assert!(result.is_none());
1072 }
1073
1074 #[test]
1075 fn maps_nested_output_path_preserving_prefix() {
1076 let dir = tempfile::tempdir().expect("create temp dir");
1077 let modules_src = dir.path().join("modules").join("src");
1078 std::fs::create_dir_all(&modules_src).unwrap();
1079 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1080
1081 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1082 assert!(result.is_some());
1083 assert!(
1084 result
1085 .unwrap()
1086 .to_string_lossy()
1087 .replace('\\', "/")
1088 .contains("modules/src/helper.ts")
1089 );
1090 }
1091 }
1092
1093 mod default_fallback_tests {
1095 use super::*;
1096
1097 #[test]
1098 fn finds_src_index_ts_as_fallback() {
1099 let dir = tempfile::tempdir().expect("create temp dir");
1100 let src = dir.path().join("src");
1101 std::fs::create_dir_all(&src).unwrap();
1102 let index_path = src.join("index.ts");
1103 std::fs::write(&index_path, "export const a = 1;").unwrap();
1104
1105 let files = vec![DiscoveredFile {
1106 id: FileId(0),
1107 path: index_path.clone(),
1108 size_bytes: 20,
1109 }];
1110
1111 let entries = apply_default_fallback(&files, dir.path(), None);
1112 assert_eq!(entries.len(), 1);
1113 assert_eq!(entries[0].path, index_path);
1114 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1115 }
1116
1117 #[test]
1118 fn finds_root_index_js_as_fallback() {
1119 let dir = tempfile::tempdir().expect("create temp dir");
1120 let index_path = dir.path().join("index.js");
1121 std::fs::write(&index_path, "module.exports = {};").unwrap();
1122
1123 let files = vec![DiscoveredFile {
1124 id: FileId(0),
1125 path: index_path.clone(),
1126 size_bytes: 21,
1127 }];
1128
1129 let entries = apply_default_fallback(&files, dir.path(), None);
1130 assert_eq!(entries.len(), 1);
1131 assert_eq!(entries[0].path, index_path);
1132 }
1133
1134 #[test]
1135 fn returns_empty_when_no_index_file() {
1136 let dir = tempfile::tempdir().expect("create temp dir");
1137 let other_path = dir.path().join("src").join("utils.ts");
1138
1139 let files = vec![DiscoveredFile {
1140 id: FileId(0),
1141 path: other_path,
1142 size_bytes: 10,
1143 }];
1144
1145 let entries = apply_default_fallback(&files, dir.path(), None);
1146 assert!(
1147 entries.is_empty(),
1148 "non-index files should not match default fallback"
1149 );
1150 }
1151
1152 #[test]
1153 fn workspace_filter_restricts_scope() {
1154 let dir = tempfile::tempdir().expect("create temp dir");
1155 let ws_a = dir.path().join("packages").join("a").join("src");
1156 std::fs::create_dir_all(&ws_a).unwrap();
1157 let ws_b = dir.path().join("packages").join("b").join("src");
1158 std::fs::create_dir_all(&ws_b).unwrap();
1159
1160 let index_a = ws_a.join("index.ts");
1161 let index_b = ws_b.join("index.ts");
1162
1163 let files = vec![
1164 DiscoveredFile {
1165 id: FileId(0),
1166 path: index_a.clone(),
1167 size_bytes: 10,
1168 },
1169 DiscoveredFile {
1170 id: FileId(1),
1171 path: index_b,
1172 size_bytes: 10,
1173 },
1174 ];
1175
1176 let ws_root = dir.path().join("packages").join("a");
1178 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1179 assert_eq!(entries.len(), 1);
1180 assert_eq!(entries[0].path, index_a);
1181 }
1182 }
1183}