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