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::Glob::new(pattern) {
479 builder.add(glob);
480 let role = plugin_result
481 .entry_point_roles
482 .get(pname)
483 .copied()
484 .unwrap_or(EntryPointRole::Support);
485 glob_meta.push((pname, role));
486 }
487 }
488 for (pattern, pname) in plugin_result
489 .discovered_always_used
490 .iter()
491 .chain(plugin_result.always_used.iter())
492 .chain(plugin_result.fixture_patterns.iter())
493 {
494 if let Ok(glob) = globset::Glob::new(pattern) {
495 builder.add(glob);
496 glob_meta.push((pname, EntryPointRole::Support));
497 }
498 }
499 if let Ok(glob_set) = builder.build()
500 && !glob_set.is_empty()
501 {
502 for (idx, rel) in relative_paths.iter().enumerate() {
503 let matches = glob_set.matches(rel);
504 if !matches.is_empty() {
505 let (name, _) = glob_meta[matches[0]];
506 let entry = EntryPoint {
507 path: files[idx].path.clone(),
508 source: EntryPointSource::Plugin {
509 name: name.to_string(),
510 },
511 };
512
513 let mut has_runtime = false;
514 let mut has_test = false;
515 let mut has_support = false;
516 for match_idx in matches {
517 match glob_meta[match_idx].1 {
518 EntryPointRole::Runtime => has_runtime = true,
519 EntryPointRole::Test => has_test = true,
520 EntryPointRole::Support => has_support = true,
521 }
522 }
523
524 if has_runtime {
525 entries.push_runtime(entry.clone());
526 }
527 if has_test {
528 entries.push_test(entry.clone());
529 }
530 if has_support || (!has_runtime && !has_test) {
531 entries.push_support(entry);
532 }
533 }
534 }
535 }
536
537 for (setup_file, pname) in &plugin_result.setup_files {
539 let resolved = if setup_file.is_absolute() {
540 setup_file.clone()
541 } else {
542 config.root.join(setup_file)
543 };
544 if resolved.exists() {
545 entries.push_support(EntryPoint {
546 path: resolved,
547 source: EntryPointSource::Plugin {
548 name: pname.clone(),
549 },
550 });
551 } else {
552 for ext in SOURCE_EXTENSIONS {
554 let with_ext = resolved.with_extension(ext);
555 if with_ext.exists() {
556 entries.push_support(EntryPoint {
557 path: with_ext,
558 source: EntryPointSource::Plugin {
559 name: pname.clone(),
560 },
561 });
562 break;
563 }
564 }
565 }
566 }
567
568 entries.dedup()
569}
570
571#[must_use]
576pub fn discover_dynamically_loaded_entry_points(
577 config: &ResolvedConfig,
578 files: &[DiscoveredFile],
579) -> Vec<EntryPoint> {
580 if config.dynamically_loaded.is_empty() {
581 return Vec::new();
582 }
583
584 let mut builder = globset::GlobSetBuilder::new();
585 for pattern in &config.dynamically_loaded {
586 if let Ok(glob) = globset::Glob::new(pattern) {
587 builder.add(glob);
588 }
589 }
590 let Ok(glob_set) = builder.build() else {
591 return Vec::new();
592 };
593 if glob_set.is_empty() {
594 return Vec::new();
595 }
596
597 let mut entries = Vec::new();
598 for file in files {
599 let rel = file
600 .path
601 .strip_prefix(&config.root)
602 .unwrap_or(&file.path)
603 .to_string_lossy();
604 if glob_set.is_match(rel.as_ref()) {
605 entries.push(EntryPoint {
606 path: file.path.clone(),
607 source: EntryPointSource::DynamicallyLoaded,
608 });
609 }
610 }
611 entries
612}
613
614#[must_use]
616pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
617 if patterns.is_empty() {
618 return None;
619 }
620 let mut builder = globset::GlobSetBuilder::new();
621 for pattern in patterns {
622 if let Ok(glob) = globset::Glob::new(pattern) {
623 builder.add(glob);
624 }
625 }
626 builder.build().ok()
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
633 use fallow_types::discover::FileId;
634 use proptest::prelude::*;
635
636 proptest! {
637 #[test]
639 fn glob_patterns_never_panic_on_compile(
640 prefix in "[a-zA-Z0-9_]{1,20}",
641 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
642 ) {
643 let pattern = format!("**/{prefix}*.{ext}");
644 let result = globset::Glob::new(&pattern);
646 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
647 }
648
649 #[test]
651 fn non_source_extensions_not_in_list(
652 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
653 ) {
654 prop_assert!(
655 !SOURCE_EXTENSIONS.contains(&ext),
656 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
657 );
658 }
659
660 #[test]
662 fn compile_glob_set_no_panic(
663 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
664 ) {
665 let _ = compile_glob_set(&patterns);
667 }
668 }
669
670 #[test]
672 fn compile_glob_set_empty_input() {
673 assert!(
674 compile_glob_set(&[]).is_none(),
675 "empty patterns should return None"
676 );
677 }
678
679 #[test]
680 fn compile_glob_set_valid_patterns() {
681 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
682 let set = compile_glob_set(&patterns);
683 assert!(set.is_some(), "valid patterns should compile");
684 let set = set.unwrap();
685 assert!(set.is_match("src/foo.ts"));
686 assert!(set.is_match("src/bar.js"));
687 assert!(!set.is_match("src/bar.py"));
688 }
689
690 #[test]
691 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
692 let dir = tempfile::tempdir().expect("create temp dir");
693 let root = dir.path();
694 std::fs::create_dir_all(root.join("src")).unwrap();
695 std::fs::create_dir_all(root.join("tests")).unwrap();
696 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
697 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
698 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
699
700 let config = FallowConfig {
701 schema: None,
702 extends: vec![],
703 entry: vec![],
704 ignore_patterns: vec![],
705 framework: vec![],
706 workspaces: None,
707 ignore_dependencies: vec![],
708 ignore_exports: vec![],
709 duplicates: fallow_config::DuplicatesConfig::default(),
710 health: fallow_config::HealthConfig::default(),
711 rules: RulesConfig::default(),
712 boundaries: fallow_config::BoundaryConfig::default(),
713 production: false,
714 plugins: vec![],
715 dynamically_loaded: vec![],
716 overrides: vec![],
717 regression: None,
718 codeowners: None,
719 public_packages: vec![],
720 }
721 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
722
723 let files = vec![
724 DiscoveredFile {
725 id: FileId(0),
726 path: root.join("src/runtime.ts"),
727 size_bytes: 1,
728 },
729 DiscoveredFile {
730 id: FileId(1),
731 path: root.join("src/setup.ts"),
732 size_bytes: 1,
733 },
734 DiscoveredFile {
735 id: FileId(2),
736 path: root.join("tests/app.test.ts"),
737 size_bytes: 1,
738 },
739 ];
740
741 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
742 plugin_result
743 .entry_patterns
744 .push(("src/runtime.ts".to_string(), "runtime-plugin".to_string()));
745 plugin_result
746 .entry_patterns
747 .push(("tests/app.test.ts".to_string(), "test-plugin".to_string()));
748 plugin_result
749 .always_used
750 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
751 plugin_result
752 .entry_point_roles
753 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
754 plugin_result
755 .entry_point_roles
756 .insert("test-plugin".to_string(), EntryPointRole::Test);
757 plugin_result
758 .entry_point_roles
759 .insert("support-plugin".to_string(), EntryPointRole::Support);
760
761 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
762
763 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
764 assert!(
765 entries.runtime[0].path.ends_with("src/runtime.ts"),
766 "runtime entry should stay runtime-only"
767 );
768 assert_eq!(entries.test.len(), 1, "expected one test entry");
769 assert!(
770 entries.test[0].path.ends_with("tests/app.test.ts"),
771 "test entry should stay test-only"
772 );
773 assert_eq!(
774 entries.all.len(),
775 3,
776 "support entries should stay in all entries"
777 );
778 assert!(
779 entries
780 .all
781 .iter()
782 .any(|entry| entry.path.ends_with("src/setup.ts")),
783 "support entries should remain in the overall entry-point set"
784 );
785 assert!(
786 !entries
787 .runtime
788 .iter()
789 .any(|entry| entry.path.ends_with("src/setup.ts")),
790 "support entries should not bleed into runtime reachability"
791 );
792 assert!(
793 !entries
794 .test
795 .iter()
796 .any(|entry| entry.path.ends_with("src/setup.ts")),
797 "support entries should not bleed into test reachability"
798 );
799 }
800
801 mod resolve_entry_path_tests {
803 use super::*;
804
805 #[test]
806 fn resolves_existing_file() {
807 let dir = tempfile::tempdir().expect("create temp dir");
808 let src = dir.path().join("src");
809 std::fs::create_dir_all(&src).unwrap();
810 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
811
812 let canonical = dunce::canonicalize(dir.path()).unwrap();
813 let result = resolve_entry_path(
814 dir.path(),
815 "src/index.ts",
816 &canonical,
817 EntryPointSource::PackageJsonMain,
818 );
819 assert!(result.is_some(), "should resolve an existing file");
820 assert!(result.unwrap().path.ends_with("src/index.ts"));
821 }
822
823 #[test]
824 fn resolves_with_extension_fallback() {
825 let dir = tempfile::tempdir().expect("create temp dir");
826 let canonical = dunce::canonicalize(dir.path()).unwrap();
828 let src = canonical.join("src");
829 std::fs::create_dir_all(&src).unwrap();
830 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
831
832 let result = resolve_entry_path(
834 &canonical,
835 "src/index",
836 &canonical,
837 EntryPointSource::PackageJsonMain,
838 );
839 assert!(
840 result.is_some(),
841 "should resolve via extension fallback when exact path doesn't exist"
842 );
843 let ep = result.unwrap();
844 assert!(
845 ep.path.to_string_lossy().contains("index.ts"),
846 "should find index.ts via extension fallback"
847 );
848 }
849
850 #[test]
851 fn returns_none_for_nonexistent_file() {
852 let dir = tempfile::tempdir().expect("create temp dir");
853 let canonical = dunce::canonicalize(dir.path()).unwrap();
854 let result = resolve_entry_path(
855 dir.path(),
856 "does/not/exist.ts",
857 &canonical,
858 EntryPointSource::PackageJsonMain,
859 );
860 assert!(result.is_none(), "should return None for nonexistent files");
861 }
862
863 #[test]
864 fn maps_dist_output_to_src() {
865 let dir = tempfile::tempdir().expect("create temp dir");
866 let src = dir.path().join("src");
867 std::fs::create_dir_all(&src).unwrap();
868 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
869
870 let dist = dir.path().join("dist");
872 std::fs::create_dir_all(&dist).unwrap();
873 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
874
875 let canonical = dunce::canonicalize(dir.path()).unwrap();
876 let result = resolve_entry_path(
877 dir.path(),
878 "./dist/utils.js",
879 &canonical,
880 EntryPointSource::PackageJsonExports,
881 );
882 assert!(result.is_some(), "should resolve dist/ path to src/");
883 let ep = result.unwrap();
884 assert!(
885 ep.path
886 .to_string_lossy()
887 .replace('\\', "/")
888 .contains("src/utils.ts"),
889 "should map ./dist/utils.js to src/utils.ts"
890 );
891 }
892
893 #[test]
894 fn maps_build_output_to_src() {
895 let dir = tempfile::tempdir().expect("create temp dir");
896 let canonical = dunce::canonicalize(dir.path()).unwrap();
898 let src = canonical.join("src");
899 std::fs::create_dir_all(&src).unwrap();
900 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
901
902 let result = resolve_entry_path(
903 &canonical,
904 "./build/index.js",
905 &canonical,
906 EntryPointSource::PackageJsonExports,
907 );
908 assert!(result.is_some(), "should map build/ output to src/");
909 let ep = result.unwrap();
910 assert!(
911 ep.path
912 .to_string_lossy()
913 .replace('\\', "/")
914 .contains("src/index.tsx"),
915 "should map ./build/index.js to src/index.tsx"
916 );
917 }
918
919 #[test]
920 fn preserves_entry_point_source() {
921 let dir = tempfile::tempdir().expect("create temp dir");
922 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
923
924 let canonical = dunce::canonicalize(dir.path()).unwrap();
925 let result = resolve_entry_path(
926 dir.path(),
927 "index.ts",
928 &canonical,
929 EntryPointSource::PackageJsonScript,
930 );
931 assert!(result.is_some());
932 assert!(
933 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
934 "should preserve the source kind"
935 );
936 }
937 }
938
939 mod output_to_source_tests {
941 use super::*;
942
943 #[test]
944 fn maps_dist_to_src_with_ts_extension() {
945 let dir = tempfile::tempdir().expect("create temp dir");
946 let src = dir.path().join("src");
947 std::fs::create_dir_all(&src).unwrap();
948 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
949
950 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
951 assert!(result.is_some());
952 assert!(
953 result
954 .unwrap()
955 .to_string_lossy()
956 .replace('\\', "/")
957 .contains("src/utils.ts")
958 );
959 }
960
961 #[test]
962 fn returns_none_when_no_source_file_exists() {
963 let dir = tempfile::tempdir().expect("create temp dir");
964 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
966 assert!(result.is_none());
967 }
968
969 #[test]
970 fn ignores_non_output_directories() {
971 let dir = tempfile::tempdir().expect("create temp dir");
972 let src = dir.path().join("src");
973 std::fs::create_dir_all(&src).unwrap();
974 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
975
976 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
978 assert!(result.is_none());
979 }
980
981 #[test]
982 fn maps_nested_output_path_preserving_prefix() {
983 let dir = tempfile::tempdir().expect("create temp dir");
984 let modules_src = dir.path().join("modules").join("src");
985 std::fs::create_dir_all(&modules_src).unwrap();
986 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
987
988 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
989 assert!(result.is_some());
990 assert!(
991 result
992 .unwrap()
993 .to_string_lossy()
994 .replace('\\', "/")
995 .contains("modules/src/helper.ts")
996 );
997 }
998 }
999
1000 mod default_fallback_tests {
1002 use super::*;
1003
1004 #[test]
1005 fn finds_src_index_ts_as_fallback() {
1006 let dir = tempfile::tempdir().expect("create temp dir");
1007 let src = dir.path().join("src");
1008 std::fs::create_dir_all(&src).unwrap();
1009 let index_path = src.join("index.ts");
1010 std::fs::write(&index_path, "export const a = 1;").unwrap();
1011
1012 let files = vec![DiscoveredFile {
1013 id: FileId(0),
1014 path: index_path.clone(),
1015 size_bytes: 20,
1016 }];
1017
1018 let entries = apply_default_fallback(&files, dir.path(), None);
1019 assert_eq!(entries.len(), 1);
1020 assert_eq!(entries[0].path, index_path);
1021 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1022 }
1023
1024 #[test]
1025 fn finds_root_index_js_as_fallback() {
1026 let dir = tempfile::tempdir().expect("create temp dir");
1027 let index_path = dir.path().join("index.js");
1028 std::fs::write(&index_path, "module.exports = {};").unwrap();
1029
1030 let files = vec![DiscoveredFile {
1031 id: FileId(0),
1032 path: index_path.clone(),
1033 size_bytes: 21,
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 }
1040
1041 #[test]
1042 fn returns_empty_when_no_index_file() {
1043 let dir = tempfile::tempdir().expect("create temp dir");
1044 let other_path = dir.path().join("src").join("utils.ts");
1045
1046 let files = vec![DiscoveredFile {
1047 id: FileId(0),
1048 path: other_path,
1049 size_bytes: 10,
1050 }];
1051
1052 let entries = apply_default_fallback(&files, dir.path(), None);
1053 assert!(
1054 entries.is_empty(),
1055 "non-index files should not match default fallback"
1056 );
1057 }
1058
1059 #[test]
1060 fn workspace_filter_restricts_scope() {
1061 let dir = tempfile::tempdir().expect("create temp dir");
1062 let ws_a = dir.path().join("packages").join("a").join("src");
1063 std::fs::create_dir_all(&ws_a).unwrap();
1064 let ws_b = dir.path().join("packages").join("b").join("src");
1065 std::fs::create_dir_all(&ws_b).unwrap();
1066
1067 let index_a = ws_a.join("index.ts");
1068 let index_b = ws_b.join("index.ts");
1069
1070 let files = vec![
1071 DiscoveredFile {
1072 id: FileId(0),
1073 path: index_a.clone(),
1074 size_bytes: 10,
1075 },
1076 DiscoveredFile {
1077 id: FileId(1),
1078 path: index_b,
1079 size_bytes: 10,
1080 },
1081 ];
1082
1083 let ws_root = dir.path().join("packages").join("a");
1085 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1086 assert_eq!(entries.len(), 1);
1087 assert_eq!(entries[0].path, index_a);
1088 }
1089 }
1090}