Skip to main content

fallow_core/discover/
entry_points.rs

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