Skip to main content

fallow_core/discover/
entry_points.rs

1use std::path::{Path, PathBuf};
2
3use super::parse_scripts::extract_script_file_refs;
4use super::walk::SOURCE_EXTENSIONS;
5use fallow_config::{EntryPointRole, PackageJson, ResolvedConfig};
6use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
7
8/// Known output directory names from exports maps.
9/// When an entry point path is inside one of these directories, we also try
10/// the `src/` equivalent to find the tracked source file.
11const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
12
13/// Entry points grouped by reachability role.
14#[derive(Debug, Clone, Default)]
15pub struct CategorizedEntryPoints {
16    pub all: Vec<EntryPoint>,
17    pub runtime: Vec<EntryPoint>,
18    pub test: Vec<EntryPoint>,
19}
20
21impl CategorizedEntryPoints {
22    pub fn push_runtime(&mut self, entry: EntryPoint) {
23        self.runtime.push(entry.clone());
24        self.all.push(entry);
25    }
26
27    pub fn push_test(&mut self, entry: EntryPoint) {
28        self.test.push(entry.clone());
29        self.all.push(entry);
30    }
31
32    pub fn push_support(&mut self, entry: EntryPoint) {
33        self.all.push(entry);
34    }
35
36    pub fn extend_runtime<I>(&mut self, entries: I)
37    where
38        I: IntoIterator<Item = EntryPoint>,
39    {
40        for entry in entries {
41            self.push_runtime(entry);
42        }
43    }
44
45    pub fn extend_test<I>(&mut self, entries: I)
46    where
47        I: IntoIterator<Item = EntryPoint>,
48    {
49        for entry in entries {
50            self.push_test(entry);
51        }
52    }
53
54    pub fn extend_support<I>(&mut self, entries: I)
55    where
56        I: IntoIterator<Item = EntryPoint>,
57    {
58        for entry in entries {
59            self.push_support(entry);
60        }
61    }
62
63    pub fn extend(&mut self, other: Self) {
64        self.all.extend(other.all);
65        self.runtime.extend(other.runtime);
66        self.test.extend(other.test);
67    }
68
69    #[must_use]
70    pub fn dedup(mut self) -> Self {
71        dedup_entry_paths(&mut self.all);
72        dedup_entry_paths(&mut self.runtime);
73        dedup_entry_paths(&mut self.test);
74        self
75    }
76}
77
78fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
79    entries.sort_by(|a, b| a.path.cmp(&b.path));
80    entries.dedup_by(|a, b| a.path == b.path);
81}
82
83/// Resolve a path relative to a base directory, with security check and extension fallback.
84///
85/// Returns `Some(EntryPoint)` if the path resolves to an existing file within `canonical_root`,
86/// trying source extensions as fallback when the exact path doesn't exist.
87/// Also handles exports map targets in output directories (e.g., `./dist/utils.js`)
88/// by trying to map back to the source file (e.g., `./src/utils.ts`).
89pub fn resolve_entry_path(
90    base: &Path,
91    entry: &str,
92    canonical_root: &Path,
93    source: EntryPointSource,
94) -> Option<EntryPoint> {
95    // Wildcard exports (e.g., `./src/themes/*.css`) can't be resolved to a single
96    // file. Return None and let the caller expand them separately.
97    if entry.contains('*') {
98        return None;
99    }
100
101    let resolved = base.join(entry);
102    // Security: ensure resolved path stays within the allowed root
103    let canonical_resolved = dunce::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
104    if !canonical_resolved.starts_with(canonical_root) {
105        tracing::warn!(path = %entry, "Skipping entry point outside project root");
106        return None;
107    }
108
109    // If the path is in an output directory (dist/, build/, etc.), try mapping to src/ first.
110    // This handles exports map targets like `./dist/utils.js` → `./src/utils.ts`.
111    // We check this BEFORE the exists() check because even if the dist file exists,
112    // fallow ignores dist/ by default, so we need the source file instead.
113    if let Some(source_path) = try_output_to_source_path(base, entry) {
114        // Security: ensure the mapped source path stays within the project root
115        if let Ok(canonical_source) = dunce::canonicalize(&source_path)
116            && canonical_source.starts_with(canonical_root)
117        {
118            return Some(EntryPoint {
119                path: source_path,
120                source,
121            });
122        }
123    }
124
125    // When the entry lives under an output directory but has no direct src/ mirror
126    // (e.g. `./dist/esm2022/index.js` where `src/esm2022/index.ts` does not exist),
127    // probe the package root for a conventional source index. TypeScript libraries
128    // commonly point `main`/`module`/`exports` at compiled output while keeping the
129    // canonical source entry at `src/index.ts`. Without this fallback, the dist file
130    // becomes the entry point, gets filtered out by the default dist ignore pattern,
131    // and leaves the entire src/ tree unreachable. See issue #102.
132    if is_entry_in_output_dir(entry)
133        && let Some(source_path) = try_source_index_fallback(base)
134        && let Ok(canonical_source) = dunce::canonicalize(&source_path)
135        && canonical_source.starts_with(canonical_root)
136    {
137        tracing::info!(
138            entry = %entry,
139            fallback = %source_path.display(),
140            "package.json entry resolves to an ignored output directory; falling back to source index"
141        );
142        return Some(EntryPoint {
143            path: source_path,
144            source,
145        });
146    }
147
148    if resolved.exists() {
149        return Some(EntryPoint {
150            path: resolved,
151            source,
152        });
153    }
154    // Try with source extensions
155    for ext in SOURCE_EXTENSIONS {
156        let with_ext = resolved.with_extension(ext);
157        if with_ext.exists() {
158            return Some(EntryPoint {
159                path: with_ext,
160                source,
161            });
162        }
163    }
164    None
165}
166
167/// Try to map an entry path from an output directory to its source equivalent.
168///
169/// Given `base=/project/packages/ui` and `entry=./dist/utils.js`, this tries:
170/// - `/project/packages/ui/src/utils.ts`
171/// - `/project/packages/ui/src/utils.tsx`
172/// - etc. for all source extensions
173///
174/// Preserves any path prefix between the package root and the output dir,
175/// e.g. `./modules/dist/utils.js` → `base/modules/src/utils.ts`.
176///
177/// Returns `Some(path)` if a source file is found.
178fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
179    let entry_path = Path::new(entry);
180    let components: Vec<_> = entry_path.components().collect();
181
182    // Find the last output directory component in the entry path
183    let output_pos = components.iter().rposition(|c| {
184        if let std::path::Component::Normal(s) = c
185            && let Some(name) = s.to_str()
186        {
187            return OUTPUT_DIRS.contains(&name);
188        }
189        false
190    })?;
191
192    // Build the relative prefix before the output dir, filtering out CurDir (".")
193    let prefix: PathBuf = components[..output_pos]
194        .iter()
195        .filter(|c| !matches!(c, std::path::Component::CurDir))
196        .collect();
197
198    // Build the relative path after the output dir (e.g., "utils.js")
199    let suffix: PathBuf = components[output_pos + 1..].iter().collect();
200
201    // Try base + prefix + "src" + suffix-with-source-extension
202    for ext in SOURCE_EXTENSIONS {
203        let source_candidate = base
204            .join(&prefix)
205            .join("src")
206            .join(suffix.with_extension(ext));
207        if source_candidate.exists() {
208            return Some(source_candidate);
209        }
210    }
211
212    None
213}
214
215/// Conventional source index file stems probed when a package.json entry lives
216/// in an ignored output directory. Ordered by preference.
217const SOURCE_INDEX_FALLBACK_STEMS: &[&str] = &["src/index", "src/main", "index", "main"];
218
219/// Return `true` when `entry` contains a known output directory component.
220///
221/// Matches any segment in `OUTPUT_DIRS`, e.g. `./dist/esm2022/index.js` →
222/// `true`, `src/main.ts` → `false`.
223fn is_entry_in_output_dir(entry: &str) -> bool {
224    Path::new(entry).components().any(|c| {
225        matches!(
226            c,
227            std::path::Component::Normal(s)
228                if s.to_str().is_some_and(|name| OUTPUT_DIRS.contains(&name))
229        )
230    })
231}
232
233/// Probe a package root for a conventional source index file.
234///
235/// Used when `package.json` points at compiled output but the canonical source
236/// entry is a standard TypeScript/JavaScript index file. Tries `src/index`,
237/// `src/main`, `index`, and `main` with each supported source extension, in
238/// that order.
239fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
240    for stem in SOURCE_INDEX_FALLBACK_STEMS {
241        for ext in SOURCE_EXTENSIONS {
242            let candidate = base.join(format!("{stem}.{ext}"));
243            if candidate.is_file() {
244                return Some(candidate);
245            }
246        }
247    }
248    None
249}
250
251/// Default index patterns used when no other entry points are found.
252const DEFAULT_INDEX_PATTERNS: &[&str] = &[
253    "src/index.{ts,tsx,js,jsx}",
254    "src/main.{ts,tsx,js,jsx}",
255    "index.{ts,tsx,js,jsx}",
256    "main.{ts,tsx,js,jsx}",
257];
258
259/// Fall back to default index patterns if no entries were found.
260///
261/// When `ws_filter` is `Some`, only files whose path starts with the given
262/// workspace root are considered (used for workspace-scoped discovery).
263fn apply_default_fallback(
264    files: &[DiscoveredFile],
265    root: &Path,
266    ws_filter: Option<&Path>,
267) -> Vec<EntryPoint> {
268    let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
269        .iter()
270        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
271        .collect();
272
273    let mut entries = Vec::new();
274    for file in files {
275        // Use strip_prefix instead of canonicalize for workspace filtering
276        if let Some(ws_root) = ws_filter
277            && file.path.strip_prefix(ws_root).is_err()
278        {
279            continue;
280        }
281        let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
282        let relative_str = relative.to_string_lossy();
283        if default_matchers
284            .iter()
285            .any(|m| m.is_match(relative_str.as_ref()))
286        {
287            entries.push(EntryPoint {
288                path: file.path.clone(),
289                source: EntryPointSource::DefaultIndex,
290            });
291        }
292    }
293    entries
294}
295
296/// Discover entry points from package.json, framework rules, and defaults.
297pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
298    let _span = tracing::info_span!("discover_entry_points").entered();
299    let mut entries = Vec::new();
300
301    // Pre-compute relative paths for all files (once, not per pattern)
302    let relative_paths: Vec<String> = files
303        .iter()
304        .map(|f| {
305            f.path
306                .strip_prefix(&config.root)
307                .unwrap_or(&f.path)
308                .to_string_lossy()
309                .into_owned()
310        })
311        .collect();
312
313    // 1. Manual entries from config — batch all patterns into a single GlobSet
314    // for O(files) matching instead of O(patterns × files).
315    {
316        let mut builder = globset::GlobSetBuilder::new();
317        for pattern in &config.entry_patterns {
318            if let Ok(glob) = globset::Glob::new(pattern) {
319                builder.add(glob);
320            }
321        }
322        if let Ok(glob_set) = builder.build()
323            && !glob_set.is_empty()
324        {
325            for (idx, rel) in relative_paths.iter().enumerate() {
326                if glob_set.is_match(rel) {
327                    entries.push(EntryPoint {
328                        path: files[idx].path.clone(),
329                        source: EntryPointSource::ManualEntry,
330                    });
331                }
332            }
333        }
334    }
335
336    // 2. Package.json entries
337    // Pre-compute canonical root once for all resolve_entry_path calls
338    let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
339    let pkg_path = config.root.join("package.json");
340    let root_pkg = PackageJson::load(&pkg_path).ok();
341    if let Some(pkg) = &root_pkg {
342        for entry_path in pkg.entry_points() {
343            if let Some(ep) = resolve_entry_path(
344                &config.root,
345                &entry_path,
346                &canonical_root,
347                EntryPointSource::PackageJsonMain,
348            ) {
349                entries.push(ep);
350            }
351        }
352
353        // 2b. Package.json scripts — extract file references as entry points
354        if let Some(scripts) = &pkg.scripts {
355            for script_value in scripts.values() {
356                for file_ref in extract_script_file_refs(script_value) {
357                    if let Some(ep) = resolve_entry_path(
358                        &config.root,
359                        &file_ref,
360                        &canonical_root,
361                        EntryPointSource::PackageJsonScript,
362                    ) {
363                        entries.push(ep);
364                    }
365                }
366            }
367        }
368
369        // Framework rules now flow through PluginRegistry via external_plugins.
370    }
371
372    // 4. Auto-discover nested package.json entry points
373    // For monorepo-like structures without explicit workspace config, scan for
374    // package.json files in subdirectories and use their main/exports as entries.
375    let exports_dirs = root_pkg
376        .map(|pkg| pkg.exports_subdirectories())
377        .unwrap_or_default();
378    discover_nested_package_entries(
379        &config.root,
380        files,
381        &mut entries,
382        &canonical_root,
383        &exports_dirs,
384    );
385
386    // 5. Default index files (if no other entries found)
387    if entries.is_empty() {
388        entries = apply_default_fallback(files, &config.root, None);
389    }
390
391    // Deduplicate by path
392    entries.sort_by(|a, b| a.path.cmp(&b.path));
393    entries.dedup_by(|a, b| a.path == b.path);
394
395    entries
396}
397
398/// Discover entry points from nested package.json files in subdirectories.
399///
400/// Scans two sources for sub-packages:
401/// 1. Common monorepo directory patterns (`packages/`, `apps/`, `libs/`, etc.)
402/// 2. Directories derived from the root package.json `exports` map keys
403///    (e.g., `"./compat": {...}` implies `compat/` may be a sub-package)
404///
405/// For each discovered sub-package with a `package.json`, the `main`, `module`,
406/// `source`, `exports`, and `bin` fields are treated as entry points.
407fn discover_nested_package_entries(
408    root: &Path,
409    _files: &[DiscoveredFile],
410    entries: &mut Vec<EntryPoint>,
411    canonical_root: &Path,
412    exports_subdirectories: &[String],
413) {
414    let mut visited = rustc_hash::FxHashSet::default();
415
416    // 1. Walk common monorepo patterns
417    let search_dirs = [
418        "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
419    ];
420    for dir_name in &search_dirs {
421        let search_dir = root.join(dir_name);
422        if !search_dir.is_dir() {
423            continue;
424        }
425        let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
426            continue;
427        };
428        for entry in read_dir.flatten() {
429            let pkg_dir = entry.path();
430            if visited.insert(pkg_dir.clone()) {
431                collect_nested_package_entries(&pkg_dir, entries, canonical_root);
432            }
433        }
434    }
435
436    // 2. Scan directories derived from the root exports map
437    for dir_name in exports_subdirectories {
438        let pkg_dir = root.join(dir_name);
439        if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
440            collect_nested_package_entries(&pkg_dir, entries, canonical_root);
441        }
442    }
443}
444
445/// Collect entry points from a single sub-package directory.
446fn collect_nested_package_entries(
447    pkg_dir: &Path,
448    entries: &mut Vec<EntryPoint>,
449    canonical_root: &Path,
450) {
451    let pkg_path = pkg_dir.join("package.json");
452    if !pkg_path.exists() {
453        return;
454    }
455    let Ok(pkg) = PackageJson::load(&pkg_path) else {
456        return;
457    };
458    for entry_path in pkg.entry_points() {
459        if entry_path.contains('*') {
460            expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
461        } else if let Some(ep) = resolve_entry_path(
462            pkg_dir,
463            &entry_path,
464            canonical_root,
465            EntryPointSource::PackageJsonExports,
466        ) {
467            entries.push(ep);
468        }
469    }
470    if let Some(scripts) = &pkg.scripts {
471        for script_value in scripts.values() {
472            for file_ref in extract_script_file_refs(script_value) {
473                if let Some(ep) = resolve_entry_path(
474                    pkg_dir,
475                    &file_ref,
476                    canonical_root,
477                    EntryPointSource::PackageJsonScript,
478                ) {
479                    entries.push(ep);
480                }
481            }
482        }
483    }
484}
485
486/// Expand wildcard subpath exports to matching files on disk.
487///
488/// Handles patterns like `./src/themes/*.css` from package.json exports maps
489/// (`"./themes/*": { "import": "./src/themes/*.css" }`). Expands the `*` to
490/// match actual files in the target directory.
491fn expand_wildcard_entries(
492    base: &Path,
493    pattern: &str,
494    canonical_root: &Path,
495    entries: &mut Vec<EntryPoint>,
496) {
497    let full_pattern = base.join(pattern).to_string_lossy().to_string();
498    let Ok(matches) = glob::glob(&full_pattern) else {
499        return;
500    };
501    for path_result in matches {
502        let Ok(path) = path_result else {
503            continue;
504        };
505        if let Ok(canonical) = dunce::canonicalize(&path)
506            && canonical.starts_with(canonical_root)
507        {
508            entries.push(EntryPoint {
509                path,
510                source: EntryPointSource::PackageJsonExports,
511            });
512        }
513    }
514}
515
516/// Discover entry points for a workspace package.
517#[must_use]
518pub fn discover_workspace_entry_points(
519    ws_root: &Path,
520    _config: &ResolvedConfig,
521    all_files: &[DiscoveredFile],
522) -> Vec<EntryPoint> {
523    let mut entries = Vec::new();
524
525    let pkg_path = ws_root.join("package.json");
526    if let Ok(pkg) = PackageJson::load(&pkg_path) {
527        let canonical_ws_root =
528            dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
529        for entry_path in pkg.entry_points() {
530            if entry_path.contains('*') {
531                expand_wildcard_entries(ws_root, &entry_path, &canonical_ws_root, &mut entries);
532            } else if let Some(ep) = resolve_entry_path(
533                ws_root,
534                &entry_path,
535                &canonical_ws_root,
536                EntryPointSource::PackageJsonMain,
537            ) {
538                entries.push(ep);
539            }
540        }
541
542        // Scripts field — extract file references as entry points
543        if let Some(scripts) = &pkg.scripts {
544            for script_value in scripts.values() {
545                for file_ref in extract_script_file_refs(script_value) {
546                    if let Some(ep) = resolve_entry_path(
547                        ws_root,
548                        &file_ref,
549                        &canonical_ws_root,
550                        EntryPointSource::PackageJsonScript,
551                    ) {
552                        entries.push(ep);
553                    }
554                }
555            }
556        }
557
558        // Framework rules now flow through PluginRegistry via external_plugins.
559    }
560
561    // Fall back to default index files if no entry points found for this workspace
562    if entries.is_empty() {
563        entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
564    }
565
566    entries.sort_by(|a, b| a.path.cmp(&b.path));
567    entries.dedup_by(|a, b| a.path == b.path);
568    entries
569}
570
571/// Discover entry points from plugin results (dynamic config parsing).
572///
573/// Converts plugin-discovered patterns and setup files into concrete entry points
574/// by matching them against the discovered file list.
575#[must_use]
576pub fn discover_plugin_entry_points(
577    plugin_result: &crate::plugins::AggregatedPluginResult,
578    config: &ResolvedConfig,
579    files: &[DiscoveredFile],
580) -> Vec<EntryPoint> {
581    discover_plugin_entry_point_sets(plugin_result, config, files).all
582}
583
584/// Discover plugin-derived entry points with runtime/test/support roles preserved.
585#[must_use]
586pub fn discover_plugin_entry_point_sets(
587    plugin_result: &crate::plugins::AggregatedPluginResult,
588    config: &ResolvedConfig,
589    files: &[DiscoveredFile],
590) -> CategorizedEntryPoints {
591    let mut entries = CategorizedEntryPoints::default();
592
593    // Pre-compute relative paths
594    let relative_paths: Vec<String> = files
595        .iter()
596        .map(|f| {
597            f.path
598                .strip_prefix(&config.root)
599                .unwrap_or(&f.path)
600                .to_string_lossy()
601                .into_owned()
602        })
603        .collect();
604
605    // Match plugin entry patterns against files using a single GlobSet for
606    // include globs, then filter candidate matches through any exclusions.
607    let mut builder = globset::GlobSetBuilder::new();
608    let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
609    for (rule, pname) in &plugin_result.entry_patterns {
610        if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
611            builder.add(include);
612            glob_meta.push(compiled);
613        }
614    }
615    for (pattern, pname) in plugin_result
616        .discovered_always_used
617        .iter()
618        .chain(plugin_result.always_used.iter())
619        .chain(plugin_result.fixture_patterns.iter())
620    {
621        if let Ok(glob) = globset::GlobBuilder::new(pattern)
622            .literal_separator(true)
623            .build()
624        {
625            builder.add(glob);
626            if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
627                &crate::plugins::PathRule::new(pattern.clone()),
628                "support entry pattern",
629            ) {
630                glob_meta.push(CompiledEntryRule {
631                    path,
632                    plugin_name: pname,
633                    role: EntryPointRole::Support,
634                });
635            }
636        }
637    }
638    if let Ok(glob_set) = builder.build()
639        && !glob_set.is_empty()
640    {
641        for (idx, rel) in relative_paths.iter().enumerate() {
642            let matches: Vec<usize> = glob_set
643                .matches(rel)
644                .into_iter()
645                .filter(|match_idx| glob_meta[*match_idx].matches(rel))
646                .collect();
647            if !matches.is_empty() {
648                let name = glob_meta[matches[0]].plugin_name;
649                let entry = EntryPoint {
650                    path: files[idx].path.clone(),
651                    source: EntryPointSource::Plugin {
652                        name: name.to_string(),
653                    },
654                };
655
656                let mut has_runtime = false;
657                let mut has_test = false;
658                let mut has_support = false;
659                for match_idx in matches {
660                    match glob_meta[match_idx].role {
661                        EntryPointRole::Runtime => has_runtime = true,
662                        EntryPointRole::Test => has_test = true,
663                        EntryPointRole::Support => has_support = true,
664                    }
665                }
666
667                if has_runtime {
668                    entries.push_runtime(entry.clone());
669                }
670                if has_test {
671                    entries.push_test(entry.clone());
672                }
673                if has_support || (!has_runtime && !has_test) {
674                    entries.push_support(entry);
675                }
676            }
677        }
678    }
679
680    // Add setup files (absolute paths from plugin config parsing)
681    for (setup_file, pname) in &plugin_result.setup_files {
682        let resolved = if setup_file.is_absolute() {
683            setup_file.clone()
684        } else {
685            config.root.join(setup_file)
686        };
687        if resolved.exists() {
688            entries.push_support(EntryPoint {
689                path: resolved,
690                source: EntryPointSource::Plugin {
691                    name: pname.clone(),
692                },
693            });
694        } else {
695            // Try with extensions
696            for ext in SOURCE_EXTENSIONS {
697                let with_ext = resolved.with_extension(ext);
698                if with_ext.exists() {
699                    entries.push_support(EntryPoint {
700                        path: with_ext,
701                        source: EntryPointSource::Plugin {
702                            name: pname.clone(),
703                        },
704                    });
705                    break;
706                }
707            }
708        }
709    }
710
711    entries.dedup()
712}
713
714/// Discover entry points from `dynamicallyLoaded` config patterns.
715///
716/// Matches the configured glob patterns against the discovered file list and
717/// marks matching files as entry points so they are never flagged as unused.
718#[must_use]
719pub fn discover_dynamically_loaded_entry_points(
720    config: &ResolvedConfig,
721    files: &[DiscoveredFile],
722) -> Vec<EntryPoint> {
723    if config.dynamically_loaded.is_empty() {
724        return Vec::new();
725    }
726
727    let mut builder = globset::GlobSetBuilder::new();
728    for pattern in &config.dynamically_loaded {
729        if let Ok(glob) = globset::Glob::new(pattern) {
730            builder.add(glob);
731        }
732    }
733    let Ok(glob_set) = builder.build() else {
734        return Vec::new();
735    };
736    if glob_set.is_empty() {
737        return Vec::new();
738    }
739
740    let mut entries = Vec::new();
741    for file in files {
742        let rel = file
743            .path
744            .strip_prefix(&config.root)
745            .unwrap_or(&file.path)
746            .to_string_lossy();
747        if glob_set.is_match(rel.as_ref()) {
748            entries.push(EntryPoint {
749                path: file.path.clone(),
750                source: EntryPointSource::DynamicallyLoaded,
751            });
752        }
753    }
754    entries
755}
756
757struct CompiledEntryRule<'a> {
758    path: crate::plugins::CompiledPathRule,
759    plugin_name: &'a str,
760    role: EntryPointRole,
761}
762
763impl CompiledEntryRule<'_> {
764    fn matches(&self, path: &str) -> bool {
765        self.path.matches(path)
766    }
767}
768
769fn compile_entry_rule<'a>(
770    rule: &'a crate::plugins::PathRule,
771    plugin_name: &'a str,
772    plugin_result: &'a crate::plugins::AggregatedPluginResult,
773) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
774    let include = match globset::GlobBuilder::new(&rule.pattern)
775        .literal_separator(true)
776        .build()
777    {
778        Ok(glob) => glob,
779        Err(err) => {
780            tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
781            return None;
782        }
783    };
784    let role = plugin_result
785        .entry_point_roles
786        .get(plugin_name)
787        .copied()
788        .unwrap_or(EntryPointRole::Support);
789    Some((
790        include,
791        CompiledEntryRule {
792            path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
793            plugin_name,
794            role,
795        },
796    ))
797}
798
799/// Pre-compile a set of glob patterns for efficient matching against many paths.
800#[must_use]
801pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
802    if patterns.is_empty() {
803        return None;
804    }
805    let mut builder = globset::GlobSetBuilder::new();
806    for pattern in patterns {
807        if let Ok(glob) = globset::GlobBuilder::new(pattern)
808            .literal_separator(true)
809            .build()
810        {
811            builder.add(glob);
812        }
813    }
814    builder.build().ok()
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820    use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
821    use fallow_types::discover::FileId;
822    use proptest::prelude::*;
823
824    proptest! {
825        /// Valid glob patterns should never panic when compiled via globset.
826        #[test]
827        fn glob_patterns_never_panic_on_compile(
828            prefix in "[a-zA-Z0-9_]{1,20}",
829            ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
830        ) {
831            let pattern = format!("**/{prefix}*.{ext}");
832            // Should not panic — either compiles or returns Err gracefully
833            let result = globset::Glob::new(&pattern);
834            prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
835        }
836
837        /// Non-source extensions should NOT be in the SOURCE_EXTENSIONS list.
838        #[test]
839        fn non_source_extensions_not_in_list(
840            ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
841        ) {
842            prop_assert!(
843                !SOURCE_EXTENSIONS.contains(&ext),
844                "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
845            );
846        }
847
848        /// compile_glob_set should never panic on arbitrary well-formed glob patterns.
849        #[test]
850        fn compile_glob_set_no_panic(
851            patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
852        ) {
853            // Should not panic regardless of input
854            let _ = compile_glob_set(&patterns);
855        }
856    }
857
858    // compile_glob_set unit tests
859    #[test]
860    fn compile_glob_set_empty_input() {
861        assert!(
862            compile_glob_set(&[]).is_none(),
863            "empty patterns should return None"
864        );
865    }
866
867    #[test]
868    fn compile_glob_set_valid_patterns() {
869        let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
870        let set = compile_glob_set(&patterns);
871        assert!(set.is_some(), "valid patterns should compile");
872        let set = set.unwrap();
873        assert!(set.is_match("src/foo.ts"));
874        assert!(set.is_match("src/bar.js"));
875        assert!(!set.is_match("src/bar.py"));
876    }
877
878    #[test]
879    fn compile_glob_set_keeps_star_within_a_single_path_segment() {
880        let patterns = vec!["composables/*.{ts,js}".to_string()];
881        let set = compile_glob_set(&patterns).expect("pattern should compile");
882
883        assert!(set.is_match("composables/useFoo.ts"));
884        assert!(!set.is_match("composables/nested/useFoo.ts"));
885    }
886
887    #[test]
888    fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
889        let dir = tempfile::tempdir().expect("create temp dir");
890        let root = dir.path();
891        std::fs::create_dir_all(root.join("src")).unwrap();
892        std::fs::create_dir_all(root.join("tests")).unwrap();
893        std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
894        std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
895        std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
896
897        let config = FallowConfig {
898            schema: None,
899            extends: vec![],
900            entry: vec![],
901            ignore_patterns: vec![],
902            framework: vec![],
903            workspaces: None,
904            ignore_dependencies: vec![],
905            ignore_exports: vec![],
906            used_class_members: vec![],
907            duplicates: fallow_config::DuplicatesConfig::default(),
908            health: fallow_config::HealthConfig::default(),
909            rules: RulesConfig::default(),
910            boundaries: fallow_config::BoundaryConfig::default(),
911            production: false,
912            plugins: vec![],
913            dynamically_loaded: vec![],
914            overrides: vec![],
915            regression: None,
916            audit: fallow_config::AuditConfig::default(),
917            codeowners: None,
918            public_packages: vec![],
919            flags: fallow_config::FlagsConfig::default(),
920            sealed: false,
921        }
922        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
923
924        let files = vec![
925            DiscoveredFile {
926                id: FileId(0),
927                path: root.join("src/runtime.ts"),
928                size_bytes: 1,
929            },
930            DiscoveredFile {
931                id: FileId(1),
932                path: root.join("src/setup.ts"),
933                size_bytes: 1,
934            },
935            DiscoveredFile {
936                id: FileId(2),
937                path: root.join("tests/app.test.ts"),
938                size_bytes: 1,
939            },
940        ];
941
942        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
943        plugin_result.entry_patterns.push((
944            crate::plugins::PathRule::new("src/runtime.ts"),
945            "runtime-plugin".to_string(),
946        ));
947        plugin_result.entry_patterns.push((
948            crate::plugins::PathRule::new("tests/app.test.ts"),
949            "test-plugin".to_string(),
950        ));
951        plugin_result
952            .always_used
953            .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
954        plugin_result
955            .entry_point_roles
956            .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
957        plugin_result
958            .entry_point_roles
959            .insert("test-plugin".to_string(), EntryPointRole::Test);
960        plugin_result
961            .entry_point_roles
962            .insert("support-plugin".to_string(), EntryPointRole::Support);
963
964        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
965
966        assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
967        assert!(
968            entries.runtime[0].path.ends_with("src/runtime.ts"),
969            "runtime entry should stay runtime-only"
970        );
971        assert_eq!(entries.test.len(), 1, "expected one test entry");
972        assert!(
973            entries.test[0].path.ends_with("tests/app.test.ts"),
974            "test entry should stay test-only"
975        );
976        assert_eq!(
977            entries.all.len(),
978            3,
979            "support entries should stay in all entries"
980        );
981        assert!(
982            entries
983                .all
984                .iter()
985                .any(|entry| entry.path.ends_with("src/setup.ts")),
986            "support entries should remain in the overall entry-point set"
987        );
988        assert!(
989            !entries
990                .runtime
991                .iter()
992                .any(|entry| entry.path.ends_with("src/setup.ts")),
993            "support entries should not bleed into runtime reachability"
994        );
995        assert!(
996            !entries
997                .test
998                .iter()
999                .any(|entry| entry.path.ends_with("src/setup.ts")),
1000            "support entries should not bleed into test reachability"
1001        );
1002    }
1003
1004    #[test]
1005    fn plugin_entry_point_rules_respect_exclusions() {
1006        let dir = tempfile::tempdir().expect("create temp dir");
1007        let root = dir.path();
1008        std::fs::create_dir_all(root.join("app/pages")).unwrap();
1009        std::fs::write(
1010            root.join("app/pages/index.tsx"),
1011            "export default function Page() { return null; }",
1012        )
1013        .unwrap();
1014        std::fs::write(
1015            root.join("app/pages/-helper.ts"),
1016            "export const helper = 1;",
1017        )
1018        .unwrap();
1019
1020        let config = FallowConfig {
1021            schema: None,
1022            extends: vec![],
1023            entry: vec![],
1024            ignore_patterns: vec![],
1025            framework: vec![],
1026            workspaces: None,
1027            ignore_dependencies: vec![],
1028            ignore_exports: vec![],
1029            used_class_members: vec![],
1030            duplicates: fallow_config::DuplicatesConfig::default(),
1031            health: fallow_config::HealthConfig::default(),
1032            rules: RulesConfig::default(),
1033            boundaries: fallow_config::BoundaryConfig::default(),
1034            production: false,
1035            plugins: vec![],
1036            dynamically_loaded: vec![],
1037            overrides: vec![],
1038            regression: None,
1039            audit: fallow_config::AuditConfig::default(),
1040            codeowners: None,
1041            public_packages: vec![],
1042            flags: fallow_config::FlagsConfig::default(),
1043            sealed: false,
1044        }
1045        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
1046
1047        let files = vec![
1048            DiscoveredFile {
1049                id: FileId(0),
1050                path: root.join("app/pages/index.tsx"),
1051                size_bytes: 1,
1052            },
1053            DiscoveredFile {
1054                id: FileId(1),
1055                path: root.join("app/pages/-helper.ts"),
1056                size_bytes: 1,
1057            },
1058        ];
1059
1060        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1061        plugin_result.entry_patterns.push((
1062            crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1063                .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1064            "tanstack-router".to_string(),
1065        ));
1066        plugin_result
1067            .entry_point_roles
1068            .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1069
1070        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1071        let entry_paths: Vec<_> = entries
1072            .all
1073            .iter()
1074            .map(|entry| {
1075                entry
1076                    .path
1077                    .strip_prefix(root)
1078                    .unwrap()
1079                    .to_string_lossy()
1080                    .into_owned()
1081            })
1082            .collect();
1083
1084        assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1085        assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1086    }
1087
1088    // resolve_entry_path unit tests
1089    mod resolve_entry_path_tests {
1090        use super::*;
1091
1092        #[test]
1093        fn resolves_existing_file() {
1094            let dir = tempfile::tempdir().expect("create temp dir");
1095            let src = dir.path().join("src");
1096            std::fs::create_dir_all(&src).unwrap();
1097            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1098
1099            let canonical = dunce::canonicalize(dir.path()).unwrap();
1100            let result = resolve_entry_path(
1101                dir.path(),
1102                "src/index.ts",
1103                &canonical,
1104                EntryPointSource::PackageJsonMain,
1105            );
1106            assert!(result.is_some(), "should resolve an existing file");
1107            assert!(result.unwrap().path.ends_with("src/index.ts"));
1108        }
1109
1110        #[test]
1111        fn resolves_with_extension_fallback() {
1112            let dir = tempfile::tempdir().expect("create temp dir");
1113            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
1114            let canonical = dunce::canonicalize(dir.path()).unwrap();
1115            let src = canonical.join("src");
1116            std::fs::create_dir_all(&src).unwrap();
1117            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1118
1119            // Provide path without extension — should try adding .ts, .tsx, etc.
1120            let result = resolve_entry_path(
1121                &canonical,
1122                "src/index",
1123                &canonical,
1124                EntryPointSource::PackageJsonMain,
1125            );
1126            assert!(
1127                result.is_some(),
1128                "should resolve via extension fallback when exact path doesn't exist"
1129            );
1130            let ep = result.unwrap();
1131            assert!(
1132                ep.path.to_string_lossy().contains("index.ts"),
1133                "should find index.ts via extension fallback"
1134            );
1135        }
1136
1137        #[test]
1138        fn returns_none_for_nonexistent_file() {
1139            let dir = tempfile::tempdir().expect("create temp dir");
1140            let canonical = dunce::canonicalize(dir.path()).unwrap();
1141            let result = resolve_entry_path(
1142                dir.path(),
1143                "does/not/exist.ts",
1144                &canonical,
1145                EntryPointSource::PackageJsonMain,
1146            );
1147            assert!(result.is_none(), "should return None for nonexistent files");
1148        }
1149
1150        #[test]
1151        fn maps_dist_output_to_src() {
1152            let dir = tempfile::tempdir().expect("create temp dir");
1153            let src = dir.path().join("src");
1154            std::fs::create_dir_all(&src).unwrap();
1155            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1156
1157            // Also create the dist/ file to make sure it prefers src/
1158            let dist = dir.path().join("dist");
1159            std::fs::create_dir_all(&dist).unwrap();
1160            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1161
1162            let canonical = dunce::canonicalize(dir.path()).unwrap();
1163            let result = resolve_entry_path(
1164                dir.path(),
1165                "./dist/utils.js",
1166                &canonical,
1167                EntryPointSource::PackageJsonExports,
1168            );
1169            assert!(result.is_some(), "should resolve dist/ path to src/");
1170            let ep = result.unwrap();
1171            assert!(
1172                ep.path
1173                    .to_string_lossy()
1174                    .replace('\\', "/")
1175                    .contains("src/utils.ts"),
1176                "should map ./dist/utils.js to src/utils.ts"
1177            );
1178        }
1179
1180        #[test]
1181        fn maps_build_output_to_src() {
1182            let dir = tempfile::tempdir().expect("create temp dir");
1183            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
1184            let canonical = dunce::canonicalize(dir.path()).unwrap();
1185            let src = canonical.join("src");
1186            std::fs::create_dir_all(&src).unwrap();
1187            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1188
1189            let result = resolve_entry_path(
1190                &canonical,
1191                "./build/index.js",
1192                &canonical,
1193                EntryPointSource::PackageJsonExports,
1194            );
1195            assert!(result.is_some(), "should map build/ output to src/");
1196            let ep = result.unwrap();
1197            assert!(
1198                ep.path
1199                    .to_string_lossy()
1200                    .replace('\\', "/")
1201                    .contains("src/index.tsx"),
1202                "should map ./build/index.js to src/index.tsx"
1203            );
1204        }
1205
1206        #[test]
1207        fn preserves_entry_point_source() {
1208            let dir = tempfile::tempdir().expect("create temp dir");
1209            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1210
1211            let canonical = dunce::canonicalize(dir.path()).unwrap();
1212            let result = resolve_entry_path(
1213                dir.path(),
1214                "index.ts",
1215                &canonical,
1216                EntryPointSource::PackageJsonScript,
1217            );
1218            assert!(result.is_some());
1219            assert!(
1220                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1221                "should preserve the source kind"
1222            );
1223        }
1224    }
1225
1226    // try_output_to_source_path unit tests
1227    mod output_to_source_tests {
1228        use super::*;
1229
1230        #[test]
1231        fn maps_dist_to_src_with_ts_extension() {
1232            let dir = tempfile::tempdir().expect("create temp dir");
1233            let src = dir.path().join("src");
1234            std::fs::create_dir_all(&src).unwrap();
1235            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1236
1237            let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1238            assert!(result.is_some());
1239            assert!(
1240                result
1241                    .unwrap()
1242                    .to_string_lossy()
1243                    .replace('\\', "/")
1244                    .contains("src/utils.ts")
1245            );
1246        }
1247
1248        #[test]
1249        fn returns_none_when_no_source_file_exists() {
1250            let dir = tempfile::tempdir().expect("create temp dir");
1251            // No src/ directory at all
1252            let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1253            assert!(result.is_none());
1254        }
1255
1256        #[test]
1257        fn ignores_non_output_directories() {
1258            let dir = tempfile::tempdir().expect("create temp dir");
1259            let src = dir.path().join("src");
1260            std::fs::create_dir_all(&src).unwrap();
1261            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1262
1263            // "lib" is not in OUTPUT_DIRS, so no mapping should occur
1264            let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1265            assert!(result.is_none());
1266        }
1267
1268        #[test]
1269        fn maps_nested_output_path_preserving_prefix() {
1270            let dir = tempfile::tempdir().expect("create temp dir");
1271            let modules_src = dir.path().join("modules").join("src");
1272            std::fs::create_dir_all(&modules_src).unwrap();
1273            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1274
1275            let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1276            assert!(result.is_some());
1277            assert!(
1278                result
1279                    .unwrap()
1280                    .to_string_lossy()
1281                    .replace('\\', "/")
1282                    .contains("modules/src/helper.ts")
1283            );
1284        }
1285    }
1286
1287    // Source index fallback unit tests (issue #102)
1288    mod source_index_fallback_tests {
1289        use super::*;
1290
1291        #[test]
1292        fn detects_dist_entry_in_output_dir() {
1293            assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1294            assert!(is_entry_in_output_dir("dist/index.js"));
1295            assert!(is_entry_in_output_dir("./build/index.js"));
1296            assert!(is_entry_in_output_dir("./out/main.js"));
1297            assert!(is_entry_in_output_dir("./esm/index.js"));
1298            assert!(is_entry_in_output_dir("./cjs/index.js"));
1299        }
1300
1301        #[test]
1302        fn rejects_non_output_entry_paths() {
1303            assert!(!is_entry_in_output_dir("./src/index.ts"));
1304            assert!(!is_entry_in_output_dir("src/main.ts"));
1305            assert!(!is_entry_in_output_dir("./index.js"));
1306            assert!(!is_entry_in_output_dir(""));
1307        }
1308
1309        #[test]
1310        fn rejects_substring_match_for_output_dir() {
1311            // "distro" contains "dist" as a substring but is not an output dir
1312            assert!(!is_entry_in_output_dir("./distro/index.js"));
1313            assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1314        }
1315
1316        #[test]
1317        fn finds_src_index_ts() {
1318            let dir = tempfile::tempdir().expect("create temp dir");
1319            let src = dir.path().join("src");
1320            std::fs::create_dir_all(&src).unwrap();
1321            let index_path = src.join("index.ts");
1322            std::fs::write(&index_path, "export const a = 1;").unwrap();
1323
1324            let result = try_source_index_fallback(dir.path());
1325            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1326        }
1327
1328        #[test]
1329        fn finds_src_index_tsx_when_ts_missing() {
1330            let dir = tempfile::tempdir().expect("create temp dir");
1331            let src = dir.path().join("src");
1332            std::fs::create_dir_all(&src).unwrap();
1333            let index_path = src.join("index.tsx");
1334            std::fs::write(&index_path, "export default 1;").unwrap();
1335
1336            let result = try_source_index_fallback(dir.path());
1337            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1338        }
1339
1340        #[test]
1341        fn prefers_src_index_over_root_index() {
1342            // Source index fallback must prefer `src/index.*` over root-level `index.*`
1343            // because library conventions keep source under `src/`.
1344            let dir = tempfile::tempdir().expect("create temp dir");
1345            let src = dir.path().join("src");
1346            std::fs::create_dir_all(&src).unwrap();
1347            let src_index = src.join("index.ts");
1348            std::fs::write(&src_index, "export const a = 1;").unwrap();
1349            let root_index = dir.path().join("index.ts");
1350            std::fs::write(&root_index, "export const b = 2;").unwrap();
1351
1352            let result = try_source_index_fallback(dir.path());
1353            assert_eq!(result.as_deref(), Some(src_index.as_path()));
1354        }
1355
1356        #[test]
1357        fn falls_back_to_src_main() {
1358            let dir = tempfile::tempdir().expect("create temp dir");
1359            let src = dir.path().join("src");
1360            std::fs::create_dir_all(&src).unwrap();
1361            let main_path = src.join("main.ts");
1362            std::fs::write(&main_path, "export const a = 1;").unwrap();
1363
1364            let result = try_source_index_fallback(dir.path());
1365            assert_eq!(result.as_deref(), Some(main_path.as_path()));
1366        }
1367
1368        #[test]
1369        fn falls_back_to_root_index_when_no_src() {
1370            let dir = tempfile::tempdir().expect("create temp dir");
1371            let index_path = dir.path().join("index.js");
1372            std::fs::write(&index_path, "module.exports = {};").unwrap();
1373
1374            let result = try_source_index_fallback(dir.path());
1375            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1376        }
1377
1378        #[test]
1379        fn returns_none_when_nothing_matches() {
1380            let dir = tempfile::tempdir().expect("create temp dir");
1381            let result = try_source_index_fallback(dir.path());
1382            assert!(result.is_none());
1383        }
1384
1385        #[test]
1386        fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1387            let dir = tempfile::tempdir().expect("create temp dir");
1388            let canonical = dunce::canonicalize(dir.path()).unwrap();
1389
1390            // dist/esm2022/index.js exists but there's no src/esm2022/ mirror —
1391            // only src/index.ts. Without the fallback, resolve_entry_path would
1392            // return the dist file, which then gets filtered out by the ignore
1393            // pattern.
1394            let dist_dir = canonical.join("dist").join("esm2022");
1395            std::fs::create_dir_all(&dist_dir).unwrap();
1396            std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1397
1398            let src = canonical.join("src");
1399            std::fs::create_dir_all(&src).unwrap();
1400            let src_index = src.join("index.ts");
1401            std::fs::write(&src_index, "export const x = 1;").unwrap();
1402
1403            let result = resolve_entry_path(
1404                &canonical,
1405                "./dist/esm2022/index.js",
1406                &canonical,
1407                EntryPointSource::PackageJsonMain,
1408            );
1409            assert!(result.is_some());
1410            let entry = result.unwrap();
1411            assert_eq!(entry.path, src_index);
1412        }
1413
1414        #[test]
1415        fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1416            // When `src/esm2022/index.ts` exists, the existing mirror logic wins
1417            // and the fallback should not fire.
1418            let dir = tempfile::tempdir().expect("create temp dir");
1419            let canonical = dunce::canonicalize(dir.path()).unwrap();
1420
1421            let src_mirror = canonical.join("src").join("esm2022");
1422            std::fs::create_dir_all(&src_mirror).unwrap();
1423            let mirror_index = src_mirror.join("index.ts");
1424            std::fs::write(&mirror_index, "export const x = 1;").unwrap();
1425
1426            // Also create src/index.ts to confirm the mirror wins over the fallback.
1427            let src_index = canonical.join("src").join("index.ts");
1428            std::fs::write(&src_index, "export const y = 2;").unwrap();
1429
1430            let result = resolve_entry_path(
1431                &canonical,
1432                "./dist/esm2022/index.js",
1433                &canonical,
1434                EntryPointSource::PackageJsonMain,
1435            );
1436            assert_eq!(result.map(|e| e.path), Some(mirror_index));
1437        }
1438    }
1439
1440    // apply_default_fallback unit tests
1441    mod default_fallback_tests {
1442        use super::*;
1443
1444        #[test]
1445        fn finds_src_index_ts_as_fallback() {
1446            let dir = tempfile::tempdir().expect("create temp dir");
1447            let src = dir.path().join("src");
1448            std::fs::create_dir_all(&src).unwrap();
1449            let index_path = src.join("index.ts");
1450            std::fs::write(&index_path, "export const a = 1;").unwrap();
1451
1452            let files = vec![DiscoveredFile {
1453                id: FileId(0),
1454                path: index_path.clone(),
1455                size_bytes: 20,
1456            }];
1457
1458            let entries = apply_default_fallback(&files, dir.path(), None);
1459            assert_eq!(entries.len(), 1);
1460            assert_eq!(entries[0].path, index_path);
1461            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1462        }
1463
1464        #[test]
1465        fn finds_root_index_js_as_fallback() {
1466            let dir = tempfile::tempdir().expect("create temp dir");
1467            let index_path = dir.path().join("index.js");
1468            std::fs::write(&index_path, "module.exports = {};").unwrap();
1469
1470            let files = vec![DiscoveredFile {
1471                id: FileId(0),
1472                path: index_path.clone(),
1473                size_bytes: 21,
1474            }];
1475
1476            let entries = apply_default_fallback(&files, dir.path(), None);
1477            assert_eq!(entries.len(), 1);
1478            assert_eq!(entries[0].path, index_path);
1479        }
1480
1481        #[test]
1482        fn returns_empty_when_no_index_file() {
1483            let dir = tempfile::tempdir().expect("create temp dir");
1484            let other_path = dir.path().join("src").join("utils.ts");
1485
1486            let files = vec![DiscoveredFile {
1487                id: FileId(0),
1488                path: other_path,
1489                size_bytes: 10,
1490            }];
1491
1492            let entries = apply_default_fallback(&files, dir.path(), None);
1493            assert!(
1494                entries.is_empty(),
1495                "non-index files should not match default fallback"
1496            );
1497        }
1498
1499        #[test]
1500        fn workspace_filter_restricts_scope() {
1501            let dir = tempfile::tempdir().expect("create temp dir");
1502            let ws_a = dir.path().join("packages").join("a").join("src");
1503            std::fs::create_dir_all(&ws_a).unwrap();
1504            let ws_b = dir.path().join("packages").join("b").join("src");
1505            std::fs::create_dir_all(&ws_b).unwrap();
1506
1507            let index_a = ws_a.join("index.ts");
1508            let index_b = ws_b.join("index.ts");
1509
1510            let files = vec![
1511                DiscoveredFile {
1512                    id: FileId(0),
1513                    path: index_a.clone(),
1514                    size_bytes: 10,
1515                },
1516                DiscoveredFile {
1517                    id: FileId(1),
1518                    path: index_b,
1519                    size_bytes: 10,
1520                },
1521            ];
1522
1523            // Filter to workspace A only
1524            let ws_root = dir.path().join("packages").join("a");
1525            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1526            assert_eq!(entries.len(), 1);
1527            assert_eq!(entries[0].path, index_a);
1528        }
1529    }
1530
1531    // expand_wildcard_entries unit tests
1532    mod wildcard_entry_tests {
1533        use super::*;
1534
1535        #[test]
1536        fn expands_wildcard_css_entries() {
1537            // Wildcard subpath exports like `"./themes/*": { "import": "./src/themes/*.css" }`
1538            // should expand to actual CSS files on disk.
1539            let dir = tempfile::tempdir().expect("create temp dir");
1540            let themes = dir.path().join("src").join("themes");
1541            std::fs::create_dir_all(&themes).unwrap();
1542            std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
1543            std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
1544
1545            let canonical = dunce::canonicalize(dir.path()).unwrap();
1546            let mut entries = Vec::new();
1547            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1548
1549            assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
1550            let paths: Vec<String> = entries
1551                .iter()
1552                .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
1553                .collect();
1554            assert!(paths.contains(&"dark.css".to_string()));
1555            assert!(paths.contains(&"light.css".to_string()));
1556            assert!(
1557                entries
1558                    .iter()
1559                    .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
1560            );
1561        }
1562
1563        #[test]
1564        fn wildcard_does_not_match_nonexistent_files() {
1565            let dir = tempfile::tempdir().expect("create temp dir");
1566            // No files matching the pattern
1567            std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
1568
1569            let canonical = dunce::canonicalize(dir.path()).unwrap();
1570            let mut entries = Vec::new();
1571            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1572
1573            assert!(
1574                entries.is_empty(),
1575                "should return empty when no files match the wildcard"
1576            );
1577        }
1578
1579        #[test]
1580        fn wildcard_only_matches_specified_extension() {
1581            // Wildcard pattern `*.css` should not match `.ts` files
1582            let dir = tempfile::tempdir().expect("create temp dir");
1583            let themes = dir.path().join("src").join("themes");
1584            std::fs::create_dir_all(&themes).unwrap();
1585            std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
1586            std::fs::write(themes.join("index.ts"), "export {};").unwrap();
1587
1588            let canonical = dunce::canonicalize(dir.path()).unwrap();
1589            let mut entries = Vec::new();
1590            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1591
1592            assert_eq!(entries.len(), 1, "should only match CSS files");
1593            assert!(
1594                entries[0]
1595                    .path
1596                    .file_name()
1597                    .unwrap()
1598                    .to_string_lossy()
1599                    .ends_with(".css")
1600            );
1601        }
1602    }
1603}