Skip to main content

fallow_core/discover/
entry_points.rs

1use std::path::{Component, 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};
7use fallow_types::path_util::is_absolute_path_any_platform;
8use rustc_hash::{FxHashMap, FxHashSet};
9
10/// Known output directory names from exports maps.
11/// When an entry point path is inside one of these directories, we also try
12/// the `src/` equivalent to find the tracked source file.
13const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
14const SKIPPED_ENTRY_WARNING_PREVIEW: usize = 5;
15
16fn format_skipped_entry_warning(skipped_entries: &FxHashMap<String, usize>) -> Option<String> {
17    if skipped_entries.is_empty() {
18        return None;
19    }
20
21    let mut entries = skipped_entries
22        .iter()
23        .map(|(path, count)| (path.as_str(), *count))
24        .collect::<Vec<_>>();
25    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
26
27    let preview = entries
28        .iter()
29        .take(SKIPPED_ENTRY_WARNING_PREVIEW)
30        .map(|(path, count)| {
31            if *count > 1 {
32                format!("{path} ({count}x)")
33            } else {
34                (*path).to_owned()
35            }
36        })
37        .collect::<Vec<_>>();
38
39    let omitted = entries.len().saturating_sub(SKIPPED_ENTRY_WARNING_PREVIEW);
40    let tail = if omitted > 0 {
41        format!(" (and {omitted} more)")
42    } else {
43        String::new()
44    };
45    let total = entries.iter().map(|(_, count)| *count).sum::<usize>();
46    let noun = if total == 1 {
47        "package.json entry point"
48    } else {
49        "package.json entry points"
50    };
51
52    Some(format!(
53        "Skipped {total} {noun} outside project root or containing parent directory traversal: {}{tail}",
54        preview.join(", ")
55    ))
56}
57
58pub fn warn_skipped_entry_summary(skipped_entries: &FxHashMap<String, usize>) {
59    let Some(message) = format_skipped_entry_warning(skipped_entries) else {
60        return;
61    };
62    // This summary is emitted once per workspace package, and on a monorepo
63    // every package commonly skips the same out-of-root binary (e.g. a shared
64    // `bin` pointing at `../../packages/<tool>/bin/<tool>`), producing dozens of
65    // byte-identical lines. Dedupe on the rendered message so identical
66    // summaries warn once per process while genuinely distinct skip-sets each
67    // still surface (issue #637).
68    if should_warn_skipped_entry(&message) {
69        tracing::warn!("{message}");
70    }
71}
72
73/// Process-wide dedupe for [`warn_skipped_entry_summary`]. Returns `true` when
74/// `message` was newly inserted (caller should emit). On a poisoned mutex
75/// returns `true` so over-warning beats swallowing.
76fn should_warn_skipped_entry(message: &str) -> bool {
77    static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
78        std::sync::OnceLock::new();
79    let warned = WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()));
80    warned
81        .lock()
82        .map_or(true, |mut set| set.insert(message.to_owned()))
83}
84
85/// Entry points grouped by reachability role.
86#[derive(Debug, Clone, Default)]
87pub struct CategorizedEntryPoints {
88    pub all: Vec<EntryPoint>,
89    pub runtime: Vec<EntryPoint>,
90    pub test: Vec<EntryPoint>,
91}
92
93impl CategorizedEntryPoints {
94    pub fn push_runtime(&mut self, entry: EntryPoint) {
95        self.runtime.push(entry.clone());
96        self.all.push(entry);
97    }
98
99    pub fn push_test(&mut self, entry: EntryPoint) {
100        self.test.push(entry.clone());
101        self.all.push(entry);
102    }
103
104    pub fn push_support(&mut self, entry: EntryPoint) {
105        self.all.push(entry);
106    }
107
108    pub fn extend_runtime<I>(&mut self, entries: I)
109    where
110        I: IntoIterator<Item = EntryPoint>,
111    {
112        for entry in entries {
113            self.push_runtime(entry);
114        }
115    }
116
117    pub fn extend_test<I>(&mut self, entries: I)
118    where
119        I: IntoIterator<Item = EntryPoint>,
120    {
121        for entry in entries {
122            self.push_test(entry);
123        }
124    }
125
126    pub fn extend_support<I>(&mut self, entries: I)
127    where
128        I: IntoIterator<Item = EntryPoint>,
129    {
130        for entry in entries {
131            self.push_support(entry);
132        }
133    }
134
135    pub fn extend(&mut self, other: Self) {
136        self.all.extend(other.all);
137        self.runtime.extend(other.runtime);
138        self.test.extend(other.test);
139    }
140
141    #[must_use]
142    pub fn dedup(mut self) -> Self {
143        dedup_entry_paths(&mut self.all);
144        dedup_entry_paths(&mut self.runtime);
145        dedup_entry_paths(&mut self.test);
146        self
147    }
148}
149
150fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
151    entries.sort_by(|a, b| a.path.cmp(&b.path));
152    entries.dedup_by(|a, b| a.path == b.path);
153}
154
155#[derive(Debug, Default)]
156pub struct EntryPointDiscovery {
157    pub entries: Vec<EntryPoint>,
158    pub skipped_entries: FxHashMap<String, usize>,
159}
160
161/// Resolve a path relative to a base directory, with security check and extension fallback.
162///
163/// Returns `Some(EntryPoint)` if the path resolves to an existing file within `canonical_root`,
164/// trying source extensions as fallback when the exact path doesn't exist.
165/// Also handles exports map targets in output directories (e.g., `./dist/utils.js`)
166/// by trying to map back to the source file (e.g., `./src/utils.ts`).
167fn resolve_entry_path_with_tracking(
168    base: &Path,
169    entry: &str,
170    canonical_root: &Path,
171    source: EntryPointSource,
172    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
173) -> Option<EntryPoint> {
174    // Wildcard exports (e.g., `./src/themes/*.css`) can't be resolved to a single
175    // file. Return None and let the caller expand them separately.
176    if entry.contains('*') {
177        return None;
178    }
179
180    if entry_has_parent_dir(entry) {
181        if let Some(skipped_entries) = skipped_entries.as_mut() {
182            *skipped_entries.entry(entry.to_owned()).or_default() += 1;
183        } else {
184            tracing::warn!(path = %entry, "Skipping entry point containing parent directory traversal");
185        }
186        return None;
187    }
188
189    let resolved = base.join(entry);
190
191    // If the path is in an output directory (dist/, build/, etc.), try mapping to src/ first.
192    // This handles exports map targets like `./dist/utils.js` → `./src/utils.ts`.
193    // We check this BEFORE the exists() check because even if the dist file exists,
194    // fallow ignores dist/ by default, so we need the source file instead.
195    if let Some(source_path) = try_output_to_source_path(base, entry) {
196        return validated_entry_point(&source_path, canonical_root, entry, source, skipped_entries);
197    }
198
199    // When the entry lives under an output directory but has no direct src/ mirror
200    // (e.g. `./dist/esm2022/index.js` where `src/esm2022/index.ts` does not exist),
201    // probe the package root for a conventional source index. TypeScript libraries
202    // commonly point `main`/`module`/`exports` at compiled output while keeping the
203    // canonical source entry at `src/index.ts`. Without this fallback, the dist file
204    // becomes the entry point, gets filtered out by the default dist ignore pattern,
205    // and leaves the entire src/ tree unreachable. See issue #102.
206    if is_entry_in_output_dir(entry)
207        && let Some(source_path) = try_source_index_fallback(base)
208    {
209        tracing::info!(
210            entry = %entry,
211            fallback = %source_path.display(),
212            "package.json entry resolves to an ignored output directory; falling back to source index"
213        );
214        return validated_entry_point(&source_path, canonical_root, entry, source, skipped_entries);
215    }
216
217    if resolved.is_file() {
218        return validated_entry_point(
219            &resolved,
220            canonical_root,
221            entry,
222            source,
223            skipped_entries.as_deref_mut(),
224        );
225    }
226
227    // Try with source extensions
228    for ext in SOURCE_EXTENSIONS {
229        let with_ext = resolved.with_extension(ext);
230        if with_ext.is_file() {
231            return validated_entry_point(
232                &with_ext,
233                canonical_root,
234                entry,
235                source,
236                skipped_entries.as_deref_mut(),
237            );
238        }
239    }
240
241    if let Some(index_entry) = try_directory_index_entry(&resolved) {
242        return validated_entry_point(
243            &index_entry,
244            canonical_root,
245            entry,
246            source,
247            skipped_entries.as_deref_mut(),
248        );
249    }
250
251    // Some source-first packages publish root build artifacts such as
252    // `./index.js` / `./index.cjs` but keep the real entry source in
253    // `src/index.ts`. If the root artifact is absent in a clean checkout, fall
254    // back to the conventional source index instead of dropping the entry.
255    if is_package_root_index_entry(entry)
256        && let Some(source_path) = try_source_index_fallback(base)
257    {
258        tracing::info!(
259            entry = %entry,
260            fallback = %source_path.display(),
261            "package.json root index entry is missing; falling back to source index"
262        );
263        return validated_entry_point(&source_path, canonical_root, entry, source, skipped_entries);
264    }
265    None
266}
267
268fn try_directory_index_entry(resolved: &Path) -> Option<PathBuf> {
269    for ext in SOURCE_EXTENSIONS {
270        let candidate = resolved.join(format!("index.{ext}"));
271        if candidate.is_file() {
272            return Some(candidate);
273        }
274    }
275    None
276}
277
278fn entry_has_parent_dir(entry: &str) -> bool {
279    Path::new(entry)
280        .components()
281        .any(|component| matches!(component, Component::ParentDir))
282}
283
284fn is_package_root_index_entry(entry: &str) -> bool {
285    let mut components = Path::new(entry)
286        .components()
287        .filter(|component| !matches!(component, Component::CurDir));
288
289    let Some(Component::Normal(file_name)) = components.next() else {
290        return false;
291    };
292    if components.next().is_some() {
293        return false;
294    }
295
296    file_name
297        .to_str()
298        .is_some_and(|name| name == "index" || name.starts_with("index."))
299}
300
301fn validated_entry_point(
302    candidate: &Path,
303    canonical_root: &Path,
304    entry: &str,
305    source: EntryPointSource,
306    mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
307) -> Option<EntryPoint> {
308    let canonical_candidate = match dunce::canonicalize(candidate) {
309        Ok(path) => path,
310        Err(err) => {
311            tracing::warn!(
312                path = %candidate.display(),
313                %entry,
314                error = %err,
315                "Skipping entry point that could not be canonicalized"
316            );
317            return None;
318        }
319    };
320
321    if !canonical_candidate.starts_with(canonical_root) {
322        if let Some(skipped_entries) = skipped_entries.as_mut() {
323            *skipped_entries.entry(entry.to_owned()).or_default() += 1;
324        } else {
325            tracing::warn!(
326                path = %candidate.display(),
327                %entry,
328                "Skipping entry point outside project root"
329            );
330        }
331        return None;
332    }
333
334    Some(EntryPoint {
335        path: candidate.to_path_buf(),
336        source,
337    })
338}
339
340pub fn resolve_entry_path(
341    base: &Path,
342    entry: &str,
343    canonical_root: &Path,
344    source: EntryPointSource,
345) -> Option<EntryPoint> {
346    resolve_entry_path_with_tracking(base, entry, canonical_root, source, None)
347}
348/// Try to map an entry path from an output directory to its source equivalent.
349///
350/// Given `base=/project/packages/ui` and `entry=./dist/utils.js`, this tries:
351/// - `/project/packages/ui/src/utils.ts`
352/// - `/project/packages/ui/src/utils.tsx`
353/// - etc. for all source extensions
354///
355/// Preserves any path prefix between the package root and the output dir,
356/// e.g. `./modules/dist/utils.js` → `base/modules/src/utils.ts`.
357///
358/// Returns `Some(path)` if a source file is found.
359fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
360    let entry_path = Path::new(entry);
361    let components: Vec<_> = entry_path.components().collect();
362
363    // Find the last output directory component in the entry path
364    let output_pos = components.iter().rposition(|c| {
365        if let std::path::Component::Normal(s) = c
366            && let Some(name) = s.to_str()
367        {
368            return OUTPUT_DIRS.contains(&name);
369        }
370        false
371    })?;
372
373    // Build the relative prefix before the output dir, filtering out CurDir (".")
374    let prefix: PathBuf = components[..output_pos]
375        .iter()
376        .filter(|c| !matches!(c, std::path::Component::CurDir))
377        .collect();
378
379    // Build the relative path after the output dir (e.g., "utils.js")
380    let suffix: PathBuf = components[output_pos + 1..].iter().collect();
381
382    // Try base + prefix + "src" + suffix-with-source-extension
383    for ext in SOURCE_EXTENSIONS {
384        let source_candidate = base
385            .join(&prefix)
386            .join("src")
387            .join(suffix.with_extension(ext));
388        if source_candidate.exists() {
389            return Some(source_candidate);
390        }
391    }
392
393    None
394}
395
396/// Conventional source index file stems probed when a package.json entry lives
397/// in an ignored output directory. Ordered by preference.
398const SOURCE_INDEX_FALLBACK_STEMS: &[&str] = &["src/index", "src/main", "index", "main"];
399
400/// Return `true` when `entry` contains a known output directory component.
401///
402/// Matches any segment in `OUTPUT_DIRS`, e.g. `./dist/esm2022/index.js` →
403/// `true`, `src/main.ts` → `false`.
404fn is_entry_in_output_dir(entry: &str) -> bool {
405    Path::new(entry).components().any(|c| {
406        matches!(
407            c,
408            std::path::Component::Normal(s)
409                if s.to_str().is_some_and(|name| OUTPUT_DIRS.contains(&name))
410        )
411    })
412}
413
414/// Probe a package root for a conventional source index file.
415///
416/// Used when `package.json` points at compiled output but the canonical source
417/// entry is a standard TypeScript/JavaScript index file. Tries `src/index`,
418/// `src/main`, `index`, and `main` with each supported source extension, in
419/// that order.
420fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
421    for stem in SOURCE_INDEX_FALLBACK_STEMS {
422        for ext in SOURCE_EXTENSIONS {
423            let candidate = base.join(format!("{stem}.{ext}"));
424            if candidate.is_file() {
425                return Some(candidate);
426            }
427        }
428    }
429    None
430}
431
432/// Default index patterns used when no other entry points are found.
433const DEFAULT_INDEX_PATTERNS: &[&str] = &[
434    "src/index.{ts,tsx,js,jsx}",
435    "src/main.{ts,tsx,js,jsx}",
436    "index.{ts,tsx,js,jsx}",
437    "main.{ts,tsx,js,jsx}",
438];
439
440/// Fall back to default index patterns if no entries were found.
441///
442/// When `ws_filter` is `Some`, only files whose path starts with the given
443/// workspace root are considered (used for workspace-scoped discovery).
444fn apply_default_fallback(
445    files: &[DiscoveredFile],
446    root: &Path,
447    ws_filter: Option<&Path>,
448) -> Vec<EntryPoint> {
449    let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
450        .iter()
451        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
452        .collect();
453
454    let mut entries = Vec::new();
455    for file in files {
456        // Use strip_prefix instead of canonicalize for workspace filtering
457        if let Some(ws_root) = ws_filter
458            && file.path.strip_prefix(ws_root).is_err()
459        {
460            continue;
461        }
462        let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
463        let relative_str = relative.to_string_lossy();
464        if default_matchers
465            .iter()
466            .any(|m| m.is_match(relative_str.as_ref()))
467        {
468            entries.push(EntryPoint {
469                path: file.path.clone(),
470                source: EntryPointSource::DefaultIndex,
471            });
472        }
473    }
474    entries
475}
476
477/// Discover entry points from package.json, framework rules, and defaults.
478fn discover_entry_points_with_warnings_impl(
479    config: &ResolvedConfig,
480    files: &[DiscoveredFile],
481    root_pkg: Option<&PackageJson>,
482    include_nested_package_entries: bool,
483) -> EntryPointDiscovery {
484    let _span = tracing::info_span!("discover_entry_points").entered();
485    let mut discovery = EntryPointDiscovery::default();
486
487    // Pre-compute relative paths for all files (once, not per pattern)
488    let relative_paths: Vec<String> = files
489        .iter()
490        .map(|f| {
491            f.path
492                .strip_prefix(&config.root)
493                .unwrap_or(&f.path)
494                .to_string_lossy()
495                .into_owned()
496        })
497        .collect();
498
499    // 1. Manual entries from config — batch all patterns into a single GlobSet
500    // for O(files) matching instead of O(patterns × files). Patterns were
501    // validated at config load time (see FallowConfig::validate_user_globs);
502    // any compile failure here is a bug.
503    {
504        let mut builder = globset::GlobSetBuilder::new();
505        for pattern in &config.entry_patterns {
506            builder.add(
507                globset::Glob::new(pattern)
508                    .expect("entry pattern was validated at config load time"),
509            );
510        }
511        if let Ok(glob_set) = builder.build()
512            && !glob_set.is_empty()
513        {
514            for (idx, rel) in relative_paths.iter().enumerate() {
515                if glob_set.is_match(rel) {
516                    discovery.entries.push(EntryPoint {
517                        path: files[idx].path.clone(),
518                        source: EntryPointSource::ManualEntry,
519                    });
520                }
521            }
522        }
523    }
524
525    // 2. Package.json entries
526    // Pre-compute canonical root once for all resolve_entry_path calls
527    let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
528    if let Some(pkg) = root_pkg {
529        for entry_path in pkg.entry_points() {
530            if let Some(ep) = resolve_entry_path_with_tracking(
531                &config.root,
532                &entry_path,
533                &canonical_root,
534                EntryPointSource::PackageJsonMain,
535                Some(&mut discovery.skipped_entries),
536            ) {
537                discovery.entries.push(ep);
538            }
539        }
540
541        // 2b. Package.json scripts — extract file references as entry points
542        if let Some(scripts) = &pkg.scripts {
543            for script_value in scripts.values() {
544                for file_ref in extract_script_file_refs(script_value) {
545                    if let Some(ep) = resolve_entry_path_with_tracking(
546                        &config.root,
547                        &file_ref,
548                        &canonical_root,
549                        EntryPointSource::PackageJsonScript,
550                        Some(&mut discovery.skipped_entries),
551                    ) {
552                        discovery.entries.push(ep);
553                    }
554                }
555            }
556        }
557
558        // Framework rules now flow through PluginRegistry via external_plugins.
559    }
560
561    // 4. Auto-discover nested package.json entry points
562    // For monorepo-like structures without explicit workspace config, scan for
563    // package.json files in subdirectories and use their main/exports as entries.
564    if include_nested_package_entries {
565        let exports_dirs = root_pkg
566            .map(PackageJson::exports_subdirectories)
567            .unwrap_or_default();
568        discover_nested_package_entries(
569            &config.root,
570            files,
571            &mut discovery.entries,
572            &canonical_root,
573            &exports_dirs,
574            &mut discovery.skipped_entries,
575        );
576    }
577
578    // 5. Default index files (if no other entries found)
579    if discovery.entries.is_empty() {
580        discovery.entries = apply_default_fallback(files, &config.root, None);
581    }
582
583    // Deduplicate by path
584    discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
585    discovery.entries.dedup_by(|a, b| a.path == b.path);
586
587    discovery
588}
589
590pub fn discover_entry_points_with_warnings_from_pkg(
591    config: &ResolvedConfig,
592    files: &[DiscoveredFile],
593    root_pkg: Option<&PackageJson>,
594    include_nested_package_entries: bool,
595) -> EntryPointDiscovery {
596    discover_entry_points_with_warnings_impl(
597        config,
598        files,
599        root_pkg,
600        include_nested_package_entries,
601    )
602}
603
604pub fn discover_entry_points_with_warnings(
605    config: &ResolvedConfig,
606    files: &[DiscoveredFile],
607) -> EntryPointDiscovery {
608    let pkg_path = config.root.join("package.json");
609    let root_pkg = PackageJson::load(&pkg_path).ok();
610    discover_entry_points_with_warnings_impl(config, files, root_pkg.as_ref(), true)
611}
612
613pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
614    let discovery = discover_entry_points_with_warnings(config, files);
615    warn_skipped_entry_summary(&discovery.skipped_entries);
616    discovery.entries
617}
618
619/// Discover entry points from nested package.json files in subdirectories.
620///
621/// Scans two sources for sub-packages:
622/// 1. Common monorepo directory patterns (`packages/`, `apps/`, `libs/`, etc.)
623/// 2. Directories derived from the root package.json `exports` map keys
624///    (e.g., `"./compat": {...}` implies `compat/` may be a sub-package)
625///
626/// For each discovered sub-package with a `package.json`, the `main`, `module`,
627/// `source`, `exports`, and `bin` fields are treated as entry points.
628fn discover_nested_package_entries(
629    root: &Path,
630    _files: &[DiscoveredFile],
631    entries: &mut Vec<EntryPoint>,
632    canonical_root: &Path,
633    exports_subdirectories: &[String],
634    skipped_entries: &mut FxHashMap<String, usize>,
635) {
636    let mut visited = rustc_hash::FxHashSet::default();
637
638    // 1. Walk common monorepo patterns
639    let search_dirs = [
640        "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
641    ];
642    for dir_name in &search_dirs {
643        let search_dir = root.join(dir_name);
644        if !search_dir.is_dir() {
645            continue;
646        }
647        let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
648            continue;
649        };
650        for entry in read_dir.flatten() {
651            let pkg_dir = entry.path();
652            if visited.insert(pkg_dir.clone()) {
653                collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
654            }
655        }
656    }
657
658    // 2. Scan directories derived from the root exports map
659    for dir_name in exports_subdirectories {
660        let pkg_dir = root.join(dir_name);
661        if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
662            collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
663        }
664    }
665}
666
667/// Collect entry points from a single sub-package directory.
668fn collect_nested_package_entries(
669    pkg_dir: &Path,
670    entries: &mut Vec<EntryPoint>,
671    canonical_root: &Path,
672    skipped_entries: &mut FxHashMap<String, usize>,
673) {
674    let pkg_path = pkg_dir.join("package.json");
675    if !pkg_path.exists() {
676        return;
677    }
678    let Ok(pkg) = PackageJson::load(&pkg_path) else {
679        return;
680    };
681    for entry_path in pkg.entry_points() {
682        if entry_path.contains('*') {
683            expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
684        } else if let Some(ep) = resolve_entry_path_with_tracking(
685            pkg_dir,
686            &entry_path,
687            canonical_root,
688            EntryPointSource::PackageJsonExports,
689            Some(&mut *skipped_entries),
690        ) {
691            entries.push(ep);
692        }
693    }
694    if let Some(scripts) = &pkg.scripts {
695        for script_value in scripts.values() {
696            for file_ref in extract_script_file_refs(script_value) {
697                if let Some(ep) = resolve_entry_path_with_tracking(
698                    pkg_dir,
699                    &file_ref,
700                    canonical_root,
701                    EntryPointSource::PackageJsonScript,
702                    Some(&mut *skipped_entries),
703                ) {
704                    entries.push(ep);
705                }
706            }
707        }
708    }
709}
710
711/// Expand wildcard subpath exports to matching files on disk.
712///
713/// Handles patterns like `./src/themes/*.css` from package.json exports maps
714/// (`"./themes/*": { "import": "./src/themes/*.css" }`). Expands the `*` to
715/// match actual files in the target directory.
716fn expand_wildcard_entries(
717    base: &Path,
718    pattern: &str,
719    canonical_root: &Path,
720    entries: &mut Vec<EntryPoint>,
721) {
722    let full_pattern = base.join(pattern).to_string_lossy().to_string();
723    let Ok(matches) = glob::glob(&full_pattern) else {
724        return;
725    };
726    for path_result in matches {
727        let Ok(path) = path_result else {
728            continue;
729        };
730        if let Ok(canonical) = dunce::canonicalize(&path)
731            && canonical.starts_with(canonical_root)
732        {
733            entries.push(EntryPoint {
734                path,
735                source: EntryPointSource::PackageJsonExports,
736            });
737        }
738    }
739}
740
741/// Discover entry points for a workspace package.
742#[must_use]
743fn discover_workspace_entry_points_with_warnings_impl(
744    ws_root: &Path,
745    all_files: &[DiscoveredFile],
746    pkg: Option<&PackageJson>,
747) -> EntryPointDiscovery {
748    let mut discovery = EntryPointDiscovery::default();
749
750    if let Some(pkg) = pkg {
751        let canonical_ws_root =
752            dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
753        for entry_path in pkg.entry_points() {
754            if entry_path.contains('*') {
755                expand_wildcard_entries(
756                    ws_root,
757                    &entry_path,
758                    &canonical_ws_root,
759                    &mut discovery.entries,
760                );
761            } else if let Some(ep) = resolve_entry_path_with_tracking(
762                ws_root,
763                &entry_path,
764                &canonical_ws_root,
765                EntryPointSource::PackageJsonMain,
766                Some(&mut discovery.skipped_entries),
767            ) {
768                discovery.entries.push(ep);
769            }
770        }
771
772        // Scripts field — extract file references as entry points
773        if let Some(scripts) = &pkg.scripts {
774            for script_value in scripts.values() {
775                for file_ref in extract_script_file_refs(script_value) {
776                    if let Some(ep) = resolve_entry_path_with_tracking(
777                        ws_root,
778                        &file_ref,
779                        &canonical_ws_root,
780                        EntryPointSource::PackageJsonScript,
781                        Some(&mut discovery.skipped_entries),
782                    ) {
783                        discovery.entries.push(ep);
784                    }
785                }
786            }
787        }
788
789        // Framework rules now flow through PluginRegistry via external_plugins.
790    }
791
792    // Fall back to default index files if no entry points found for this workspace
793    if discovery.entries.is_empty() {
794        discovery.entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
795    }
796
797    discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
798    discovery.entries.dedup_by(|a, b| a.path == b.path);
799    discovery
800}
801
802pub fn discover_workspace_entry_points_with_warnings_from_pkg(
803    ws_root: &Path,
804    all_files: &[DiscoveredFile],
805    pkg: Option<&PackageJson>,
806) -> EntryPointDiscovery {
807    discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg)
808}
809
810#[must_use]
811pub fn discover_workspace_entry_points_with_warnings(
812    ws_root: &Path,
813    _config: &ResolvedConfig,
814    all_files: &[DiscoveredFile],
815) -> EntryPointDiscovery {
816    let pkg_path = ws_root.join("package.json");
817    let pkg = PackageJson::load(&pkg_path).ok();
818    discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg.as_ref())
819}
820
821#[must_use]
822pub fn discover_workspace_entry_points(
823    ws_root: &Path,
824    config: &ResolvedConfig,
825    all_files: &[DiscoveredFile],
826) -> Vec<EntryPoint> {
827    let discovery = discover_workspace_entry_points_with_warnings(ws_root, config, all_files);
828    warn_skipped_entry_summary(&discovery.skipped_entries);
829    discovery.entries
830}
831
832/// Discover entry points from plugin results (dynamic config parsing).
833///
834/// Converts plugin-discovered patterns and setup files into concrete entry points
835/// by matching them against the discovered file list.
836#[must_use]
837pub fn discover_plugin_entry_points(
838    plugin_result: &crate::plugins::AggregatedPluginResult,
839    config: &ResolvedConfig,
840    files: &[DiscoveredFile],
841) -> Vec<EntryPoint> {
842    discover_plugin_entry_point_sets(plugin_result, config, files).all
843}
844
845/// Discover plugin-derived entry points with runtime/test/support roles preserved.
846#[must_use]
847pub fn discover_plugin_entry_point_sets(
848    plugin_result: &crate::plugins::AggregatedPluginResult,
849    config: &ResolvedConfig,
850    files: &[DiscoveredFile],
851) -> CategorizedEntryPoints {
852    let mut entries = CategorizedEntryPoints::default();
853
854    // Pre-compute relative paths
855    let relative_paths: Vec<String> = files
856        .iter()
857        .map(|f| {
858            f.path
859                .strip_prefix(&config.root)
860                .unwrap_or(&f.path)
861                .to_string_lossy()
862                .into_owned()
863        })
864        .collect();
865
866    // Match plugin entry patterns against files using a single GlobSet for
867    // include globs, then filter candidate matches through any exclusions.
868    let mut builder = globset::GlobSetBuilder::new();
869    let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
870    for (rule, pname) in &plugin_result.entry_patterns {
871        if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
872            builder.add(include);
873            glob_meta.push(compiled);
874        }
875    }
876    for (pattern, pname) in plugin_result
877        .discovered_always_used
878        .iter()
879        .chain(plugin_result.always_used.iter())
880        .chain(plugin_result.fixture_patterns.iter())
881    {
882        if let Ok(glob) = globset::GlobBuilder::new(pattern)
883            .literal_separator(true)
884            .build()
885        {
886            builder.add(glob);
887            if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
888                &crate::plugins::PathRule::new(pattern.clone()),
889                "support entry pattern",
890            ) {
891                glob_meta.push(CompiledEntryRule {
892                    path,
893                    plugin_name: pname,
894                    role: EntryPointRole::Support,
895                });
896            }
897        }
898    }
899    if let Ok(glob_set) = builder.build()
900        && !glob_set.is_empty()
901    {
902        for (idx, rel) in relative_paths.iter().enumerate() {
903            let matches: Vec<usize> = glob_set
904                .matches(rel)
905                .into_iter()
906                .filter(|match_idx| glob_meta[*match_idx].matches(rel))
907                .collect();
908            if !matches.is_empty() {
909                let name = glob_meta[matches[0]].plugin_name;
910                let entry = EntryPoint {
911                    path: files[idx].path.clone(),
912                    source: EntryPointSource::Plugin {
913                        name: name.to_string(),
914                    },
915                };
916
917                let mut has_runtime = false;
918                let mut has_test = false;
919                let mut has_support = false;
920                for match_idx in matches {
921                    match glob_meta[match_idx].role {
922                        EntryPointRole::Runtime => has_runtime = true,
923                        EntryPointRole::Test => has_test = true,
924                        EntryPointRole::Support => has_support = true,
925                    }
926                }
927
928                if has_runtime {
929                    entries.push_runtime(entry.clone());
930                }
931                if has_test {
932                    entries.push_test(entry.clone());
933                }
934                if has_support || (!has_runtime && !has_test) {
935                    entries.push_support(entry);
936                }
937            }
938        }
939    }
940
941    // Add setup files (absolute paths from plugin config parsing)
942    for (setup_file, pname) in &plugin_result.setup_files {
943        let resolved = resolve_plugin_setup_file(&config.root, setup_file);
944        if resolved.exists() {
945            entries.push_support(EntryPoint {
946                path: resolved,
947                source: EntryPointSource::Plugin {
948                    name: pname.clone(),
949                },
950            });
951        } else {
952            // Try with extensions
953            for ext in SOURCE_EXTENSIONS {
954                let with_ext = resolved.with_extension(ext);
955                if with_ext.exists() {
956                    entries.push_support(EntryPoint {
957                        path: with_ext,
958                        source: EntryPointSource::Plugin {
959                            name: pname.clone(),
960                        },
961                    });
962                    break;
963                }
964            }
965        }
966    }
967
968    entries.dedup()
969}
970
971fn resolve_plugin_setup_file(root: &Path, setup_file: &Path) -> PathBuf {
972    if is_absolute_path_any_platform(setup_file) {
973        setup_file.to_path_buf()
974    } else {
975        root.join(setup_file)
976    }
977}
978
979/// Discover entry points from `dynamicallyLoaded` config patterns.
980///
981/// Matches the configured glob patterns against the discovered file list and
982/// marks matching files as entry points so they are never flagged as unused.
983#[must_use]
984pub fn discover_dynamically_loaded_entry_points(
985    config: &ResolvedConfig,
986    files: &[DiscoveredFile],
987) -> Vec<EntryPoint> {
988    if config.dynamically_loaded.is_empty() {
989        return Vec::new();
990    }
991
992    // Patterns were validated at config load time (see
993    // FallowConfig::validate_user_globs); any compile failure here is a bug.
994    let mut builder = globset::GlobSetBuilder::new();
995    for pattern in &config.dynamically_loaded {
996        builder.add(
997            globset::Glob::new(pattern)
998                .expect("dynamicallyLoaded pattern was validated at config load time"),
999        );
1000    }
1001    let Ok(glob_set) = builder.build() else {
1002        return Vec::new();
1003    };
1004    if glob_set.is_empty() {
1005        return Vec::new();
1006    }
1007
1008    let mut entries = Vec::new();
1009    for file in files {
1010        let rel = file
1011            .path
1012            .strip_prefix(&config.root)
1013            .unwrap_or(&file.path)
1014            .to_string_lossy();
1015        if glob_set.is_match(rel.as_ref()) {
1016            entries.push(EntryPoint {
1017                path: file.path.clone(),
1018                source: EntryPointSource::DynamicallyLoaded,
1019            });
1020        }
1021    }
1022    entries
1023}
1024
1025struct CompiledEntryRule<'a> {
1026    path: crate::plugins::CompiledPathRule,
1027    plugin_name: &'a str,
1028    role: EntryPointRole,
1029}
1030
1031impl CompiledEntryRule<'_> {
1032    fn matches(&self, path: &str) -> bool {
1033        self.path.matches(path)
1034    }
1035}
1036
1037fn compile_entry_rule<'a>(
1038    rule: &'a crate::plugins::PathRule,
1039    plugin_name: &'a str,
1040    plugin_result: &'a crate::plugins::AggregatedPluginResult,
1041) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
1042    let include = match globset::GlobBuilder::new(&rule.pattern)
1043        .literal_separator(true)
1044        .build()
1045    {
1046        Ok(glob) => glob,
1047        Err(err) => {
1048            tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
1049            return None;
1050        }
1051    };
1052    let role = plugin_result
1053        .entry_point_roles
1054        .get(plugin_name)
1055        .copied()
1056        .unwrap_or(EntryPointRole::Support);
1057    Some((
1058        include,
1059        CompiledEntryRule {
1060            path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
1061            plugin_name,
1062            role,
1063        },
1064    ))
1065}
1066
1067/// Pre-compile a set of glob patterns for efficient matching against many paths.
1068#[must_use]
1069pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
1070    if patterns.is_empty() {
1071        return None;
1072    }
1073    let mut builder = globset::GlobSetBuilder::new();
1074    for pattern in patterns {
1075        if let Ok(glob) = globset::GlobBuilder::new(pattern)
1076            .literal_separator(true)
1077            .build()
1078        {
1079            builder.add(glob);
1080        }
1081    }
1082    builder.build().ok()
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088    use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
1089    use fallow_types::discover::FileId;
1090    use proptest::prelude::*;
1091
1092    proptest! {
1093        /// Valid glob patterns should never panic when compiled via globset.
1094        #[test]
1095        fn glob_patterns_never_panic_on_compile(
1096            prefix in "[a-zA-Z0-9_]{1,20}",
1097            ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
1098        ) {
1099            let pattern = format!("**/{prefix}*.{ext}");
1100            // Should not panic — either compiles or returns Err gracefully
1101            let result = globset::Glob::new(&pattern);
1102            prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
1103        }
1104
1105        /// Non-source extensions should NOT be in the SOURCE_EXTENSIONS list.
1106        #[test]
1107        fn non_source_extensions_not_in_list(
1108            ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
1109        ) {
1110            prop_assert!(
1111                !SOURCE_EXTENSIONS.contains(&ext),
1112                "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
1113            );
1114        }
1115
1116        /// compile_glob_set should never panic on arbitrary well-formed glob patterns.
1117        #[test]
1118        fn compile_glob_set_no_panic(
1119            patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
1120        ) {
1121            // Should not panic regardless of input
1122            let _ = compile_glob_set(&patterns);
1123        }
1124    }
1125
1126    // compile_glob_set unit tests
1127    #[test]
1128    fn compile_glob_set_empty_input() {
1129        assert!(
1130            compile_glob_set(&[]).is_none(),
1131            "empty patterns should return None"
1132        );
1133    }
1134
1135    #[test]
1136    fn compile_glob_set_valid_patterns() {
1137        let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
1138        let set = compile_glob_set(&patterns);
1139        assert!(set.is_some(), "valid patterns should compile");
1140        let set = set.unwrap();
1141        assert!(set.is_match("src/foo.ts"));
1142        assert!(set.is_match("src/bar.js"));
1143        assert!(!set.is_match("src/bar.py"));
1144    }
1145
1146    #[test]
1147    fn compile_glob_set_keeps_star_within_a_single_path_segment() {
1148        let patterns = vec!["composables/*.{ts,js}".to_string()];
1149        let set = compile_glob_set(&patterns).expect("pattern should compile");
1150
1151        assert!(set.is_match("composables/useFoo.ts"));
1152        assert!(!set.is_match("composables/nested/useFoo.ts"));
1153    }
1154
1155    #[test]
1156    fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
1157        let dir = tempfile::tempdir().expect("create temp dir");
1158        let root = dir.path();
1159        std::fs::create_dir_all(root.join("src")).unwrap();
1160        std::fs::create_dir_all(root.join("tests")).unwrap();
1161        std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
1162        std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
1163        std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
1164
1165        let config = FallowConfig {
1166            schema: None,
1167            extends: vec![],
1168            entry: vec![],
1169            ignore_patterns: vec![],
1170            framework: vec![],
1171            workspaces: None,
1172            ignore_dependencies: vec![],
1173            ignore_unresolved_imports: vec![],
1174            ignore_exports: vec![],
1175            ignore_catalog_references: vec![],
1176            ignore_dependency_overrides: vec![],
1177            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1178            used_class_members: vec![],
1179            ignore_decorators: vec![],
1180            duplicates: fallow_config::DuplicatesConfig::default(),
1181            health: fallow_config::HealthConfig::default(),
1182            rules: RulesConfig::default(),
1183            boundaries: fallow_config::BoundaryConfig::default(),
1184            production: false.into(),
1185            plugins: vec![],
1186            dynamically_loaded: vec![],
1187            overrides: vec![],
1188            regression: None,
1189            audit: fallow_config::AuditConfig::default(),
1190            codeowners: None,
1191            public_packages: vec![],
1192            flags: fallow_config::FlagsConfig::default(),
1193            fix: fallow_config::FixConfig::default(),
1194            resolve: fallow_config::ResolveConfig::default(),
1195            sealed: false,
1196            include_entry_exports: false,
1197            auto_imports: false,
1198            cache: fallow_config::CacheConfig::default(),
1199        }
1200        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1201
1202        let files = vec![
1203            DiscoveredFile {
1204                id: FileId(0),
1205                path: root.join("src/runtime.ts"),
1206                size_bytes: 1,
1207            },
1208            DiscoveredFile {
1209                id: FileId(1),
1210                path: root.join("src/setup.ts"),
1211                size_bytes: 1,
1212            },
1213            DiscoveredFile {
1214                id: FileId(2),
1215                path: root.join("tests/app.test.ts"),
1216                size_bytes: 1,
1217            },
1218        ];
1219
1220        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1221        plugin_result.entry_patterns.push((
1222            crate::plugins::PathRule::new("src/runtime.ts"),
1223            "runtime-plugin".to_string(),
1224        ));
1225        plugin_result.entry_patterns.push((
1226            crate::plugins::PathRule::new("tests/app.test.ts"),
1227            "test-plugin".to_string(),
1228        ));
1229        plugin_result
1230            .always_used
1231            .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1232        plugin_result
1233            .entry_point_roles
1234            .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1235        plugin_result
1236            .entry_point_roles
1237            .insert("test-plugin".to_string(), EntryPointRole::Test);
1238        plugin_result
1239            .entry_point_roles
1240            .insert("support-plugin".to_string(), EntryPointRole::Support);
1241
1242        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1243
1244        assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1245        assert!(
1246            entries.runtime[0].path.ends_with("src/runtime.ts"),
1247            "runtime entry should stay runtime-only"
1248        );
1249        assert_eq!(entries.test.len(), 1, "expected one test entry");
1250        assert!(
1251            entries.test[0].path.ends_with("tests/app.test.ts"),
1252            "test entry should stay test-only"
1253        );
1254        assert_eq!(
1255            entries.all.len(),
1256            3,
1257            "support entries should stay in all entries"
1258        );
1259        assert!(
1260            entries
1261                .all
1262                .iter()
1263                .any(|entry| entry.path.ends_with("src/setup.ts")),
1264            "support entries should remain in the overall entry-point set"
1265        );
1266        assert!(
1267            !entries
1268                .runtime
1269                .iter()
1270                .any(|entry| entry.path.ends_with("src/setup.ts")),
1271            "support entries should not bleed into runtime reachability"
1272        );
1273        assert!(
1274            !entries
1275                .test
1276                .iter()
1277                .any(|entry| entry.path.ends_with("src/setup.ts")),
1278            "support entries should not bleed into test reachability"
1279        );
1280    }
1281
1282    #[test]
1283    fn resolve_plugin_setup_file_preserves_windows_absolute_path_on_any_host() {
1284        let root = Path::new("/workspace/project");
1285        let setup_file = Path::new(r"C:\workspace\project\setup.ts");
1286
1287        assert_eq!(
1288            resolve_plugin_setup_file(root, setup_file),
1289            setup_file.to_path_buf()
1290        );
1291    }
1292
1293    #[cfg(windows)]
1294    #[test]
1295    fn resolve_plugin_setup_file_preserves_posix_rooted_path_on_windows() {
1296        let root = Path::new(r"C:\workspace\project");
1297        let setup_file = Path::new(r"/workspace/project/setup.ts");
1298
1299        assert_eq!(
1300            resolve_plugin_setup_file(root, setup_file),
1301            setup_file.to_path_buf()
1302        );
1303    }
1304
1305    #[test]
1306    fn plugin_entry_point_rules_respect_exclusions() {
1307        let dir = tempfile::tempdir().expect("create temp dir");
1308        let root = dir.path();
1309        std::fs::create_dir_all(root.join("app/pages")).unwrap();
1310        std::fs::write(
1311            root.join("app/pages/index.tsx"),
1312            "export default function Page() { return null; }",
1313        )
1314        .unwrap();
1315        std::fs::write(
1316            root.join("app/pages/-helper.ts"),
1317            "export const helper = 1;",
1318        )
1319        .unwrap();
1320
1321        let config = FallowConfig {
1322            schema: None,
1323            extends: vec![],
1324            entry: vec![],
1325            ignore_patterns: vec![],
1326            framework: vec![],
1327            workspaces: None,
1328            ignore_dependencies: vec![],
1329            ignore_unresolved_imports: vec![],
1330            ignore_exports: vec![],
1331            ignore_catalog_references: vec![],
1332            ignore_dependency_overrides: vec![],
1333            ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1334            used_class_members: vec![],
1335            ignore_decorators: vec![],
1336            duplicates: fallow_config::DuplicatesConfig::default(),
1337            health: fallow_config::HealthConfig::default(),
1338            rules: RulesConfig::default(),
1339            boundaries: fallow_config::BoundaryConfig::default(),
1340            production: false.into(),
1341            plugins: vec![],
1342            dynamically_loaded: vec![],
1343            overrides: vec![],
1344            regression: None,
1345            audit: fallow_config::AuditConfig::default(),
1346            codeowners: None,
1347            public_packages: vec![],
1348            flags: fallow_config::FlagsConfig::default(),
1349            fix: fallow_config::FixConfig::default(),
1350            resolve: fallow_config::ResolveConfig::default(),
1351            sealed: false,
1352            include_entry_exports: false,
1353            auto_imports: false,
1354            cache: fallow_config::CacheConfig::default(),
1355        }
1356        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1357
1358        let files = vec![
1359            DiscoveredFile {
1360                id: FileId(0),
1361                path: root.join("app/pages/index.tsx"),
1362                size_bytes: 1,
1363            },
1364            DiscoveredFile {
1365                id: FileId(1),
1366                path: root.join("app/pages/-helper.ts"),
1367                size_bytes: 1,
1368            },
1369        ];
1370
1371        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1372        plugin_result.entry_patterns.push((
1373            crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1374                .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1375            "tanstack-router".to_string(),
1376        ));
1377        plugin_result
1378            .entry_point_roles
1379            .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1380
1381        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1382        let entry_paths: Vec<_> = entries
1383            .all
1384            .iter()
1385            .map(|entry| {
1386                entry
1387                    .path
1388                    .strip_prefix(root)
1389                    .unwrap()
1390                    .to_string_lossy()
1391                    .into_owned()
1392            })
1393            .collect();
1394
1395        assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1396        assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1397    }
1398
1399    // resolve_entry_path unit tests
1400    mod resolve_entry_path_tests {
1401        use super::*;
1402
1403        #[test]
1404        fn resolves_existing_file() {
1405            let dir = tempfile::tempdir().expect("create temp dir");
1406            let src = dir.path().join("src");
1407            std::fs::create_dir_all(&src).unwrap();
1408            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1409
1410            let canonical = dunce::canonicalize(dir.path()).unwrap();
1411            let result = resolve_entry_path(
1412                dir.path(),
1413                "src/index.ts",
1414                &canonical,
1415                EntryPointSource::PackageJsonMain,
1416            );
1417            assert!(result.is_some(), "should resolve an existing file");
1418            assert!(result.unwrap().path.ends_with("src/index.ts"));
1419        }
1420
1421        #[test]
1422        fn resolves_with_extension_fallback() {
1423            let dir = tempfile::tempdir().expect("create temp dir");
1424            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
1425            let canonical = dunce::canonicalize(dir.path()).unwrap();
1426            let src = canonical.join("src");
1427            std::fs::create_dir_all(&src).unwrap();
1428            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1429
1430            // Provide path without extension — should try adding .ts, .tsx, etc.
1431            let result = resolve_entry_path(
1432                &canonical,
1433                "src/index",
1434                &canonical,
1435                EntryPointSource::PackageJsonMain,
1436            );
1437            assert!(
1438                result.is_some(),
1439                "should resolve via extension fallback when exact path doesn't exist"
1440            );
1441            let ep = result.unwrap();
1442            assert!(
1443                ep.path.to_string_lossy().contains("index.ts"),
1444                "should find index.ts via extension fallback"
1445            );
1446        }
1447
1448        #[test]
1449        fn exact_file_wins_before_directory_index_fallback() {
1450            let dir = tempfile::tempdir().expect("create temp dir");
1451            let canonical = dunce::canonicalize(dir.path()).unwrap();
1452            let scripts = canonical.join("scripts");
1453            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1454            std::fs::write(
1455                scripts.join("process-messages.js"),
1456                "export const direct = true;",
1457            )
1458            .unwrap();
1459            std::fs::write(
1460                scripts.join("process-messages").join("index.js"),
1461                "export const index = true;",
1462            )
1463            .unwrap();
1464
1465            let result = resolve_entry_path(
1466                &canonical,
1467                "scripts/process-messages.js",
1468                &canonical,
1469                EntryPointSource::PackageJsonScript,
1470            )
1471            .expect("exact file should resolve");
1472
1473            assert!(result.path.ends_with("scripts/process-messages.js"));
1474        }
1475
1476        #[test]
1477        fn extension_fallback_wins_before_directory_index_fallback() {
1478            let dir = tempfile::tempdir().expect("create temp dir");
1479            let canonical = dunce::canonicalize(dir.path()).unwrap();
1480            let scripts = canonical.join("scripts");
1481            std::fs::create_dir_all(scripts.join("process-messages")).unwrap();
1482            std::fs::write(
1483                scripts.join("process-messages.ts"),
1484                "export const withExt = true;",
1485            )
1486            .unwrap();
1487            std::fs::write(
1488                scripts.join("process-messages").join("index.js"),
1489                "export const index = true;",
1490            )
1491            .unwrap();
1492
1493            let result = resolve_entry_path(
1494                &canonical,
1495                "scripts/process-messages",
1496                &canonical,
1497                EntryPointSource::PackageJsonScript,
1498            )
1499            .expect("extension fallback should resolve");
1500
1501            assert!(result.path.ends_with("scripts/process-messages.ts"));
1502        }
1503
1504        #[test]
1505        fn resolves_directory_index_after_exact_and_extension_fallbacks() {
1506            let dir = tempfile::tempdir().expect("create temp dir");
1507            let canonical = dunce::canonicalize(dir.path()).unwrap();
1508            let scripts = canonical.join("scripts/process-messages");
1509            std::fs::create_dir_all(&scripts).unwrap();
1510            std::fs::write(scripts.join("index.js"), "export const index = true;").unwrap();
1511
1512            let result = resolve_entry_path(
1513                &canonical,
1514                "scripts/process-messages",
1515                &canonical,
1516                EntryPointSource::PackageJsonScript,
1517            )
1518            .expect("directory index should resolve");
1519
1520            assert!(result.path.ends_with("scripts/process-messages/index.js"));
1521        }
1522
1523        #[test]
1524        fn directory_index_fallback_ignores_wildcards_and_url_like_entries() {
1525            let dir = tempfile::tempdir().expect("create temp dir");
1526            let canonical = dunce::canonicalize(dir.path()).unwrap();
1527            std::fs::create_dir_all(canonical.join("scripts/process-messages")).unwrap();
1528            std::fs::write(
1529                canonical.join("scripts/process-messages/index.js"),
1530                "export const index = true;",
1531            )
1532            .unwrap();
1533
1534            for entry in [
1535                "scripts/*",
1536                "https://example.com/scripts/process-messages",
1537                "@scope/package/scripts/process-messages",
1538            ] {
1539                let result = resolve_entry_path(
1540                    &canonical,
1541                    entry,
1542                    &canonical,
1543                    EntryPointSource::PackageJsonScript,
1544                );
1545                assert!(result.is_none(), "{entry} should not resolve");
1546            }
1547        }
1548
1549        #[test]
1550        fn returns_none_for_nonexistent_file() {
1551            let dir = tempfile::tempdir().expect("create temp dir");
1552            let canonical = dunce::canonicalize(dir.path()).unwrap();
1553            let result = resolve_entry_path(
1554                dir.path(),
1555                "does/not/exist.ts",
1556                &canonical,
1557                EntryPointSource::PackageJsonMain,
1558            );
1559            assert!(result.is_none(), "should return None for nonexistent files");
1560        }
1561
1562        #[test]
1563        fn maps_dist_output_to_src() {
1564            let dir = tempfile::tempdir().expect("create temp dir");
1565            let src = dir.path().join("src");
1566            std::fs::create_dir_all(&src).unwrap();
1567            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1568
1569            // Also create the dist/ file to make sure it prefers src/
1570            let dist = dir.path().join("dist");
1571            std::fs::create_dir_all(&dist).unwrap();
1572            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1573
1574            let canonical = dunce::canonicalize(dir.path()).unwrap();
1575            let result = resolve_entry_path(
1576                dir.path(),
1577                "./dist/utils.js",
1578                &canonical,
1579                EntryPointSource::PackageJsonExports,
1580            );
1581            assert!(result.is_some(), "should resolve dist/ path to src/");
1582            let ep = result.unwrap();
1583            assert!(
1584                ep.path
1585                    .to_string_lossy()
1586                    .replace('\\', "/")
1587                    .contains("src/utils.ts"),
1588                "should map ./dist/utils.js to src/utils.ts"
1589            );
1590        }
1591
1592        #[test]
1593        fn maps_build_output_to_src() {
1594            let dir = tempfile::tempdir().expect("create temp dir");
1595            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
1596            let canonical = dunce::canonicalize(dir.path()).unwrap();
1597            let src = canonical.join("src");
1598            std::fs::create_dir_all(&src).unwrap();
1599            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1600
1601            let result = resolve_entry_path(
1602                &canonical,
1603                "./build/index.js",
1604                &canonical,
1605                EntryPointSource::PackageJsonExports,
1606            );
1607            assert!(result.is_some(), "should map build/ output to src/");
1608            let ep = result.unwrap();
1609            assert!(
1610                ep.path
1611                    .to_string_lossy()
1612                    .replace('\\', "/")
1613                    .contains("src/index.tsx"),
1614                "should map ./build/index.js to src/index.tsx"
1615            );
1616        }
1617
1618        #[test]
1619        fn preserves_entry_point_source() {
1620            let dir = tempfile::tempdir().expect("create temp dir");
1621            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1622
1623            let canonical = dunce::canonicalize(dir.path()).unwrap();
1624            let result = resolve_entry_path(
1625                dir.path(),
1626                "index.ts",
1627                &canonical,
1628                EntryPointSource::PackageJsonScript,
1629            );
1630            assert!(result.is_some());
1631            assert!(
1632                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1633                "should preserve the source kind"
1634            );
1635        }
1636        #[test]
1637        fn tracks_skipped_entries_without_logging_each_repeat() {
1638            let dir = tempfile::tempdir().expect("create temp dir");
1639            let canonical = dunce::canonicalize(dir.path()).unwrap();
1640            let mut skipped_entries = FxHashMap::default();
1641
1642            let result = resolve_entry_path_with_tracking(
1643                dir.path(),
1644                "../scripts/build.js",
1645                &canonical,
1646                EntryPointSource::PackageJsonScript,
1647                Some(&mut skipped_entries),
1648            );
1649
1650            assert!(result.is_none(), "unsafe entry should be skipped");
1651            assert_eq!(
1652                skipped_entries.get("../scripts/build.js"),
1653                Some(&1),
1654                "warning tracker should count the skipped path"
1655            );
1656        }
1657
1658        #[test]
1659        fn formats_skipped_entry_warning_with_counts() {
1660            let mut skipped_entries = FxHashMap::default();
1661            skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
1662            skipped_entries.insert("../utils/bar.js".to_owned(), 2);
1663
1664            let warning =
1665                format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
1666
1667            assert_eq!(
1668                warning,
1669                "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
1670            );
1671        }
1672
1673        #[test]
1674        fn skipped_entry_summary_dedupes_identical_messages() {
1675            // The summary is emitted once per workspace package; on a monorepo
1676            // many packages skip the same out-of-root binary. A unique message
1677            // suffix keeps this test independent of the process-wide set's
1678            // state across sibling tests in the same binary.
1679            let message = format!(
1680                "Skipped 1 package.json entry point outside project root: ../../pkg-{}/bin/x",
1681                std::process::id()
1682            );
1683            assert!(
1684                should_warn_skipped_entry(&message),
1685                "first occurrence of a message emits"
1686            );
1687            assert!(
1688                !should_warn_skipped_entry(&message),
1689                "identical repeat is suppressed"
1690            );
1691        }
1692
1693        #[test]
1694        fn rejects_parent_dir_escape_for_exact_file() {
1695            let sandbox = tempfile::tempdir().expect("create sandbox");
1696            let root = sandbox.path().join("project");
1697            std::fs::create_dir_all(&root).unwrap();
1698            std::fs::write(
1699                sandbox.path().join("escape.ts"),
1700                "export const escape = true;",
1701            )
1702            .unwrap();
1703
1704            let canonical = dunce::canonicalize(&root).unwrap();
1705            let result = resolve_entry_path(
1706                &root,
1707                "../escape.ts",
1708                &canonical,
1709                EntryPointSource::PackageJsonMain,
1710            );
1711
1712            assert!(
1713                result.is_none(),
1714                "should reject exact paths that escape the root"
1715            );
1716        }
1717
1718        #[test]
1719        fn rejects_parent_dir_escape_via_extension_fallback() {
1720            let sandbox = tempfile::tempdir().expect("create sandbox");
1721            let root = sandbox.path().join("project");
1722            std::fs::create_dir_all(&root).unwrap();
1723            std::fs::write(
1724                sandbox.path().join("escape.ts"),
1725                "export const escape = true;",
1726            )
1727            .unwrap();
1728
1729            let canonical = dunce::canonicalize(&root).unwrap();
1730            let result = resolve_entry_path(
1731                &root,
1732                "../escape",
1733                &canonical,
1734                EntryPointSource::PackageJsonMain,
1735            );
1736
1737            assert!(
1738                result.is_none(),
1739                "should reject extension fallback paths that escape the root"
1740            );
1741        }
1742    }
1743
1744    // try_output_to_source_path unit tests
1745    mod output_to_source_tests {
1746        use super::*;
1747
1748        #[test]
1749        fn maps_dist_to_src_with_ts_extension() {
1750            let dir = tempfile::tempdir().expect("create temp dir");
1751            let src = dir.path().join("src");
1752            std::fs::create_dir_all(&src).unwrap();
1753            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1754
1755            let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1756            assert!(result.is_some());
1757            assert!(
1758                result
1759                    .unwrap()
1760                    .to_string_lossy()
1761                    .replace('\\', "/")
1762                    .contains("src/utils.ts")
1763            );
1764        }
1765
1766        #[test]
1767        fn returns_none_when_no_source_file_exists() {
1768            let dir = tempfile::tempdir().expect("create temp dir");
1769            // No src/ directory at all
1770            let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1771            assert!(result.is_none());
1772        }
1773
1774        #[test]
1775        fn ignores_non_output_directories() {
1776            let dir = tempfile::tempdir().expect("create temp dir");
1777            let src = dir.path().join("src");
1778            std::fs::create_dir_all(&src).unwrap();
1779            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1780
1781            // "lib" is not in OUTPUT_DIRS, so no mapping should occur
1782            let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1783            assert!(result.is_none());
1784        }
1785
1786        #[test]
1787        fn maps_nested_output_path_preserving_prefix() {
1788            let dir = tempfile::tempdir().expect("create temp dir");
1789            let modules_src = dir.path().join("modules").join("src");
1790            std::fs::create_dir_all(&modules_src).unwrap();
1791            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1792
1793            let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1794            assert!(result.is_some());
1795            assert!(
1796                result
1797                    .unwrap()
1798                    .to_string_lossy()
1799                    .replace('\\', "/")
1800                    .contains("modules/src/helper.ts")
1801            );
1802        }
1803    }
1804
1805    // Source index fallback unit tests (issue #102)
1806    mod source_index_fallback_tests {
1807        use super::*;
1808
1809        #[test]
1810        fn detects_dist_entry_in_output_dir() {
1811            assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1812            assert!(is_entry_in_output_dir("dist/index.js"));
1813            assert!(is_entry_in_output_dir("./build/index.js"));
1814            assert!(is_entry_in_output_dir("./out/main.js"));
1815            assert!(is_entry_in_output_dir("./esm/index.js"));
1816            assert!(is_entry_in_output_dir("./cjs/index.js"));
1817        }
1818
1819        #[test]
1820        fn rejects_non_output_entry_paths() {
1821            assert!(!is_entry_in_output_dir("./src/index.ts"));
1822            assert!(!is_entry_in_output_dir("src/main.ts"));
1823            assert!(!is_entry_in_output_dir("./index.js"));
1824            assert!(!is_entry_in_output_dir(""));
1825        }
1826
1827        #[test]
1828        fn root_index_entries_are_recognized_for_source_fallback() {
1829            assert!(is_package_root_index_entry("./index.js"));
1830            assert!(is_package_root_index_entry("index.cjs"));
1831            assert!(is_package_root_index_entry("./index.d.ts"));
1832            assert!(!is_package_root_index_entry("./src/index.js"));
1833            assert!(!is_package_root_index_entry("./main.js"));
1834            assert!(!is_package_root_index_entry(""));
1835        }
1836
1837        #[test]
1838        fn rejects_substring_match_for_output_dir() {
1839            // "distro" contains "dist" as a substring but is not an output dir
1840            assert!(!is_entry_in_output_dir("./distro/index.js"));
1841            assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1842        }
1843
1844        #[test]
1845        fn finds_src_index_ts() {
1846            let dir = tempfile::tempdir().expect("create temp dir");
1847            let src = dir.path().join("src");
1848            std::fs::create_dir_all(&src).unwrap();
1849            let index_path = src.join("index.ts");
1850            std::fs::write(&index_path, "export const a = 1;").unwrap();
1851
1852            let result = try_source_index_fallback(dir.path());
1853            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1854        }
1855
1856        #[test]
1857        fn finds_src_index_tsx_when_ts_missing() {
1858            let dir = tempfile::tempdir().expect("create temp dir");
1859            let src = dir.path().join("src");
1860            std::fs::create_dir_all(&src).unwrap();
1861            let index_path = src.join("index.tsx");
1862            std::fs::write(&index_path, "export default 1;").unwrap();
1863
1864            let result = try_source_index_fallback(dir.path());
1865            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1866        }
1867
1868        #[test]
1869        fn prefers_src_index_over_root_index() {
1870            // Source index fallback must prefer `src/index.*` over root-level `index.*`
1871            // because library conventions keep source under `src/`.
1872            let dir = tempfile::tempdir().expect("create temp dir");
1873            let src = dir.path().join("src");
1874            std::fs::create_dir_all(&src).unwrap();
1875            let src_index = src.join("index.ts");
1876            std::fs::write(&src_index, "export const a = 1;").unwrap();
1877            let root_index = dir.path().join("index.ts");
1878            std::fs::write(&root_index, "export const b = 2;").unwrap();
1879
1880            let result = try_source_index_fallback(dir.path());
1881            assert_eq!(result.as_deref(), Some(src_index.as_path()));
1882        }
1883
1884        #[test]
1885        fn falls_back_to_src_main() {
1886            let dir = tempfile::tempdir().expect("create temp dir");
1887            let src = dir.path().join("src");
1888            std::fs::create_dir_all(&src).unwrap();
1889            let main_path = src.join("main.ts");
1890            std::fs::write(&main_path, "export const a = 1;").unwrap();
1891
1892            let result = try_source_index_fallback(dir.path());
1893            assert_eq!(result.as_deref(), Some(main_path.as_path()));
1894        }
1895
1896        #[test]
1897        fn falls_back_to_root_index_when_no_src() {
1898            let dir = tempfile::tempdir().expect("create temp dir");
1899            let index_path = dir.path().join("index.js");
1900            std::fs::write(&index_path, "module.exports = {};").unwrap();
1901
1902            let result = try_source_index_fallback(dir.path());
1903            assert_eq!(result.as_deref(), Some(index_path.as_path()));
1904        }
1905
1906        #[test]
1907        fn returns_none_when_nothing_matches() {
1908            let dir = tempfile::tempdir().expect("create temp dir");
1909            let result = try_source_index_fallback(dir.path());
1910            assert!(result.is_none());
1911        }
1912
1913        #[test]
1914        fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1915            let dir = tempfile::tempdir().expect("create temp dir");
1916            let canonical = dunce::canonicalize(dir.path()).unwrap();
1917
1918            // dist/esm2022/index.js exists but there's no src/esm2022/ mirror —
1919            // only src/index.ts. Without the fallback, resolve_entry_path would
1920            // return the dist file, which then gets filtered out by the ignore
1921            // pattern.
1922            let dist_dir = canonical.join("dist").join("esm2022");
1923            std::fs::create_dir_all(&dist_dir).unwrap();
1924            std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1925
1926            let src = canonical.join("src");
1927            std::fs::create_dir_all(&src).unwrap();
1928            let src_index = src.join("index.ts");
1929            std::fs::write(&src_index, "export const x = 1;").unwrap();
1930
1931            let result = resolve_entry_path(
1932                &canonical,
1933                "./dist/esm2022/index.js",
1934                &canonical,
1935                EntryPointSource::PackageJsonMain,
1936            );
1937            assert!(result.is_some());
1938            let entry = result.unwrap();
1939            assert_eq!(entry.path, src_index);
1940        }
1941
1942        #[test]
1943        fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1944            // When `src/esm2022/index.ts` exists, the existing mirror logic wins
1945            // and the fallback should not fire.
1946            let dir = tempfile::tempdir().expect("create temp dir");
1947            let canonical = dunce::canonicalize(dir.path()).unwrap();
1948
1949            let src_mirror = canonical.join("src").join("esm2022");
1950            std::fs::create_dir_all(&src_mirror).unwrap();
1951            let mirror_index = src_mirror.join("index.ts");
1952            std::fs::write(&mirror_index, "export const x = 1;").unwrap();
1953
1954            // Also create src/index.ts to confirm the mirror wins over the fallback.
1955            let src_index = canonical.join("src").join("index.ts");
1956            std::fs::write(&src_index, "export const y = 2;").unwrap();
1957
1958            let result = resolve_entry_path(
1959                &canonical,
1960                "./dist/esm2022/index.js",
1961                &canonical,
1962                EntryPointSource::PackageJsonMain,
1963            );
1964            assert_eq!(result.map(|e| e.path), Some(mirror_index));
1965        }
1966
1967        #[test]
1968        fn resolve_entry_path_falls_back_to_src_index_for_missing_root_index() {
1969            let dir = tempfile::tempdir().expect("create temp dir");
1970            let canonical = dunce::canonicalize(dir.path()).unwrap();
1971
1972            let src = canonical.join("src");
1973            std::fs::create_dir_all(&src).unwrap();
1974            let src_index = src.join("index.ts");
1975            std::fs::write(&src_index, "export const x = 1;").unwrap();
1976
1977            let result = resolve_entry_path(
1978                &canonical,
1979                "./index.js",
1980                &canonical,
1981                EntryPointSource::PackageJsonMain,
1982            );
1983            assert_eq!(result.map(|entry| entry.path), Some(src_index));
1984        }
1985    }
1986
1987    // apply_default_fallback unit tests
1988    mod default_fallback_tests {
1989        use super::*;
1990
1991        #[test]
1992        fn finds_src_index_ts_as_fallback() {
1993            let dir = tempfile::tempdir().expect("create temp dir");
1994            let src = dir.path().join("src");
1995            std::fs::create_dir_all(&src).unwrap();
1996            let index_path = src.join("index.ts");
1997            std::fs::write(&index_path, "export const a = 1;").unwrap();
1998
1999            let files = vec![DiscoveredFile {
2000                id: FileId(0),
2001                path: index_path.clone(),
2002                size_bytes: 20,
2003            }];
2004
2005            let entries = apply_default_fallback(&files, dir.path(), None);
2006            assert_eq!(entries.len(), 1);
2007            assert_eq!(entries[0].path, index_path);
2008            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
2009        }
2010
2011        #[test]
2012        fn finds_root_index_js_as_fallback() {
2013            let dir = tempfile::tempdir().expect("create temp dir");
2014            let index_path = dir.path().join("index.js");
2015            std::fs::write(&index_path, "module.exports = {};").unwrap();
2016
2017            let files = vec![DiscoveredFile {
2018                id: FileId(0),
2019                path: index_path.clone(),
2020                size_bytes: 21,
2021            }];
2022
2023            let entries = apply_default_fallback(&files, dir.path(), None);
2024            assert_eq!(entries.len(), 1);
2025            assert_eq!(entries[0].path, index_path);
2026        }
2027
2028        #[test]
2029        fn returns_empty_when_no_index_file() {
2030            let dir = tempfile::tempdir().expect("create temp dir");
2031            let other_path = dir.path().join("src").join("utils.ts");
2032
2033            let files = vec![DiscoveredFile {
2034                id: FileId(0),
2035                path: other_path,
2036                size_bytes: 10,
2037            }];
2038
2039            let entries = apply_default_fallback(&files, dir.path(), None);
2040            assert!(
2041                entries.is_empty(),
2042                "non-index files should not match default fallback"
2043            );
2044        }
2045
2046        #[test]
2047        fn workspace_filter_restricts_scope() {
2048            let dir = tempfile::tempdir().expect("create temp dir");
2049            let ws_a = dir.path().join("packages").join("a").join("src");
2050            std::fs::create_dir_all(&ws_a).unwrap();
2051            let ws_b = dir.path().join("packages").join("b").join("src");
2052            std::fs::create_dir_all(&ws_b).unwrap();
2053
2054            let index_a = ws_a.join("index.ts");
2055            let index_b = ws_b.join("index.ts");
2056
2057            let files = vec![
2058                DiscoveredFile {
2059                    id: FileId(0),
2060                    path: index_a.clone(),
2061                    size_bytes: 10,
2062                },
2063                DiscoveredFile {
2064                    id: FileId(1),
2065                    path: index_b,
2066                    size_bytes: 10,
2067                },
2068            ];
2069
2070            // Filter to workspace A only
2071            let ws_root = dir.path().join("packages").join("a");
2072            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
2073            assert_eq!(entries.len(), 1);
2074            assert_eq!(entries[0].path, index_a);
2075        }
2076    }
2077
2078    // expand_wildcard_entries unit tests
2079    mod wildcard_entry_tests {
2080        use super::*;
2081
2082        #[test]
2083        fn expands_wildcard_css_entries() {
2084            // Wildcard subpath exports like `"./themes/*": { "import": "./src/themes/*.css" }`
2085            // should expand to actual CSS files on disk.
2086            let dir = tempfile::tempdir().expect("create temp dir");
2087            let themes = dir.path().join("src").join("themes");
2088            std::fs::create_dir_all(&themes).unwrap();
2089            std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
2090            std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
2091
2092            let canonical = dunce::canonicalize(dir.path()).unwrap();
2093            let mut entries = Vec::new();
2094            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2095
2096            assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
2097            let paths: Vec<String> = entries
2098                .iter()
2099                .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
2100                .collect();
2101            assert!(paths.contains(&"dark.css".to_string()));
2102            assert!(paths.contains(&"light.css".to_string()));
2103            assert!(
2104                entries
2105                    .iter()
2106                    .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
2107            );
2108        }
2109
2110        #[test]
2111        fn wildcard_does_not_match_nonexistent_files() {
2112            let dir = tempfile::tempdir().expect("create temp dir");
2113            // No files matching the pattern
2114            std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
2115
2116            let canonical = dunce::canonicalize(dir.path()).unwrap();
2117            let mut entries = Vec::new();
2118            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2119
2120            assert!(
2121                entries.is_empty(),
2122                "should return empty when no files match the wildcard"
2123            );
2124        }
2125
2126        #[test]
2127        fn wildcard_only_matches_specified_extension() {
2128            // Wildcard pattern `*.css` should not match `.ts` files
2129            let dir = tempfile::tempdir().expect("create temp dir");
2130            let themes = dir.path().join("src").join("themes");
2131            std::fs::create_dir_all(&themes).unwrap();
2132            std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
2133            std::fs::write(themes.join("index.ts"), "export {};").unwrap();
2134
2135            let canonical = dunce::canonicalize(dir.path()).unwrap();
2136            let mut entries = Vec::new();
2137            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
2138
2139            assert_eq!(entries.len(), 1, "should only match CSS files");
2140            assert!(
2141                entries[0]
2142                    .path
2143                    .file_name()
2144                    .unwrap()
2145                    .to_string_lossy()
2146                    .ends_with(".css")
2147            );
2148        }
2149    }
2150}