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 let resolved = base.join(entry);
97 let canonical_resolved = dunce::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
99 if !canonical_resolved.starts_with(canonical_root) {
100 tracing::warn!(path = %entry, "Skipping entry point outside project root");
101 return None;
102 }
103
104 if let Some(source_path) = try_output_to_source_path(base, entry) {
109 if let Ok(canonical_source) = dunce::canonicalize(&source_path)
111 && canonical_source.starts_with(canonical_root)
112 {
113 return Some(EntryPoint {
114 path: source_path,
115 source,
116 });
117 }
118 }
119
120 if resolved.exists() {
121 return Some(EntryPoint {
122 path: resolved,
123 source,
124 });
125 }
126 for ext in SOURCE_EXTENSIONS {
128 let with_ext = resolved.with_extension(ext);
129 if with_ext.exists() {
130 return Some(EntryPoint {
131 path: with_ext,
132 source,
133 });
134 }
135 }
136 None
137}
138
139fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
151 let entry_path = Path::new(entry);
152 let components: Vec<_> = entry_path.components().collect();
153
154 let output_pos = components.iter().rposition(|c| {
156 if let std::path::Component::Normal(s) = c
157 && let Some(name) = s.to_str()
158 {
159 return OUTPUT_DIRS.contains(&name);
160 }
161 false
162 })?;
163
164 let prefix: PathBuf = components[..output_pos]
166 .iter()
167 .filter(|c| !matches!(c, std::path::Component::CurDir))
168 .collect();
169
170 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
172
173 for ext in SOURCE_EXTENSIONS {
175 let source_candidate = base
176 .join(&prefix)
177 .join("src")
178 .join(suffix.with_extension(ext));
179 if source_candidate.exists() {
180 return Some(source_candidate);
181 }
182 }
183
184 None
185}
186
187const DEFAULT_INDEX_PATTERNS: &[&str] = &[
189 "src/index.{ts,tsx,js,jsx}",
190 "src/main.{ts,tsx,js,jsx}",
191 "index.{ts,tsx,js,jsx}",
192 "main.{ts,tsx,js,jsx}",
193];
194
195fn apply_default_fallback(
200 files: &[DiscoveredFile],
201 root: &Path,
202 ws_filter: Option<&Path>,
203) -> Vec<EntryPoint> {
204 let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
205 .iter()
206 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
207 .collect();
208
209 let mut entries = Vec::new();
210 for file in files {
211 if let Some(ws_root) = ws_filter
213 && file.path.strip_prefix(ws_root).is_err()
214 {
215 continue;
216 }
217 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
218 let relative_str = relative.to_string_lossy();
219 if default_matchers
220 .iter()
221 .any(|m| m.is_match(relative_str.as_ref()))
222 {
223 entries.push(EntryPoint {
224 path: file.path.clone(),
225 source: EntryPointSource::DefaultIndex,
226 });
227 }
228 }
229 entries
230}
231
232pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
234 let _span = tracing::info_span!("discover_entry_points").entered();
235 let mut entries = Vec::new();
236
237 let relative_paths: Vec<String> = files
239 .iter()
240 .map(|f| {
241 f.path
242 .strip_prefix(&config.root)
243 .unwrap_or(&f.path)
244 .to_string_lossy()
245 .into_owned()
246 })
247 .collect();
248
249 {
252 let mut builder = globset::GlobSetBuilder::new();
253 for pattern in &config.entry_patterns {
254 if let Ok(glob) = globset::Glob::new(pattern) {
255 builder.add(glob);
256 }
257 }
258 if let Ok(glob_set) = builder.build()
259 && !glob_set.is_empty()
260 {
261 for (idx, rel) in relative_paths.iter().enumerate() {
262 if glob_set.is_match(rel) {
263 entries.push(EntryPoint {
264 path: files[idx].path.clone(),
265 source: EntryPointSource::ManualEntry,
266 });
267 }
268 }
269 }
270 }
271
272 let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
275 let pkg_path = config.root.join("package.json");
276 if let Ok(pkg) = PackageJson::load(&pkg_path) {
277 for entry_path in pkg.entry_points() {
278 if let Some(ep) = resolve_entry_path(
279 &config.root,
280 &entry_path,
281 &canonical_root,
282 EntryPointSource::PackageJsonMain,
283 ) {
284 entries.push(ep);
285 }
286 }
287
288 if let Some(scripts) = &pkg.scripts {
290 for script_value in scripts.values() {
291 for file_ref in extract_script_file_refs(script_value) {
292 if let Some(ep) = resolve_entry_path(
293 &config.root,
294 &file_ref,
295 &canonical_root,
296 EntryPointSource::PackageJsonScript,
297 ) {
298 entries.push(ep);
299 }
300 }
301 }
302 }
303
304 }
306
307 discover_nested_package_entries(&config.root, files, &mut entries, &canonical_root);
311
312 if entries.is_empty() {
314 entries = apply_default_fallback(files, &config.root, None);
315 }
316
317 entries.sort_by(|a, b| a.path.cmp(&b.path));
319 entries.dedup_by(|a, b| a.path == b.path);
320
321 entries
322}
323
324fn discover_nested_package_entries(
330 root: &Path,
331 _files: &[DiscoveredFile],
332 entries: &mut Vec<EntryPoint>,
333 canonical_root: &Path,
334) {
335 let search_dirs = [
337 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
338 ];
339 for dir_name in &search_dirs {
340 let search_dir = root.join(dir_name);
341 if !search_dir.is_dir() {
342 continue;
343 }
344 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
345 continue;
346 };
347 for entry in read_dir.flatten() {
348 let pkg_path = entry.path().join("package.json");
349 if !pkg_path.exists() {
350 continue;
351 }
352 let Ok(pkg) = PackageJson::load(&pkg_path) else {
353 continue;
354 };
355 let pkg_dir = entry.path();
356 for entry_path in pkg.entry_points() {
357 if let Some(ep) = resolve_entry_path(
358 &pkg_dir,
359 &entry_path,
360 canonical_root,
361 EntryPointSource::PackageJsonExports,
362 ) {
363 entries.push(ep);
364 }
365 }
366 if let Some(scripts) = &pkg.scripts {
368 for script_value in scripts.values() {
369 for file_ref in extract_script_file_refs(script_value) {
370 if let Some(ep) = resolve_entry_path(
371 &pkg_dir,
372 &file_ref,
373 canonical_root,
374 EntryPointSource::PackageJsonScript,
375 ) {
376 entries.push(ep);
377 }
378 }
379 }
380 }
381 }
382 }
383}
384
385#[must_use]
387pub fn discover_workspace_entry_points(
388 ws_root: &Path,
389 _config: &ResolvedConfig,
390 all_files: &[DiscoveredFile],
391) -> Vec<EntryPoint> {
392 let mut entries = Vec::new();
393
394 let pkg_path = ws_root.join("package.json");
395 if let Ok(pkg) = PackageJson::load(&pkg_path) {
396 let canonical_ws_root =
397 dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
398 for entry_path in pkg.entry_points() {
399 if let Some(ep) = resolve_entry_path(
400 ws_root,
401 &entry_path,
402 &canonical_ws_root,
403 EntryPointSource::PackageJsonMain,
404 ) {
405 entries.push(ep);
406 }
407 }
408
409 if let Some(scripts) = &pkg.scripts {
411 for script_value in scripts.values() {
412 for file_ref in extract_script_file_refs(script_value) {
413 if let Some(ep) = resolve_entry_path(
414 ws_root,
415 &file_ref,
416 &canonical_ws_root,
417 EntryPointSource::PackageJsonScript,
418 ) {
419 entries.push(ep);
420 }
421 }
422 }
423 }
424
425 }
427
428 if entries.is_empty() {
430 entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
431 }
432
433 entries.sort_by(|a, b| a.path.cmp(&b.path));
434 entries.dedup_by(|a, b| a.path == b.path);
435 entries
436}
437
438#[must_use]
443pub fn discover_plugin_entry_points(
444 plugin_result: &crate::plugins::AggregatedPluginResult,
445 config: &ResolvedConfig,
446 files: &[DiscoveredFile],
447) -> Vec<EntryPoint> {
448 discover_plugin_entry_point_sets(plugin_result, config, files).all
449}
450
451#[must_use]
453pub fn discover_plugin_entry_point_sets(
454 plugin_result: &crate::plugins::AggregatedPluginResult,
455 config: &ResolvedConfig,
456 files: &[DiscoveredFile],
457) -> CategorizedEntryPoints {
458 let mut entries = CategorizedEntryPoints::default();
459
460 let relative_paths: Vec<String> = files
462 .iter()
463 .map(|f| {
464 f.path
465 .strip_prefix(&config.root)
466 .unwrap_or(&f.path)
467 .to_string_lossy()
468 .into_owned()
469 })
470 .collect();
471
472 let mut builder = globset::GlobSetBuilder::new();
476 let mut glob_meta: Vec<(&str, EntryPointRole)> = Vec::new();
477 for (pattern, pname) in &plugin_result.entry_patterns {
478 if let Ok(glob) = globset::GlobBuilder::new(pattern)
479 .literal_separator(true)
480 .build()
481 {
482 builder.add(glob);
483 let role = plugin_result
484 .entry_point_roles
485 .get(pname)
486 .copied()
487 .unwrap_or(EntryPointRole::Support);
488 glob_meta.push((pname, role));
489 }
490 }
491 for (pattern, pname) in plugin_result
492 .discovered_always_used
493 .iter()
494 .chain(plugin_result.always_used.iter())
495 .chain(plugin_result.fixture_patterns.iter())
496 {
497 if let Ok(glob) = globset::GlobBuilder::new(pattern)
498 .literal_separator(true)
499 .build()
500 {
501 builder.add(glob);
502 glob_meta.push((pname, EntryPointRole::Support));
503 }
504 }
505 if let Ok(glob_set) = builder.build()
506 && !glob_set.is_empty()
507 {
508 for (idx, rel) in relative_paths.iter().enumerate() {
509 let matches = glob_set.matches(rel);
510 if !matches.is_empty() {
511 let (name, _) = glob_meta[matches[0]];
512 let entry = EntryPoint {
513 path: files[idx].path.clone(),
514 source: EntryPointSource::Plugin {
515 name: name.to_string(),
516 },
517 };
518
519 let mut has_runtime = false;
520 let mut has_test = false;
521 let mut has_support = false;
522 for match_idx in matches {
523 match glob_meta[match_idx].1 {
524 EntryPointRole::Runtime => has_runtime = true,
525 EntryPointRole::Test => has_test = true,
526 EntryPointRole::Support => has_support = true,
527 }
528 }
529
530 if has_runtime {
531 entries.push_runtime(entry.clone());
532 }
533 if has_test {
534 entries.push_test(entry.clone());
535 }
536 if has_support || (!has_runtime && !has_test) {
537 entries.push_support(entry);
538 }
539 }
540 }
541 }
542
543 for (setup_file, pname) in &plugin_result.setup_files {
545 let resolved = if setup_file.is_absolute() {
546 setup_file.clone()
547 } else {
548 config.root.join(setup_file)
549 };
550 if resolved.exists() {
551 entries.push_support(EntryPoint {
552 path: resolved,
553 source: EntryPointSource::Plugin {
554 name: pname.clone(),
555 },
556 });
557 } else {
558 for ext in SOURCE_EXTENSIONS {
560 let with_ext = resolved.with_extension(ext);
561 if with_ext.exists() {
562 entries.push_support(EntryPoint {
563 path: with_ext,
564 source: EntryPointSource::Plugin {
565 name: pname.clone(),
566 },
567 });
568 break;
569 }
570 }
571 }
572 }
573
574 entries.dedup()
575}
576
577#[must_use]
582pub fn discover_dynamically_loaded_entry_points(
583 config: &ResolvedConfig,
584 files: &[DiscoveredFile],
585) -> Vec<EntryPoint> {
586 if config.dynamically_loaded.is_empty() {
587 return Vec::new();
588 }
589
590 let mut builder = globset::GlobSetBuilder::new();
591 for pattern in &config.dynamically_loaded {
592 if let Ok(glob) = globset::Glob::new(pattern) {
593 builder.add(glob);
594 }
595 }
596 let Ok(glob_set) = builder.build() else {
597 return Vec::new();
598 };
599 if glob_set.is_empty() {
600 return Vec::new();
601 }
602
603 let mut entries = Vec::new();
604 for file in files {
605 let rel = file
606 .path
607 .strip_prefix(&config.root)
608 .unwrap_or(&file.path)
609 .to_string_lossy();
610 if glob_set.is_match(rel.as_ref()) {
611 entries.push(EntryPoint {
612 path: file.path.clone(),
613 source: EntryPointSource::DynamicallyLoaded,
614 });
615 }
616 }
617 entries
618}
619
620#[must_use]
622pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
623 if patterns.is_empty() {
624 return None;
625 }
626 let mut builder = globset::GlobSetBuilder::new();
627 for pattern in patterns {
628 if let Ok(glob) = globset::GlobBuilder::new(pattern)
629 .literal_separator(true)
630 .build()
631 {
632 builder.add(glob);
633 }
634 }
635 builder.build().ok()
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
642 use fallow_types::discover::FileId;
643 use proptest::prelude::*;
644
645 proptest! {
646 #[test]
648 fn glob_patterns_never_panic_on_compile(
649 prefix in "[a-zA-Z0-9_]{1,20}",
650 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
651 ) {
652 let pattern = format!("**/{prefix}*.{ext}");
653 let result = globset::Glob::new(&pattern);
655 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
656 }
657
658 #[test]
660 fn non_source_extensions_not_in_list(
661 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
662 ) {
663 prop_assert!(
664 !SOURCE_EXTENSIONS.contains(&ext),
665 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
666 );
667 }
668
669 #[test]
671 fn compile_glob_set_no_panic(
672 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
673 ) {
674 let _ = compile_glob_set(&patterns);
676 }
677 }
678
679 #[test]
681 fn compile_glob_set_empty_input() {
682 assert!(
683 compile_glob_set(&[]).is_none(),
684 "empty patterns should return None"
685 );
686 }
687
688 #[test]
689 fn compile_glob_set_valid_patterns() {
690 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
691 let set = compile_glob_set(&patterns);
692 assert!(set.is_some(), "valid patterns should compile");
693 let set = set.unwrap();
694 assert!(set.is_match("src/foo.ts"));
695 assert!(set.is_match("src/bar.js"));
696 assert!(!set.is_match("src/bar.py"));
697 }
698
699 #[test]
700 fn compile_glob_set_keeps_star_within_a_single_path_segment() {
701 let patterns = vec!["composables/*.{ts,js}".to_string()];
702 let set = compile_glob_set(&patterns).expect("pattern should compile");
703
704 assert!(set.is_match("composables/useFoo.ts"));
705 assert!(!set.is_match("composables/nested/useFoo.ts"));
706 }
707
708 #[test]
709 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
710 let dir = tempfile::tempdir().expect("create temp dir");
711 let root = dir.path();
712 std::fs::create_dir_all(root.join("src")).unwrap();
713 std::fs::create_dir_all(root.join("tests")).unwrap();
714 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
715 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
716 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
717
718 let config = FallowConfig {
719 schema: None,
720 extends: vec![],
721 entry: vec![],
722 ignore_patterns: vec![],
723 framework: vec![],
724 workspaces: None,
725 ignore_dependencies: vec![],
726 ignore_exports: vec![],
727 duplicates: fallow_config::DuplicatesConfig::default(),
728 health: fallow_config::HealthConfig::default(),
729 rules: RulesConfig::default(),
730 boundaries: fallow_config::BoundaryConfig::default(),
731 production: false,
732 plugins: vec![],
733 dynamically_loaded: vec![],
734 overrides: vec![],
735 regression: None,
736 codeowners: None,
737 public_packages: vec![],
738 }
739 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
740
741 let files = vec![
742 DiscoveredFile {
743 id: FileId(0),
744 path: root.join("src/runtime.ts"),
745 size_bytes: 1,
746 },
747 DiscoveredFile {
748 id: FileId(1),
749 path: root.join("src/setup.ts"),
750 size_bytes: 1,
751 },
752 DiscoveredFile {
753 id: FileId(2),
754 path: root.join("tests/app.test.ts"),
755 size_bytes: 1,
756 },
757 ];
758
759 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
760 plugin_result
761 .entry_patterns
762 .push(("src/runtime.ts".to_string(), "runtime-plugin".to_string()));
763 plugin_result
764 .entry_patterns
765 .push(("tests/app.test.ts".to_string(), "test-plugin".to_string()));
766 plugin_result
767 .always_used
768 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
769 plugin_result
770 .entry_point_roles
771 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
772 plugin_result
773 .entry_point_roles
774 .insert("test-plugin".to_string(), EntryPointRole::Test);
775 plugin_result
776 .entry_point_roles
777 .insert("support-plugin".to_string(), EntryPointRole::Support);
778
779 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
780
781 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
782 assert!(
783 entries.runtime[0].path.ends_with("src/runtime.ts"),
784 "runtime entry should stay runtime-only"
785 );
786 assert_eq!(entries.test.len(), 1, "expected one test entry");
787 assert!(
788 entries.test[0].path.ends_with("tests/app.test.ts"),
789 "test entry should stay test-only"
790 );
791 assert_eq!(
792 entries.all.len(),
793 3,
794 "support entries should stay in all entries"
795 );
796 assert!(
797 entries
798 .all
799 .iter()
800 .any(|entry| entry.path.ends_with("src/setup.ts")),
801 "support entries should remain in the overall entry-point set"
802 );
803 assert!(
804 !entries
805 .runtime
806 .iter()
807 .any(|entry| entry.path.ends_with("src/setup.ts")),
808 "support entries should not bleed into runtime reachability"
809 );
810 assert!(
811 !entries
812 .test
813 .iter()
814 .any(|entry| entry.path.ends_with("src/setup.ts")),
815 "support entries should not bleed into test reachability"
816 );
817 }
818
819 mod resolve_entry_path_tests {
821 use super::*;
822
823 #[test]
824 fn resolves_existing_file() {
825 let dir = tempfile::tempdir().expect("create temp dir");
826 let src = dir.path().join("src");
827 std::fs::create_dir_all(&src).unwrap();
828 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
829
830 let canonical = dunce::canonicalize(dir.path()).unwrap();
831 let result = resolve_entry_path(
832 dir.path(),
833 "src/index.ts",
834 &canonical,
835 EntryPointSource::PackageJsonMain,
836 );
837 assert!(result.is_some(), "should resolve an existing file");
838 assert!(result.unwrap().path.ends_with("src/index.ts"));
839 }
840
841 #[test]
842 fn resolves_with_extension_fallback() {
843 let dir = tempfile::tempdir().expect("create temp dir");
844 let canonical = dunce::canonicalize(dir.path()).unwrap();
846 let src = canonical.join("src");
847 std::fs::create_dir_all(&src).unwrap();
848 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
849
850 let result = resolve_entry_path(
852 &canonical,
853 "src/index",
854 &canonical,
855 EntryPointSource::PackageJsonMain,
856 );
857 assert!(
858 result.is_some(),
859 "should resolve via extension fallback when exact path doesn't exist"
860 );
861 let ep = result.unwrap();
862 assert!(
863 ep.path.to_string_lossy().contains("index.ts"),
864 "should find index.ts via extension fallback"
865 );
866 }
867
868 #[test]
869 fn returns_none_for_nonexistent_file() {
870 let dir = tempfile::tempdir().expect("create temp dir");
871 let canonical = dunce::canonicalize(dir.path()).unwrap();
872 let result = resolve_entry_path(
873 dir.path(),
874 "does/not/exist.ts",
875 &canonical,
876 EntryPointSource::PackageJsonMain,
877 );
878 assert!(result.is_none(), "should return None for nonexistent files");
879 }
880
881 #[test]
882 fn maps_dist_output_to_src() {
883 let dir = tempfile::tempdir().expect("create temp dir");
884 let src = dir.path().join("src");
885 std::fs::create_dir_all(&src).unwrap();
886 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
887
888 let dist = dir.path().join("dist");
890 std::fs::create_dir_all(&dist).unwrap();
891 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
892
893 let canonical = dunce::canonicalize(dir.path()).unwrap();
894 let result = resolve_entry_path(
895 dir.path(),
896 "./dist/utils.js",
897 &canonical,
898 EntryPointSource::PackageJsonExports,
899 );
900 assert!(result.is_some(), "should resolve dist/ path to src/");
901 let ep = result.unwrap();
902 assert!(
903 ep.path
904 .to_string_lossy()
905 .replace('\\', "/")
906 .contains("src/utils.ts"),
907 "should map ./dist/utils.js to src/utils.ts"
908 );
909 }
910
911 #[test]
912 fn maps_build_output_to_src() {
913 let dir = tempfile::tempdir().expect("create temp dir");
914 let canonical = dunce::canonicalize(dir.path()).unwrap();
916 let src = canonical.join("src");
917 std::fs::create_dir_all(&src).unwrap();
918 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
919
920 let result = resolve_entry_path(
921 &canonical,
922 "./build/index.js",
923 &canonical,
924 EntryPointSource::PackageJsonExports,
925 );
926 assert!(result.is_some(), "should map build/ output to src/");
927 let ep = result.unwrap();
928 assert!(
929 ep.path
930 .to_string_lossy()
931 .replace('\\', "/")
932 .contains("src/index.tsx"),
933 "should map ./build/index.js to src/index.tsx"
934 );
935 }
936
937 #[test]
938 fn preserves_entry_point_source() {
939 let dir = tempfile::tempdir().expect("create temp dir");
940 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
941
942 let canonical = dunce::canonicalize(dir.path()).unwrap();
943 let result = resolve_entry_path(
944 dir.path(),
945 "index.ts",
946 &canonical,
947 EntryPointSource::PackageJsonScript,
948 );
949 assert!(result.is_some());
950 assert!(
951 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
952 "should preserve the source kind"
953 );
954 }
955 }
956
957 mod output_to_source_tests {
959 use super::*;
960
961 #[test]
962 fn maps_dist_to_src_with_ts_extension() {
963 let dir = tempfile::tempdir().expect("create temp dir");
964 let src = dir.path().join("src");
965 std::fs::create_dir_all(&src).unwrap();
966 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
967
968 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
969 assert!(result.is_some());
970 assert!(
971 result
972 .unwrap()
973 .to_string_lossy()
974 .replace('\\', "/")
975 .contains("src/utils.ts")
976 );
977 }
978
979 #[test]
980 fn returns_none_when_no_source_file_exists() {
981 let dir = tempfile::tempdir().expect("create temp dir");
982 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
984 assert!(result.is_none());
985 }
986
987 #[test]
988 fn ignores_non_output_directories() {
989 let dir = tempfile::tempdir().expect("create temp dir");
990 let src = dir.path().join("src");
991 std::fs::create_dir_all(&src).unwrap();
992 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
993
994 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
996 assert!(result.is_none());
997 }
998
999 #[test]
1000 fn maps_nested_output_path_preserving_prefix() {
1001 let dir = tempfile::tempdir().expect("create temp dir");
1002 let modules_src = dir.path().join("modules").join("src");
1003 std::fs::create_dir_all(&modules_src).unwrap();
1004 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1005
1006 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1007 assert!(result.is_some());
1008 assert!(
1009 result
1010 .unwrap()
1011 .to_string_lossy()
1012 .replace('\\', "/")
1013 .contains("modules/src/helper.ts")
1014 );
1015 }
1016 }
1017
1018 mod default_fallback_tests {
1020 use super::*;
1021
1022 #[test]
1023 fn finds_src_index_ts_as_fallback() {
1024 let dir = tempfile::tempdir().expect("create temp dir");
1025 let src = dir.path().join("src");
1026 std::fs::create_dir_all(&src).unwrap();
1027 let index_path = src.join("index.ts");
1028 std::fs::write(&index_path, "export const a = 1;").unwrap();
1029
1030 let files = vec![DiscoveredFile {
1031 id: FileId(0),
1032 path: index_path.clone(),
1033 size_bytes: 20,
1034 }];
1035
1036 let entries = apply_default_fallback(&files, dir.path(), None);
1037 assert_eq!(entries.len(), 1);
1038 assert_eq!(entries[0].path, index_path);
1039 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1040 }
1041
1042 #[test]
1043 fn finds_root_index_js_as_fallback() {
1044 let dir = tempfile::tempdir().expect("create temp dir");
1045 let index_path = dir.path().join("index.js");
1046 std::fs::write(&index_path, "module.exports = {};").unwrap();
1047
1048 let files = vec![DiscoveredFile {
1049 id: FileId(0),
1050 path: index_path.clone(),
1051 size_bytes: 21,
1052 }];
1053
1054 let entries = apply_default_fallback(&files, dir.path(), None);
1055 assert_eq!(entries.len(), 1);
1056 assert_eq!(entries[0].path, index_path);
1057 }
1058
1059 #[test]
1060 fn returns_empty_when_no_index_file() {
1061 let dir = tempfile::tempdir().expect("create temp dir");
1062 let other_path = dir.path().join("src").join("utils.ts");
1063
1064 let files = vec![DiscoveredFile {
1065 id: FileId(0),
1066 path: other_path,
1067 size_bytes: 10,
1068 }];
1069
1070 let entries = apply_default_fallback(&files, dir.path(), None);
1071 assert!(
1072 entries.is_empty(),
1073 "non-index files should not match default fallback"
1074 );
1075 }
1076
1077 #[test]
1078 fn workspace_filter_restricts_scope() {
1079 let dir = tempfile::tempdir().expect("create temp dir");
1080 let ws_a = dir.path().join("packages").join("a").join("src");
1081 std::fs::create_dir_all(&ws_a).unwrap();
1082 let ws_b = dir.path().join("packages").join("b").join("src");
1083 std::fs::create_dir_all(&ws_b).unwrap();
1084
1085 let index_a = ws_a.join("index.ts");
1086 let index_b = ws_b.join("index.ts");
1087
1088 let files = vec![
1089 DiscoveredFile {
1090 id: FileId(0),
1091 path: index_a.clone(),
1092 size_bytes: 10,
1093 },
1094 DiscoveredFile {
1095 id: FileId(1),
1096 path: index_b,
1097 size_bytes: 10,
1098 },
1099 ];
1100
1101 let ws_root = dir.path().join("packages").join("a");
1103 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1104 assert_eq!(entries.len(), 1);
1105 assert_eq!(entries[0].path, index_a);
1106 }
1107 }
1108}