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            codeowners: None,
917            public_packages: vec![],
918            flags: fallow_config::FlagsConfig::default(),
919        }
920        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
921
922        let files = vec![
923            DiscoveredFile {
924                id: FileId(0),
925                path: root.join("src/runtime.ts"),
926                size_bytes: 1,
927            },
928            DiscoveredFile {
929                id: FileId(1),
930                path: root.join("src/setup.ts"),
931                size_bytes: 1,
932            },
933            DiscoveredFile {
934                id: FileId(2),
935                path: root.join("tests/app.test.ts"),
936                size_bytes: 1,
937            },
938        ];
939
940        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
941        plugin_result.entry_patterns.push((
942            crate::plugins::PathRule::new("src/runtime.ts"),
943            "runtime-plugin".to_string(),
944        ));
945        plugin_result.entry_patterns.push((
946            crate::plugins::PathRule::new("tests/app.test.ts"),
947            "test-plugin".to_string(),
948        ));
949        plugin_result
950            .always_used
951            .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
952        plugin_result
953            .entry_point_roles
954            .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
955        plugin_result
956            .entry_point_roles
957            .insert("test-plugin".to_string(), EntryPointRole::Test);
958        plugin_result
959            .entry_point_roles
960            .insert("support-plugin".to_string(), EntryPointRole::Support);
961
962        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
963
964        assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
965        assert!(
966            entries.runtime[0].path.ends_with("src/runtime.ts"),
967            "runtime entry should stay runtime-only"
968        );
969        assert_eq!(entries.test.len(), 1, "expected one test entry");
970        assert!(
971            entries.test[0].path.ends_with("tests/app.test.ts"),
972            "test entry should stay test-only"
973        );
974        assert_eq!(
975            entries.all.len(),
976            3,
977            "support entries should stay in all entries"
978        );
979        assert!(
980            entries
981                .all
982                .iter()
983                .any(|entry| entry.path.ends_with("src/setup.ts")),
984            "support entries should remain in the overall entry-point set"
985        );
986        assert!(
987            !entries
988                .runtime
989                .iter()
990                .any(|entry| entry.path.ends_with("src/setup.ts")),
991            "support entries should not bleed into runtime reachability"
992        );
993        assert!(
994            !entries
995                .test
996                .iter()
997                .any(|entry| entry.path.ends_with("src/setup.ts")),
998            "support entries should not bleed into test reachability"
999        );
1000    }
1001
1002    #[test]
1003    fn plugin_entry_point_rules_respect_exclusions() {
1004        let dir = tempfile::tempdir().expect("create temp dir");
1005        let root = dir.path();
1006        std::fs::create_dir_all(root.join("app/pages")).unwrap();
1007        std::fs::write(
1008            root.join("app/pages/index.tsx"),
1009            "export default function Page() { return null; }",
1010        )
1011        .unwrap();
1012        std::fs::write(
1013            root.join("app/pages/-helper.ts"),
1014            "export const helper = 1;",
1015        )
1016        .unwrap();
1017
1018        let config = FallowConfig {
1019            schema: None,
1020            extends: vec![],
1021            entry: vec![],
1022            ignore_patterns: vec![],
1023            framework: vec![],
1024            workspaces: None,
1025            ignore_dependencies: vec![],
1026            ignore_exports: vec![],
1027            used_class_members: vec![],
1028            duplicates: fallow_config::DuplicatesConfig::default(),
1029            health: fallow_config::HealthConfig::default(),
1030            rules: RulesConfig::default(),
1031            boundaries: fallow_config::BoundaryConfig::default(),
1032            production: false,
1033            plugins: vec![],
1034            dynamically_loaded: vec![],
1035            overrides: vec![],
1036            regression: None,
1037            codeowners: None,
1038            public_packages: vec![],
1039            flags: fallow_config::FlagsConfig::default(),
1040        }
1041        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
1042
1043        let files = vec![
1044            DiscoveredFile {
1045                id: FileId(0),
1046                path: root.join("app/pages/index.tsx"),
1047                size_bytes: 1,
1048            },
1049            DiscoveredFile {
1050                id: FileId(1),
1051                path: root.join("app/pages/-helper.ts"),
1052                size_bytes: 1,
1053            },
1054        ];
1055
1056        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1057        plugin_result.entry_patterns.push((
1058            crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1059                .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1060            "tanstack-router".to_string(),
1061        ));
1062        plugin_result
1063            .entry_point_roles
1064            .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1065
1066        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1067        let entry_paths: Vec<_> = entries
1068            .all
1069            .iter()
1070            .map(|entry| {
1071                entry
1072                    .path
1073                    .strip_prefix(root)
1074                    .unwrap()
1075                    .to_string_lossy()
1076                    .into_owned()
1077            })
1078            .collect();
1079
1080        assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1081        assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1082    }
1083
1084    // resolve_entry_path unit tests
1085    mod resolve_entry_path_tests {
1086        use super::*;
1087
1088        #[test]
1089        fn resolves_existing_file() {
1090            let dir = tempfile::tempdir().expect("create temp dir");
1091            let src = dir.path().join("src");
1092            std::fs::create_dir_all(&src).unwrap();
1093            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1094
1095            let canonical = dunce::canonicalize(dir.path()).unwrap();
1096            let result = resolve_entry_path(
1097                dir.path(),
1098                "src/index.ts",
1099                &canonical,
1100                EntryPointSource::PackageJsonMain,
1101            );
1102            assert!(result.is_some(), "should resolve an existing file");
1103            assert!(result.unwrap().path.ends_with("src/index.ts"));
1104        }
1105
1106        #[test]
1107        fn resolves_with_extension_fallback() {
1108            let dir = tempfile::tempdir().expect("create temp dir");
1109            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
1110            let canonical = dunce::canonicalize(dir.path()).unwrap();
1111            let src = canonical.join("src");
1112            std::fs::create_dir_all(&src).unwrap();
1113            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1114
1115            // Provide path without extension — should try adding .ts, .tsx, etc.
1116            let result = resolve_entry_path(
1117                &canonical,
1118                "src/index",
1119                &canonical,
1120                EntryPointSource::PackageJsonMain,
1121            );
1122            assert!(
1123                result.is_some(),
1124                "should resolve via extension fallback when exact path doesn't exist"
1125            );
1126            let ep = result.unwrap();
1127            assert!(
1128                ep.path.to_string_lossy().contains("index.ts"),
1129                "should find index.ts via extension fallback"
1130            );
1131        }
1132
1133        #[test]
1134        fn returns_none_for_nonexistent_file() {
1135            let dir = tempfile::tempdir().expect("create temp dir");
1136            let canonical = dunce::canonicalize(dir.path()).unwrap();
1137            let result = resolve_entry_path(
1138                dir.path(),
1139                "does/not/exist.ts",
1140                &canonical,
1141                EntryPointSource::PackageJsonMain,
1142            );
1143            assert!(result.is_none(), "should return None for nonexistent files");
1144        }
1145
1146        #[test]
1147        fn maps_dist_output_to_src() {
1148            let dir = tempfile::tempdir().expect("create temp dir");
1149            let src = dir.path().join("src");
1150            std::fs::create_dir_all(&src).unwrap();
1151            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1152
1153            // Also create the dist/ file to make sure it prefers src/
1154            let dist = dir.path().join("dist");
1155            std::fs::create_dir_all(&dist).unwrap();
1156            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1157
1158            let canonical = dunce::canonicalize(dir.path()).unwrap();
1159            let result = resolve_entry_path(
1160                dir.path(),
1161                "./dist/utils.js",
1162                &canonical,
1163                EntryPointSource::PackageJsonExports,
1164            );
1165            assert!(result.is_some(), "should resolve dist/ path to src/");
1166            let ep = result.unwrap();
1167            assert!(
1168                ep.path
1169                    .to_string_lossy()
1170                    .replace('\\', "/")
1171                    .contains("src/utils.ts"),
1172                "should map ./dist/utils.js to src/utils.ts"
1173            );
1174        }
1175
1176        #[test]
1177        fn maps_build_output_to_src() {
1178            let dir = tempfile::tempdir().expect("create temp dir");
1179            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
1180            let canonical = dunce::canonicalize(dir.path()).unwrap();
1181            let src = canonical.join("src");
1182            std::fs::create_dir_all(&src).unwrap();
1183            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1184
1185            let result = resolve_entry_path(
1186                &canonical,
1187                "./build/index.js",
1188                &canonical,
1189                EntryPointSource::PackageJsonExports,
1190            );
1191            assert!(result.is_some(), "should map build/ output to src/");
1192            let ep = result.unwrap();
1193            assert!(
1194                ep.path
1195                    .to_string_lossy()
1196                    .replace('\\', "/")
1197                    .contains("src/index.tsx"),
1198                "should map ./build/index.js to src/index.tsx"
1199            );
1200        }
1201
1202        #[test]
1203        fn preserves_entry_point_source() {
1204            let dir = tempfile::tempdir().expect("create temp dir");
1205            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1206
1207            let canonical = dunce::canonicalize(dir.path()).unwrap();
1208            let result = resolve_entry_path(
1209                dir.path(),
1210                "index.ts",
1211                &canonical,
1212                EntryPointSource::PackageJsonScript,
1213            );
1214            assert!(result.is_some());
1215            assert!(
1216                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1217                "should preserve the source kind"
1218            );
1219        }
1220    }
1221
1222    // try_output_to_source_path unit tests
1223    mod output_to_source_tests {
1224        use super::*;
1225
1226        #[test]
1227        fn maps_dist_to_src_with_ts_extension() {
1228            let dir = tempfile::tempdir().expect("create temp dir");
1229            let src = dir.path().join("src");
1230            std::fs::create_dir_all(&src).unwrap();
1231            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1232
1233            let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1234            assert!(result.is_some());
1235            assert!(
1236                result
1237                    .unwrap()
1238                    .to_string_lossy()
1239                    .replace('\\', "/")
1240                    .contains("src/utils.ts")
1241            );
1242        }
1243
1244        #[test]
1245        fn returns_none_when_no_source_file_exists() {
1246            let dir = tempfile::tempdir().expect("create temp dir");
1247            // No src/ directory at all
1248            let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1249            assert!(result.is_none());
1250        }
1251
1252        #[test]
1253        fn ignores_non_output_directories() {
1254            let dir = tempfile::tempdir().expect("create temp dir");
1255            let src = dir.path().join("src");
1256            std::fs::create_dir_all(&src).unwrap();
1257            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1258
1259            // "lib" is not in OUTPUT_DIRS, so no mapping should occur
1260            let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1261            assert!(result.is_none());
1262        }
1263
1264        #[test]
1265        fn maps_nested_output_path_preserving_prefix() {
1266            let dir = tempfile::tempdir().expect("create temp dir");
1267            let modules_src = dir.path().join("modules").join("src");
1268            std::fs::create_dir_all(&modules_src).unwrap();
1269            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1270
1271            let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1272            assert!(result.is_some());
1273            assert!(
1274                result
1275                    .unwrap()
1276                    .to_string_lossy()
1277                    .replace('\\', "/")
1278                    .contains("modules/src/helper.ts")
1279            );
1280        }
1281    }
1282
1283    // Source index fallback unit tests (issue #102)
1284    mod source_index_fallback_tests {
1285        use super::*;
1286
1287        #[test]
1288        fn detects_dist_entry_in_output_dir() {
1289            assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1290            assert!(is_entry_in_output_dir("dist/index.js"));
1291            assert!(is_entry_in_output_dir("./build/index.js"));
1292            assert!(is_entry_in_output_dir("./out/main.js"));
1293            assert!(is_entry_in_output_dir("./esm/index.js"));
1294            assert!(is_entry_in_output_dir("./cjs/index.js"));
1295        }
1296
1297        #[test]
1298        fn rejects_non_output_entry_paths() {
1299            assert!(!is_entry_in_output_dir("./src/index.ts"));
1300            assert!(!is_entry_in_output_dir("src/main.ts"));
1301            assert!(!is_entry_in_output_dir("./index.js"));
1302            assert!(!is_entry_in_output_dir(""));
1303        }
1304
1305        #[test]
1306        fn rejects_substring_match_for_output_dir() {
1307            // "distro" contains "dist" as a substring but is not an output dir
1308            assert!(!is_entry_in_output_dir("./distro/index.js"));
1309            assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1310        }
1311
1312        #[test]
1313        fn finds_src_index_ts() {
1314            let dir = tempfile::tempdir().expect("create temp dir");
1315            let src = dir.path().join("src");
1316            std::fs::create_dir_all(&src).unwrap();
1317            let index_path = src.join("index.ts");
1318            std::fs::write(&index_path, "export const a = 1;").unwrap();
1319
1320            let result = try_source_index_fallback(dir.path());
1321            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1322        }
1323
1324        #[test]
1325        fn finds_src_index_tsx_when_ts_missing() {
1326            let dir = tempfile::tempdir().expect("create temp dir");
1327            let src = dir.path().join("src");
1328            std::fs::create_dir_all(&src).unwrap();
1329            let index_path = src.join("index.tsx");
1330            std::fs::write(&index_path, "export default 1;").unwrap();
1331
1332            let result = try_source_index_fallback(dir.path());
1333            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1334        }
1335
1336        #[test]
1337        fn prefers_src_index_over_root_index() {
1338            // Source index fallback must prefer `src/index.*` over root-level `index.*`
1339            // because library conventions keep source under `src/`.
1340            let dir = tempfile::tempdir().expect("create temp dir");
1341            let src = dir.path().join("src");
1342            std::fs::create_dir_all(&src).unwrap();
1343            let src_index = src.join("index.ts");
1344            std::fs::write(&src_index, "export const a = 1;").unwrap();
1345            let root_index = dir.path().join("index.ts");
1346            std::fs::write(&root_index, "export const b = 2;").unwrap();
1347
1348            let result = try_source_index_fallback(dir.path());
1349            assert_eq!(result.as_deref(), Some(src_index.as_path()));
1350        }
1351
1352        #[test]
1353        fn falls_back_to_src_main() {
1354            let dir = tempfile::tempdir().expect("create temp dir");
1355            let src = dir.path().join("src");
1356            std::fs::create_dir_all(&src).unwrap();
1357            let main_path = src.join("main.ts");
1358            std::fs::write(&main_path, "export const a = 1;").unwrap();
1359
1360            let result = try_source_index_fallback(dir.path());
1361            assert_eq!(result.as_deref(), Some(main_path.as_path()));
1362        }
1363
1364        #[test]
1365        fn falls_back_to_root_index_when_no_src() {
1366            let dir = tempfile::tempdir().expect("create temp dir");
1367            let index_path = dir.path().join("index.js");
1368            std::fs::write(&index_path, "module.exports = {};").unwrap();
1369
1370            let result = try_source_index_fallback(dir.path());
1371            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1372        }
1373
1374        #[test]
1375        fn returns_none_when_nothing_matches() {
1376            let dir = tempfile::tempdir().expect("create temp dir");
1377            let result = try_source_index_fallback(dir.path());
1378            assert!(result.is_none());
1379        }
1380
1381        #[test]
1382        fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1383            let dir = tempfile::tempdir().expect("create temp dir");
1384            let canonical = dunce::canonicalize(dir.path()).unwrap();
1385
1386            // dist/esm2022/index.js exists but there's no src/esm2022/ mirror —
1387            // only src/index.ts. Without the fallback, resolve_entry_path would
1388            // return the dist file, which then gets filtered out by the ignore
1389            // pattern.
1390            let dist_dir = canonical.join("dist").join("esm2022");
1391            std::fs::create_dir_all(&dist_dir).unwrap();
1392            std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1393
1394            let src = canonical.join("src");
1395            std::fs::create_dir_all(&src).unwrap();
1396            let src_index = src.join("index.ts");
1397            std::fs::write(&src_index, "export const x = 1;").unwrap();
1398
1399            let result = resolve_entry_path(
1400                &canonical,
1401                "./dist/esm2022/index.js",
1402                &canonical,
1403                EntryPointSource::PackageJsonMain,
1404            );
1405            assert!(result.is_some());
1406            let entry = result.unwrap();
1407            assert_eq!(entry.path, src_index);
1408        }
1409
1410        #[test]
1411        fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1412            // When `src/esm2022/index.ts` exists, the existing mirror logic wins
1413            // and the fallback should not fire.
1414            let dir = tempfile::tempdir().expect("create temp dir");
1415            let canonical = dunce::canonicalize(dir.path()).unwrap();
1416
1417            let src_mirror = canonical.join("src").join("esm2022");
1418            std::fs::create_dir_all(&src_mirror).unwrap();
1419            let mirror_index = src_mirror.join("index.ts");
1420            std::fs::write(&mirror_index, "export const x = 1;").unwrap();
1421
1422            // Also create src/index.ts to confirm the mirror wins over the fallback.
1423            let src_index = canonical.join("src").join("index.ts");
1424            std::fs::write(&src_index, "export const y = 2;").unwrap();
1425
1426            let result = resolve_entry_path(
1427                &canonical,
1428                "./dist/esm2022/index.js",
1429                &canonical,
1430                EntryPointSource::PackageJsonMain,
1431            );
1432            assert_eq!(result.map(|e| e.path), Some(mirror_index));
1433        }
1434    }
1435
1436    // apply_default_fallback unit tests
1437    mod default_fallback_tests {
1438        use super::*;
1439
1440        #[test]
1441        fn finds_src_index_ts_as_fallback() {
1442            let dir = tempfile::tempdir().expect("create temp dir");
1443            let src = dir.path().join("src");
1444            std::fs::create_dir_all(&src).unwrap();
1445            let index_path = src.join("index.ts");
1446            std::fs::write(&index_path, "export const a = 1;").unwrap();
1447
1448            let files = vec![DiscoveredFile {
1449                id: FileId(0),
1450                path: index_path.clone(),
1451                size_bytes: 20,
1452            }];
1453
1454            let entries = apply_default_fallback(&files, dir.path(), None);
1455            assert_eq!(entries.len(), 1);
1456            assert_eq!(entries[0].path, index_path);
1457            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1458        }
1459
1460        #[test]
1461        fn finds_root_index_js_as_fallback() {
1462            let dir = tempfile::tempdir().expect("create temp dir");
1463            let index_path = dir.path().join("index.js");
1464            std::fs::write(&index_path, "module.exports = {};").unwrap();
1465
1466            let files = vec![DiscoveredFile {
1467                id: FileId(0),
1468                path: index_path.clone(),
1469                size_bytes: 21,
1470            }];
1471
1472            let entries = apply_default_fallback(&files, dir.path(), None);
1473            assert_eq!(entries.len(), 1);
1474            assert_eq!(entries[0].path, index_path);
1475        }
1476
1477        #[test]
1478        fn returns_empty_when_no_index_file() {
1479            let dir = tempfile::tempdir().expect("create temp dir");
1480            let other_path = dir.path().join("src").join("utils.ts");
1481
1482            let files = vec![DiscoveredFile {
1483                id: FileId(0),
1484                path: other_path,
1485                size_bytes: 10,
1486            }];
1487
1488            let entries = apply_default_fallback(&files, dir.path(), None);
1489            assert!(
1490                entries.is_empty(),
1491                "non-index files should not match default fallback"
1492            );
1493        }
1494
1495        #[test]
1496        fn workspace_filter_restricts_scope() {
1497            let dir = tempfile::tempdir().expect("create temp dir");
1498            let ws_a = dir.path().join("packages").join("a").join("src");
1499            std::fs::create_dir_all(&ws_a).unwrap();
1500            let ws_b = dir.path().join("packages").join("b").join("src");
1501            std::fs::create_dir_all(&ws_b).unwrap();
1502
1503            let index_a = ws_a.join("index.ts");
1504            let index_b = ws_b.join("index.ts");
1505
1506            let files = vec![
1507                DiscoveredFile {
1508                    id: FileId(0),
1509                    path: index_a.clone(),
1510                    size_bytes: 10,
1511                },
1512                DiscoveredFile {
1513                    id: FileId(1),
1514                    path: index_b,
1515                    size_bytes: 10,
1516                },
1517            ];
1518
1519            // Filter to workspace A only
1520            let ws_root = dir.path().join("packages").join("a");
1521            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1522            assert_eq!(entries.len(), 1);
1523            assert_eq!(entries[0].path, index_a);
1524        }
1525    }
1526
1527    // expand_wildcard_entries unit tests
1528    mod wildcard_entry_tests {
1529        use super::*;
1530
1531        #[test]
1532        fn expands_wildcard_css_entries() {
1533            // Wildcard subpath exports like `"./themes/*": { "import": "./src/themes/*.css" }`
1534            // should expand to actual CSS files on disk.
1535            let dir = tempfile::tempdir().expect("create temp dir");
1536            let themes = dir.path().join("src").join("themes");
1537            std::fs::create_dir_all(&themes).unwrap();
1538            std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
1539            std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
1540
1541            let canonical = dunce::canonicalize(dir.path()).unwrap();
1542            let mut entries = Vec::new();
1543            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1544
1545            assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
1546            let paths: Vec<String> = entries
1547                .iter()
1548                .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
1549                .collect();
1550            assert!(paths.contains(&"dark.css".to_string()));
1551            assert!(paths.contains(&"light.css".to_string()));
1552            assert!(
1553                entries
1554                    .iter()
1555                    .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
1556            );
1557        }
1558
1559        #[test]
1560        fn wildcard_does_not_match_nonexistent_files() {
1561            let dir = tempfile::tempdir().expect("create temp dir");
1562            // No files matching the pattern
1563            std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
1564
1565            let canonical = dunce::canonicalize(dir.path()).unwrap();
1566            let mut entries = Vec::new();
1567            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1568
1569            assert!(
1570                entries.is_empty(),
1571                "should return empty when no files match the wildcard"
1572            );
1573        }
1574
1575        #[test]
1576        fn wildcard_only_matches_specified_extension() {
1577            // Wildcard pattern `*.css` should not match `.ts` files
1578            let dir = tempfile::tempdir().expect("create temp dir");
1579            let themes = dir.path().join("src").join("themes");
1580            std::fs::create_dir_all(&themes).unwrap();
1581            std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
1582            std::fs::write(themes.join("index.ts"), "export {};").unwrap();
1583
1584            let canonical = dunce::canonicalize(dir.path()).unwrap();
1585            let mut entries = Vec::new();
1586            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1587
1588            assert_eq!(entries.len(), 1, "should only match CSS files");
1589            assert!(
1590                entries[0]
1591                    .path
1592                    .file_name()
1593                    .unwrap()
1594                    .to_string_lossy()
1595                    .ends_with(".css")
1596            );
1597        }
1598    }
1599}