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