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 = resolved.canonicalize().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) = source_path.canonicalize()
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 = config
275 .root
276 .canonicalize()
277 .unwrap_or_else(|_| config.root.clone());
278 let pkg_path = config.root.join("package.json");
279 if let Ok(pkg) = PackageJson::load(&pkg_path) {
280 for entry_path in pkg.entry_points() {
281 if let Some(ep) = resolve_entry_path(
282 &config.root,
283 &entry_path,
284 &canonical_root,
285 EntryPointSource::PackageJsonMain,
286 ) {
287 entries.push(ep);
288 }
289 }
290
291 if let Some(scripts) = &pkg.scripts {
293 for script_value in scripts.values() {
294 for file_ref in extract_script_file_refs(script_value) {
295 if let Some(ep) = resolve_entry_path(
296 &config.root,
297 &file_ref,
298 &canonical_root,
299 EntryPointSource::PackageJsonScript,
300 ) {
301 entries.push(ep);
302 }
303 }
304 }
305 }
306
307 }
309
310 discover_nested_package_entries(&config.root, files, &mut entries, &canonical_root);
314
315 if entries.is_empty() {
317 entries = apply_default_fallback(files, &config.root, None);
318 }
319
320 entries.sort_by(|a, b| a.path.cmp(&b.path));
322 entries.dedup_by(|a, b| a.path == b.path);
323
324 entries
325}
326
327fn discover_nested_package_entries(
333 root: &Path,
334 _files: &[DiscoveredFile],
335 entries: &mut Vec<EntryPoint>,
336 canonical_root: &Path,
337) {
338 let search_dirs = [
340 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
341 ];
342 for dir_name in &search_dirs {
343 let search_dir = root.join(dir_name);
344 if !search_dir.is_dir() {
345 continue;
346 }
347 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
348 continue;
349 };
350 for entry in read_dir.flatten() {
351 let pkg_path = entry.path().join("package.json");
352 if !pkg_path.exists() {
353 continue;
354 }
355 let Ok(pkg) = PackageJson::load(&pkg_path) else {
356 continue;
357 };
358 let pkg_dir = entry.path();
359 for entry_path in pkg.entry_points() {
360 if let Some(ep) = resolve_entry_path(
361 &pkg_dir,
362 &entry_path,
363 canonical_root,
364 EntryPointSource::PackageJsonExports,
365 ) {
366 entries.push(ep);
367 }
368 }
369 if let Some(scripts) = &pkg.scripts {
371 for script_value in scripts.values() {
372 for file_ref in extract_script_file_refs(script_value) {
373 if let Some(ep) = resolve_entry_path(
374 &pkg_dir,
375 &file_ref,
376 canonical_root,
377 EntryPointSource::PackageJsonScript,
378 ) {
379 entries.push(ep);
380 }
381 }
382 }
383 }
384 }
385 }
386}
387
388#[must_use]
390pub fn discover_workspace_entry_points(
391 ws_root: &Path,
392 _config: &ResolvedConfig,
393 all_files: &[DiscoveredFile],
394) -> Vec<EntryPoint> {
395 let mut entries = Vec::new();
396
397 let pkg_path = ws_root.join("package.json");
398 if let Ok(pkg) = PackageJson::load(&pkg_path) {
399 let canonical_ws_root = ws_root
400 .canonicalize()
401 .unwrap_or_else(|_| ws_root.to_path_buf());
402 for entry_path in pkg.entry_points() {
403 if let Some(ep) = resolve_entry_path(
404 ws_root,
405 &entry_path,
406 &canonical_ws_root,
407 EntryPointSource::PackageJsonMain,
408 ) {
409 entries.push(ep);
410 }
411 }
412
413 if let Some(scripts) = &pkg.scripts {
415 for script_value in scripts.values() {
416 for file_ref in extract_script_file_refs(script_value) {
417 if let Some(ep) = resolve_entry_path(
418 ws_root,
419 &file_ref,
420 &canonical_ws_root,
421 EntryPointSource::PackageJsonScript,
422 ) {
423 entries.push(ep);
424 }
425 }
426 }
427 }
428
429 }
431
432 if entries.is_empty() {
434 entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
435 }
436
437 entries.sort_by(|a, b| a.path.cmp(&b.path));
438 entries.dedup_by(|a, b| a.path == b.path);
439 entries
440}
441
442#[must_use]
447pub fn discover_plugin_entry_points(
448 plugin_result: &crate::plugins::AggregatedPluginResult,
449 config: &ResolvedConfig,
450 files: &[DiscoveredFile],
451) -> Vec<EntryPoint> {
452 discover_plugin_entry_point_sets(plugin_result, config, files).all
453}
454
455#[must_use]
457pub fn discover_plugin_entry_point_sets(
458 plugin_result: &crate::plugins::AggregatedPluginResult,
459 config: &ResolvedConfig,
460 files: &[DiscoveredFile],
461) -> CategorizedEntryPoints {
462 let mut entries = CategorizedEntryPoints::default();
463
464 let relative_paths: Vec<String> = files
466 .iter()
467 .map(|f| {
468 f.path
469 .strip_prefix(&config.root)
470 .unwrap_or(&f.path)
471 .to_string_lossy()
472 .into_owned()
473 })
474 .collect();
475
476 let mut builder = globset::GlobSetBuilder::new();
480 let mut glob_meta: Vec<(&str, EntryPointRole)> = Vec::new();
481 for (pattern, pname) in &plugin_result.entry_patterns {
482 if let Ok(glob) = globset::Glob::new(pattern) {
483 builder.add(glob);
484 let role = plugin_result
485 .entry_point_roles
486 .get(pname)
487 .copied()
488 .unwrap_or(EntryPointRole::Support);
489 glob_meta.push((pname, role));
490 }
491 }
492 for (pattern, pname) in plugin_result
493 .discovered_always_used
494 .iter()
495 .chain(plugin_result.always_used.iter())
496 .chain(plugin_result.fixture_patterns.iter())
497 {
498 if let Ok(glob) = globset::Glob::new(pattern) {
499 builder.add(glob);
500 glob_meta.push((pname, EntryPointRole::Support));
501 }
502 }
503 if let Ok(glob_set) = builder.build()
504 && !glob_set.is_empty()
505 {
506 for (idx, rel) in relative_paths.iter().enumerate() {
507 let matches = glob_set.matches(rel);
508 if !matches.is_empty() {
509 let (name, _) = glob_meta[matches[0]];
510 let entry = EntryPoint {
511 path: files[idx].path.clone(),
512 source: EntryPointSource::Plugin {
513 name: name.to_string(),
514 },
515 };
516
517 let mut has_runtime = false;
518 let mut has_test = false;
519 let mut has_support = false;
520 for match_idx in matches {
521 match glob_meta[match_idx].1 {
522 EntryPointRole::Runtime => has_runtime = true,
523 EntryPointRole::Test => has_test = true,
524 EntryPointRole::Support => has_support = true,
525 }
526 }
527
528 if has_runtime {
529 entries.push_runtime(entry.clone());
530 }
531 if has_test {
532 entries.push_test(entry.clone());
533 }
534 if has_support || (!has_runtime && !has_test) {
535 entries.push_support(entry);
536 }
537 }
538 }
539 }
540
541 for (setup_file, pname) in &plugin_result.setup_files {
543 let resolved = if setup_file.is_absolute() {
544 setup_file.clone()
545 } else {
546 config.root.join(setup_file)
547 };
548 if resolved.exists() {
549 entries.push_support(EntryPoint {
550 path: resolved,
551 source: EntryPointSource::Plugin {
552 name: pname.clone(),
553 },
554 });
555 } else {
556 for ext in SOURCE_EXTENSIONS {
558 let with_ext = resolved.with_extension(ext);
559 if with_ext.exists() {
560 entries.push_support(EntryPoint {
561 path: with_ext,
562 source: EntryPointSource::Plugin {
563 name: pname.clone(),
564 },
565 });
566 break;
567 }
568 }
569 }
570 }
571
572 entries.dedup()
573}
574
575#[must_use]
580pub fn discover_dynamically_loaded_entry_points(
581 config: &ResolvedConfig,
582 files: &[DiscoveredFile],
583) -> Vec<EntryPoint> {
584 if config.dynamically_loaded.is_empty() {
585 return Vec::new();
586 }
587
588 let mut builder = globset::GlobSetBuilder::new();
589 for pattern in &config.dynamically_loaded {
590 if let Ok(glob) = globset::Glob::new(pattern) {
591 builder.add(glob);
592 }
593 }
594 let Ok(glob_set) = builder.build() else {
595 return Vec::new();
596 };
597 if glob_set.is_empty() {
598 return Vec::new();
599 }
600
601 let mut entries = Vec::new();
602 for file in files {
603 let rel = file
604 .path
605 .strip_prefix(&config.root)
606 .unwrap_or(&file.path)
607 .to_string_lossy();
608 if glob_set.is_match(rel.as_ref()) {
609 entries.push(EntryPoint {
610 path: file.path.clone(),
611 source: EntryPointSource::DynamicallyLoaded,
612 });
613 }
614 }
615 entries
616}
617
618#[must_use]
620pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
621 if patterns.is_empty() {
622 return None;
623 }
624 let mut builder = globset::GlobSetBuilder::new();
625 for pattern in patterns {
626 if let Ok(glob) = globset::Glob::new(pattern) {
627 builder.add(glob);
628 }
629 }
630 builder.build().ok()
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
637 use fallow_types::discover::FileId;
638 use proptest::prelude::*;
639
640 proptest! {
641 #[test]
643 fn glob_patterns_never_panic_on_compile(
644 prefix in "[a-zA-Z0-9_]{1,20}",
645 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
646 ) {
647 let pattern = format!("**/{prefix}*.{ext}");
648 let result = globset::Glob::new(&pattern);
650 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
651 }
652
653 #[test]
655 fn non_source_extensions_not_in_list(
656 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
657 ) {
658 prop_assert!(
659 !SOURCE_EXTENSIONS.contains(&ext),
660 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
661 );
662 }
663
664 #[test]
666 fn compile_glob_set_no_panic(
667 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
668 ) {
669 let _ = compile_glob_set(&patterns);
671 }
672 }
673
674 #[test]
676 fn compile_glob_set_empty_input() {
677 assert!(
678 compile_glob_set(&[]).is_none(),
679 "empty patterns should return None"
680 );
681 }
682
683 #[test]
684 fn compile_glob_set_valid_patterns() {
685 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
686 let set = compile_glob_set(&patterns);
687 assert!(set.is_some(), "valid patterns should compile");
688 let set = set.unwrap();
689 assert!(set.is_match("src/foo.ts"));
690 assert!(set.is_match("src/bar.js"));
691 assert!(!set.is_match("src/bar.py"));
692 }
693
694 #[test]
695 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
696 let dir = tempfile::tempdir().expect("create temp dir");
697 let root = dir.path();
698 std::fs::create_dir_all(root.join("src")).unwrap();
699 std::fs::create_dir_all(root.join("tests")).unwrap();
700 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
701 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
702 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
703
704 let config = FallowConfig {
705 schema: None,
706 extends: vec![],
707 entry: vec![],
708 ignore_patterns: vec![],
709 framework: vec![],
710 workspaces: None,
711 ignore_dependencies: vec![],
712 ignore_exports: vec![],
713 duplicates: fallow_config::DuplicatesConfig::default(),
714 health: fallow_config::HealthConfig::default(),
715 rules: RulesConfig::default(),
716 boundaries: fallow_config::BoundaryConfig::default(),
717 production: false,
718 plugins: vec![],
719 dynamically_loaded: vec![],
720 overrides: vec![],
721 regression: None,
722 codeowners: None,
723 public_packages: vec![],
724 }
725 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
726
727 let files = vec![
728 DiscoveredFile {
729 id: FileId(0),
730 path: root.join("src/runtime.ts"),
731 size_bytes: 1,
732 },
733 DiscoveredFile {
734 id: FileId(1),
735 path: root.join("src/setup.ts"),
736 size_bytes: 1,
737 },
738 DiscoveredFile {
739 id: FileId(2),
740 path: root.join("tests/app.test.ts"),
741 size_bytes: 1,
742 },
743 ];
744
745 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
746 plugin_result
747 .entry_patterns
748 .push(("src/runtime.ts".to_string(), "runtime-plugin".to_string()));
749 plugin_result
750 .entry_patterns
751 .push(("tests/app.test.ts".to_string(), "test-plugin".to_string()));
752 plugin_result
753 .always_used
754 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
755 plugin_result
756 .entry_point_roles
757 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
758 plugin_result
759 .entry_point_roles
760 .insert("test-plugin".to_string(), EntryPointRole::Test);
761 plugin_result
762 .entry_point_roles
763 .insert("support-plugin".to_string(), EntryPointRole::Support);
764
765 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
766
767 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
768 assert!(
769 entries.runtime[0].path.ends_with("src/runtime.ts"),
770 "runtime entry should stay runtime-only"
771 );
772 assert_eq!(entries.test.len(), 1, "expected one test entry");
773 assert!(
774 entries.test[0].path.ends_with("tests/app.test.ts"),
775 "test entry should stay test-only"
776 );
777 assert_eq!(
778 entries.all.len(),
779 3,
780 "support entries should stay in all entries"
781 );
782 assert!(
783 entries
784 .all
785 .iter()
786 .any(|entry| entry.path.ends_with("src/setup.ts")),
787 "support entries should remain in the overall entry-point set"
788 );
789 assert!(
790 !entries
791 .runtime
792 .iter()
793 .any(|entry| entry.path.ends_with("src/setup.ts")),
794 "support entries should not bleed into runtime reachability"
795 );
796 assert!(
797 !entries
798 .test
799 .iter()
800 .any(|entry| entry.path.ends_with("src/setup.ts")),
801 "support entries should not bleed into test reachability"
802 );
803 }
804
805 mod resolve_entry_path_tests {
807 use super::*;
808
809 #[test]
810 fn resolves_existing_file() {
811 let dir = tempfile::tempdir().expect("create temp dir");
812 let src = dir.path().join("src");
813 std::fs::create_dir_all(&src).unwrap();
814 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
815
816 let canonical = dir.path().canonicalize().unwrap();
817 let result = resolve_entry_path(
818 dir.path(),
819 "src/index.ts",
820 &canonical,
821 EntryPointSource::PackageJsonMain,
822 );
823 assert!(result.is_some(), "should resolve an existing file");
824 assert!(result.unwrap().path.ends_with("src/index.ts"));
825 }
826
827 #[test]
828 fn resolves_with_extension_fallback() {
829 let dir = tempfile::tempdir().expect("create temp dir");
830 let canonical = dir.path().canonicalize().unwrap();
832 let src = canonical.join("src");
833 std::fs::create_dir_all(&src).unwrap();
834 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
835
836 let result = resolve_entry_path(
838 &canonical,
839 "src/index",
840 &canonical,
841 EntryPointSource::PackageJsonMain,
842 );
843 assert!(
844 result.is_some(),
845 "should resolve via extension fallback when exact path doesn't exist"
846 );
847 let ep = result.unwrap();
848 assert!(
849 ep.path.to_string_lossy().contains("index.ts"),
850 "should find index.ts via extension fallback"
851 );
852 }
853
854 #[test]
855 fn returns_none_for_nonexistent_file() {
856 let dir = tempfile::tempdir().expect("create temp dir");
857 let canonical = dir.path().canonicalize().unwrap();
858 let result = resolve_entry_path(
859 dir.path(),
860 "does/not/exist.ts",
861 &canonical,
862 EntryPointSource::PackageJsonMain,
863 );
864 assert!(result.is_none(), "should return None for nonexistent files");
865 }
866
867 #[test]
868 fn maps_dist_output_to_src() {
869 let dir = tempfile::tempdir().expect("create temp dir");
870 let src = dir.path().join("src");
871 std::fs::create_dir_all(&src).unwrap();
872 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
873
874 let dist = dir.path().join("dist");
876 std::fs::create_dir_all(&dist).unwrap();
877 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
878
879 let canonical = dir.path().canonicalize().unwrap();
880 let result = resolve_entry_path(
881 dir.path(),
882 "./dist/utils.js",
883 &canonical,
884 EntryPointSource::PackageJsonExports,
885 );
886 assert!(result.is_some(), "should resolve dist/ path to src/");
887 let ep = result.unwrap();
888 assert!(
889 ep.path
890 .to_string_lossy()
891 .replace('\\', "/")
892 .contains("src/utils.ts"),
893 "should map ./dist/utils.js to src/utils.ts"
894 );
895 }
896
897 #[test]
898 fn maps_build_output_to_src() {
899 let dir = tempfile::tempdir().expect("create temp dir");
900 let canonical = dir.path().canonicalize().unwrap();
902 let src = canonical.join("src");
903 std::fs::create_dir_all(&src).unwrap();
904 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
905
906 let result = resolve_entry_path(
907 &canonical,
908 "./build/index.js",
909 &canonical,
910 EntryPointSource::PackageJsonExports,
911 );
912 assert!(result.is_some(), "should map build/ output to src/");
913 let ep = result.unwrap();
914 assert!(
915 ep.path
916 .to_string_lossy()
917 .replace('\\', "/")
918 .contains("src/index.tsx"),
919 "should map ./build/index.js to src/index.tsx"
920 );
921 }
922
923 #[test]
924 fn preserves_entry_point_source() {
925 let dir = tempfile::tempdir().expect("create temp dir");
926 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
927
928 let canonical = dir.path().canonicalize().unwrap();
929 let result = resolve_entry_path(
930 dir.path(),
931 "index.ts",
932 &canonical,
933 EntryPointSource::PackageJsonScript,
934 );
935 assert!(result.is_some());
936 assert!(
937 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
938 "should preserve the source kind"
939 );
940 }
941 }
942
943 mod output_to_source_tests {
945 use super::*;
946
947 #[test]
948 fn maps_dist_to_src_with_ts_extension() {
949 let dir = tempfile::tempdir().expect("create temp dir");
950 let src = dir.path().join("src");
951 std::fs::create_dir_all(&src).unwrap();
952 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
953
954 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
955 assert!(result.is_some());
956 assert!(
957 result
958 .unwrap()
959 .to_string_lossy()
960 .replace('\\', "/")
961 .contains("src/utils.ts")
962 );
963 }
964
965 #[test]
966 fn returns_none_when_no_source_file_exists() {
967 let dir = tempfile::tempdir().expect("create temp dir");
968 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
970 assert!(result.is_none());
971 }
972
973 #[test]
974 fn ignores_non_output_directories() {
975 let dir = tempfile::tempdir().expect("create temp dir");
976 let src = dir.path().join("src");
977 std::fs::create_dir_all(&src).unwrap();
978 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
979
980 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
982 assert!(result.is_none());
983 }
984
985 #[test]
986 fn maps_nested_output_path_preserving_prefix() {
987 let dir = tempfile::tempdir().expect("create temp dir");
988 let modules_src = dir.path().join("modules").join("src");
989 std::fs::create_dir_all(&modules_src).unwrap();
990 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
991
992 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
993 assert!(result.is_some());
994 assert!(
995 result
996 .unwrap()
997 .to_string_lossy()
998 .replace('\\', "/")
999 .contains("modules/src/helper.ts")
1000 );
1001 }
1002 }
1003
1004 mod default_fallback_tests {
1006 use super::*;
1007
1008 #[test]
1009 fn finds_src_index_ts_as_fallback() {
1010 let dir = tempfile::tempdir().expect("create temp dir");
1011 let src = dir.path().join("src");
1012 std::fs::create_dir_all(&src).unwrap();
1013 let index_path = src.join("index.ts");
1014 std::fs::write(&index_path, "export const a = 1;").unwrap();
1015
1016 let files = vec![DiscoveredFile {
1017 id: FileId(0),
1018 path: index_path.clone(),
1019 size_bytes: 20,
1020 }];
1021
1022 let entries = apply_default_fallback(&files, dir.path(), None);
1023 assert_eq!(entries.len(), 1);
1024 assert_eq!(entries[0].path, index_path);
1025 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1026 }
1027
1028 #[test]
1029 fn finds_root_index_js_as_fallback() {
1030 let dir = tempfile::tempdir().expect("create temp dir");
1031 let index_path = dir.path().join("index.js");
1032 std::fs::write(&index_path, "module.exports = {};").unwrap();
1033
1034 let files = vec![DiscoveredFile {
1035 id: FileId(0),
1036 path: index_path.clone(),
1037 size_bytes: 21,
1038 }];
1039
1040 let entries = apply_default_fallback(&files, dir.path(), None);
1041 assert_eq!(entries.len(), 1);
1042 assert_eq!(entries[0].path, index_path);
1043 }
1044
1045 #[test]
1046 fn returns_empty_when_no_index_file() {
1047 let dir = tempfile::tempdir().expect("create temp dir");
1048 let other_path = dir.path().join("src").join("utils.ts");
1049
1050 let files = vec![DiscoveredFile {
1051 id: FileId(0),
1052 path: other_path,
1053 size_bytes: 10,
1054 }];
1055
1056 let entries = apply_default_fallback(&files, dir.path(), None);
1057 assert!(
1058 entries.is_empty(),
1059 "non-index files should not match default fallback"
1060 );
1061 }
1062
1063 #[test]
1064 fn workspace_filter_restricts_scope() {
1065 let dir = tempfile::tempdir().expect("create temp dir");
1066 let ws_a = dir.path().join("packages").join("a").join("src");
1067 std::fs::create_dir_all(&ws_a).unwrap();
1068 let ws_b = dir.path().join("packages").join("b").join("src");
1069 std::fs::create_dir_all(&ws_b).unwrap();
1070
1071 let index_a = ws_a.join("index.ts");
1072 let index_b = ws_b.join("index.ts");
1073
1074 let files = vec![
1075 DiscoveredFile {
1076 id: FileId(0),
1077 path: index_a.clone(),
1078 size_bytes: 10,
1079 },
1080 DiscoveredFile {
1081 id: FileId(1),
1082 path: index_b,
1083 size_bytes: 10,
1084 },
1085 ];
1086
1087 let ws_root = dir.path().join("packages").join("a");
1089 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1090 assert_eq!(entries.len(), 1);
1091 assert_eq!(entries[0].path, index_a);
1092 }
1093 }
1094}