Skip to main content

fallow_core/plugins/
registry.rs

1//! Plugin registry: discovers active plugins, collects patterns, parses configs.
2#![expect(clippy::excessive_nesting)]
3
4use rustc_hash::FxHashSet;
5use std::path::{Path, PathBuf};
6
7use fallow_config::{ExternalPluginDef, PackageJson, PluginDetection};
8
9use super::{Plugin, PluginResult};
10
11// Import all plugin structs
12use super::angular::AngularPlugin;
13use super::astro::AstroPlugin;
14use super::ava::AvaPlugin;
15use super::babel::BabelPlugin;
16use super::biome::BiomePlugin;
17use super::bun::BunPlugin;
18use super::c8::C8Plugin;
19use super::capacitor::CapacitorPlugin;
20use super::changesets::ChangesetsPlugin;
21use super::commitizen::CommitizenPlugin;
22use super::commitlint::CommitlintPlugin;
23use super::cspell::CspellPlugin;
24use super::cucumber::CucumberPlugin;
25use super::cypress::CypressPlugin;
26use super::dependency_cruiser::DependencyCruiserPlugin;
27use super::docusaurus::DocusaurusPlugin;
28use super::drizzle::DrizzlePlugin;
29use super::electron::ElectronPlugin;
30use super::eslint::EslintPlugin;
31use super::expo::ExpoPlugin;
32use super::gatsby::GatsbyPlugin;
33use super::graphql_codegen::GraphqlCodegenPlugin;
34use super::husky::HuskyPlugin;
35use super::i18next::I18nextPlugin;
36use super::jest::JestPlugin;
37use super::karma::KarmaPlugin;
38use super::knex::KnexPlugin;
39use super::kysely::KyselyPlugin;
40use super::lefthook::LefthookPlugin;
41use super::lint_staged::LintStagedPlugin;
42use super::markdownlint::MarkdownlintPlugin;
43use super::mocha::MochaPlugin;
44use super::msw::MswPlugin;
45use super::nestjs::NestJsPlugin;
46use super::next_intl::NextIntlPlugin;
47use super::nextjs::NextJsPlugin;
48use super::nitro::NitroPlugin;
49use super::nodemon::NodemonPlugin;
50use super::nuxt::NuxtPlugin;
51use super::nx::NxPlugin;
52use super::nyc::NycPlugin;
53use super::openapi_ts::OpenapiTsPlugin;
54use super::oxlint::OxlintPlugin;
55use super::parcel::ParcelPlugin;
56use super::playwright::PlaywrightPlugin;
57use super::plop::PlopPlugin;
58use super::pm2::Pm2Plugin;
59use super::postcss::PostCssPlugin;
60use super::prettier::PrettierPlugin;
61use super::prisma::PrismaPlugin;
62use super::react_native::ReactNativePlugin;
63use super::react_router::ReactRouterPlugin;
64use super::relay::RelayPlugin;
65use super::remark::RemarkPlugin;
66use super::remix::RemixPlugin;
67use super::rolldown::RolldownPlugin;
68use super::rollup::RollupPlugin;
69use super::rsbuild::RsbuildPlugin;
70use super::rspack::RspackPlugin;
71use super::sanity::SanityPlugin;
72use super::semantic_release::SemanticReleasePlugin;
73use super::sentry::SentryPlugin;
74use super::simple_git_hooks::SimpleGitHooksPlugin;
75use super::storybook::StorybookPlugin;
76use super::stylelint::StylelintPlugin;
77use super::sveltekit::SvelteKitPlugin;
78use super::svgo::SvgoPlugin;
79use super::svgr::SvgrPlugin;
80use super::swc::SwcPlugin;
81use super::syncpack::SyncpackPlugin;
82use super::tailwind::TailwindPlugin;
83use super::tanstack_router::TanstackRouterPlugin;
84use super::tsdown::TsdownPlugin;
85use super::tsup::TsupPlugin;
86use super::turborepo::TurborepoPlugin;
87use super::typedoc::TypedocPlugin;
88use super::typeorm::TypeormPlugin;
89use super::typescript::TypeScriptPlugin;
90use super::vite::VitePlugin;
91use super::vitepress::VitePressPlugin;
92use super::vitest::VitestPlugin;
93use super::webdriverio::WebdriverioPlugin;
94use super::webpack::WebpackPlugin;
95use super::wrangler::WranglerPlugin;
96
97/// Registry of all available plugins (built-in + external).
98pub struct PluginRegistry {
99    plugins: Vec<Box<dyn Plugin>>,
100    external_plugins: Vec<ExternalPluginDef>,
101}
102
103/// Aggregated results from all active plugins for a project.
104#[derive(Debug, Default)]
105pub struct AggregatedPluginResult {
106    /// All entry point patterns from active plugins: (pattern, plugin_name).
107    pub entry_patterns: Vec<(String, String)>,
108    /// All config file patterns from active plugins.
109    pub config_patterns: Vec<String>,
110    /// All always-used file patterns from active plugins: (pattern, plugin_name).
111    pub always_used: Vec<(String, String)>,
112    /// All used export rules from active plugins.
113    pub used_exports: Vec<(String, Vec<String>)>,
114    /// Dependencies referenced in config files (should not be flagged unused).
115    pub referenced_dependencies: Vec<String>,
116    /// Additional always-used files discovered from config parsing: (pattern, plugin_name).
117    pub discovered_always_used: Vec<(String, String)>,
118    /// Setup files discovered from config parsing: (path, plugin_name).
119    pub setup_files: Vec<(PathBuf, String)>,
120    /// Tooling dependencies (should not be flagged as unused devDeps).
121    pub tooling_dependencies: Vec<String>,
122    /// Package names discovered as used in package.json scripts (binary invocations).
123    pub script_used_packages: FxHashSet<String>,
124    /// Import prefixes for virtual modules provided by active frameworks.
125    /// Imports matching these prefixes should not be flagged as unlisted dependencies.
126    pub virtual_module_prefixes: Vec<String>,
127    /// Path alias mappings from active plugins (prefix → replacement directory).
128    /// Used by the resolver to substitute import prefixes before re-resolving.
129    pub path_aliases: Vec<(String, String)>,
130    /// Names of active plugins.
131    pub active_plugins: Vec<String>,
132}
133
134impl PluginRegistry {
135    /// Create a registry with all built-in plugins and optional external plugins.
136    pub fn new(external: Vec<ExternalPluginDef>) -> Self {
137        let plugins: Vec<Box<dyn Plugin>> = vec![
138            // Frameworks
139            Box::new(NextJsPlugin),
140            Box::new(NuxtPlugin),
141            Box::new(RemixPlugin),
142            Box::new(AstroPlugin),
143            Box::new(AngularPlugin),
144            Box::new(ReactRouterPlugin),
145            Box::new(TanstackRouterPlugin),
146            Box::new(ReactNativePlugin),
147            Box::new(ExpoPlugin),
148            Box::new(NestJsPlugin),
149            Box::new(DocusaurusPlugin),
150            Box::new(GatsbyPlugin),
151            Box::new(SvelteKitPlugin),
152            Box::new(NitroPlugin),
153            Box::new(CapacitorPlugin),
154            Box::new(SanityPlugin),
155            Box::new(VitePressPlugin),
156            Box::new(NextIntlPlugin),
157            Box::new(RelayPlugin),
158            Box::new(ElectronPlugin),
159            Box::new(I18nextPlugin),
160            // Bundlers
161            Box::new(VitePlugin),
162            Box::new(WebpackPlugin),
163            Box::new(RollupPlugin),
164            Box::new(RolldownPlugin),
165            Box::new(RspackPlugin),
166            Box::new(RsbuildPlugin),
167            Box::new(TsupPlugin),
168            Box::new(TsdownPlugin),
169            Box::new(ParcelPlugin),
170            // Testing
171            Box::new(VitestPlugin),
172            Box::new(JestPlugin),
173            Box::new(PlaywrightPlugin),
174            Box::new(CypressPlugin),
175            Box::new(MochaPlugin),
176            Box::new(AvaPlugin),
177            Box::new(StorybookPlugin),
178            Box::new(KarmaPlugin),
179            Box::new(CucumberPlugin),
180            Box::new(WebdriverioPlugin),
181            // Linting & formatting
182            Box::new(EslintPlugin),
183            Box::new(BiomePlugin),
184            Box::new(StylelintPlugin),
185            Box::new(PrettierPlugin),
186            Box::new(OxlintPlugin),
187            Box::new(MarkdownlintPlugin),
188            Box::new(CspellPlugin),
189            Box::new(RemarkPlugin),
190            // Transpilation & language
191            Box::new(TypeScriptPlugin),
192            Box::new(BabelPlugin),
193            Box::new(SwcPlugin),
194            // CSS
195            Box::new(TailwindPlugin),
196            Box::new(PostCssPlugin),
197            // Database & ORM
198            Box::new(PrismaPlugin),
199            Box::new(DrizzlePlugin),
200            Box::new(KnexPlugin),
201            Box::new(TypeormPlugin),
202            Box::new(KyselyPlugin),
203            // Monorepo
204            Box::new(TurborepoPlugin),
205            Box::new(NxPlugin),
206            Box::new(ChangesetsPlugin),
207            Box::new(SyncpackPlugin),
208            // CI/CD & release
209            Box::new(CommitlintPlugin),
210            Box::new(CommitizenPlugin),
211            Box::new(SemanticReleasePlugin),
212            // Deployment
213            Box::new(WranglerPlugin),
214            Box::new(SentryPlugin),
215            // Git hooks
216            Box::new(HuskyPlugin),
217            Box::new(LintStagedPlugin),
218            Box::new(LefthookPlugin),
219            Box::new(SimpleGitHooksPlugin),
220            // Media & assets
221            Box::new(SvgoPlugin),
222            Box::new(SvgrPlugin),
223            // Code generation & docs
224            Box::new(GraphqlCodegenPlugin),
225            Box::new(TypedocPlugin),
226            Box::new(OpenapiTsPlugin),
227            Box::new(PlopPlugin),
228            // Coverage
229            Box::new(C8Plugin),
230            Box::new(NycPlugin),
231            // Other tools
232            Box::new(MswPlugin),
233            Box::new(NodemonPlugin),
234            Box::new(Pm2Plugin),
235            Box::new(DependencyCruiserPlugin),
236            // Runtime
237            Box::new(BunPlugin),
238        ];
239        Self {
240            plugins,
241            external_plugins: external,
242        }
243    }
244
245    /// Run all plugins against a project, returning aggregated results.
246    ///
247    /// This discovers which plugins are active, collects their static patterns,
248    /// then parses any config files to extract dynamic information.
249    pub fn run(
250        &self,
251        pkg: &PackageJson,
252        root: &Path,
253        discovered_files: &[PathBuf],
254    ) -> AggregatedPluginResult {
255        let _span = tracing::info_span!("run_plugins").entered();
256        let mut result = AggregatedPluginResult::default();
257
258        // Phase 1: Determine which plugins are active
259        // Compute deps once to avoid repeated Vec<String> allocation per plugin
260        let all_deps = pkg.all_dependency_names();
261        let active: Vec<&dyn Plugin> = self
262            .plugins
263            .iter()
264            .filter(|p| p.is_enabled_with_deps(&all_deps, root))
265            .map(|p| p.as_ref())
266            .collect();
267
268        tracing::info!(
269            plugins = active
270                .iter()
271                .map(|p| p.name())
272                .collect::<Vec<_>>()
273                .join(", "),
274            "active plugins"
275        );
276
277        // Phase 2: Collect static patterns from active plugins
278        for plugin in &active {
279            process_static_patterns(*plugin, root, &mut result);
280        }
281
282        // Phase 2b: Process external plugins (includes inline framework definitions)
283        process_external_plugins(
284            &self.external_plugins,
285            &all_deps,
286            root,
287            discovered_files,
288            &mut result,
289        );
290
291        // Phase 3: Find and parse config files for dynamic resolution
292        // Pre-compile all config patterns
293        let config_matchers: Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> = active
294            .iter()
295            .filter(|p| !p.config_patterns().is_empty())
296            .map(|p| {
297                let matchers: Vec<globset::GlobMatcher> = p
298                    .config_patterns()
299                    .iter()
300                    .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
301                    .collect();
302                (*p, matchers)
303            })
304            .collect();
305
306        // Build relative paths for matching (used by Phase 3 and 4)
307        let relative_files: Vec<(&PathBuf, String)> = discovered_files
308            .iter()
309            .map(|f| {
310                let rel = f
311                    .strip_prefix(root)
312                    .unwrap_or(f)
313                    .to_string_lossy()
314                    .into_owned();
315                (f, rel)
316            })
317            .collect();
318
319        if !config_matchers.is_empty() {
320            // Phase 3a: Match config files from discovered source files
321            let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
322
323            for (plugin, matchers) in &config_matchers {
324                for (abs_path, rel_path) in &relative_files {
325                    if matchers.iter().any(|m| m.is_match(rel_path.as_str())) {
326                        // Mark as resolved regardless of result to prevent Phase 3b
327                        // from re-parsing a JSON config for the same plugin.
328                        resolved_plugins.insert(plugin.name());
329                        if let Ok(source) = std::fs::read_to_string(abs_path) {
330                            let plugin_result = plugin.resolve_config(abs_path, &source, root);
331                            if !plugin_result.is_empty() {
332                                tracing::debug!(
333                                    plugin = plugin.name(),
334                                    config = rel_path.as_str(),
335                                    entries = plugin_result.entry_patterns.len(),
336                                    deps = plugin_result.referenced_dependencies.len(),
337                                    "resolved config"
338                                );
339                                process_config_result(plugin.name(), plugin_result, &mut result);
340                            }
341                        }
342                    }
343                }
344            }
345
346            // Phase 3b: Filesystem fallback for JSON config files.
347            // JSON files (angular.json, project.json) are not in the discovered file set
348            // because fallow only discovers JS/TS/CSS/Vue/etc. files.
349            let json_configs = discover_json_config_files(
350                &config_matchers,
351                &resolved_plugins,
352                &relative_files,
353                root,
354            );
355            for (abs_path, plugin) in &json_configs {
356                if let Ok(source) = std::fs::read_to_string(abs_path) {
357                    let plugin_result = plugin.resolve_config(abs_path, &source, root);
358                    if !plugin_result.is_empty() {
359                        let rel = abs_path
360                            .strip_prefix(root)
361                            .map(|p| p.to_string_lossy())
362                            .unwrap_or_default();
363                        tracing::debug!(
364                            plugin = plugin.name(),
365                            config = %rel,
366                            entries = plugin_result.entry_patterns.len(),
367                            deps = plugin_result.referenced_dependencies.len(),
368                            "resolved config (filesystem fallback)"
369                        );
370                        process_config_result(plugin.name(), plugin_result, &mut result);
371                    }
372                }
373            }
374        }
375
376        // Phase 4: Package.json inline config fallback
377        // For plugins that define `package_json_config_key()`, check if the root
378        // package.json contains that key and no standalone config file was found.
379        for plugin in &active {
380            if let Some(key) = plugin.package_json_config_key()
381                && !check_has_config_file(*plugin, &config_matchers, &relative_files)
382            {
383                // Try to extract the key from package.json
384                let pkg_path = root.join("package.json");
385                if let Ok(content) = std::fs::read_to_string(&pkg_path)
386                    && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
387                    && let Some(config_value) = json.get(key)
388                {
389                    let config_json = serde_json::to_string(config_value).unwrap_or_default();
390                    let fake_path = root.join(format!("{key}.config.json"));
391                    let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
392                    if !plugin_result.is_empty() {
393                        tracing::debug!(
394                            plugin = plugin.name(),
395                            key = key,
396                            "resolved inline package.json config"
397                        );
398                        process_config_result(plugin.name(), plugin_result, &mut result);
399                    }
400                }
401            }
402        }
403
404        result
405    }
406
407    /// Fast variant of `run()` for workspace packages.
408    ///
409    /// Reuses pre-compiled config matchers and pre-computed relative files from the root
410    /// project run, avoiding repeated glob compilation and path computation per workspace.
411    /// Skips external plugins (they only activate at root level) and package.json inline
412    /// config (workspace packages rarely have inline configs).
413    pub fn run_workspace_fast(
414        &self,
415        pkg: &PackageJson,
416        root: &Path,
417        project_root: &Path,
418        precompiled_config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
419        relative_files: &[(&PathBuf, String)],
420    ) -> AggregatedPluginResult {
421        let _span = tracing::info_span!("run_plugins").entered();
422        let mut result = AggregatedPluginResult::default();
423
424        // Phase 1: Determine which plugins are active (with pre-computed deps)
425        let all_deps = pkg.all_dependency_names();
426        let active: Vec<&dyn Plugin> = self
427            .plugins
428            .iter()
429            .filter(|p| p.is_enabled_with_deps(&all_deps, root))
430            .map(|p| p.as_ref())
431            .collect();
432
433        tracing::info!(
434            plugins = active
435                .iter()
436                .map(|p| p.name())
437                .collect::<Vec<_>>()
438                .join(", "),
439            "active plugins"
440        );
441
442        // Early exit if no plugins are active (common for leaf workspace packages)
443        if active.is_empty() {
444            return result;
445        }
446
447        // Phase 2: Collect static patterns from active plugins
448        for plugin in &active {
449            process_static_patterns(*plugin, root, &mut result);
450        }
451
452        // Phase 3: Find and parse config files using pre-compiled matchers
453        // Only check matchers for plugins that are active in this workspace
454        let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
455        let workspace_matchers: Vec<_> = precompiled_config_matchers
456            .iter()
457            .filter(|(p, _)| active_names.contains(p.name()))
458            .collect();
459
460        let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
461        if !workspace_matchers.is_empty() {
462            for (plugin, matchers) in &workspace_matchers {
463                for (abs_path, rel_path) in relative_files {
464                    if matchers.iter().any(|m| m.is_match(rel_path.as_str()))
465                        && let Ok(source) = std::fs::read_to_string(abs_path)
466                    {
467                        // Mark resolved regardless of result to prevent Phase 3b
468                        // from re-parsing a JSON config for the same plugin.
469                        resolved_ws_plugins.insert(plugin.name());
470                        let plugin_result = plugin.resolve_config(abs_path, &source, root);
471                        if !plugin_result.is_empty() {
472                            tracing::debug!(
473                                plugin = plugin.name(),
474                                config = rel_path.as_str(),
475                                entries = plugin_result.entry_patterns.len(),
476                                deps = plugin_result.referenced_dependencies.len(),
477                                "resolved config"
478                            );
479                            process_config_result(plugin.name(), plugin_result, &mut result);
480                        }
481                    }
482                }
483            }
484        }
485
486        // Phase 3b: Filesystem fallback for JSON config files at the project root.
487        // Config files like angular.json live at the monorepo root, but Angular is
488        // only active in workspace packages. Check the project root for unresolved
489        // config patterns.
490        let mut ws_json_configs: Vec<(PathBuf, &dyn Plugin)> = Vec::new();
491        let mut ws_seen_paths: FxHashSet<PathBuf> = FxHashSet::default();
492        for plugin in &active {
493            if resolved_ws_plugins.contains(plugin.name()) || plugin.config_patterns().is_empty() {
494                continue;
495            }
496            for pat in plugin.config_patterns() {
497                let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
498                if !has_glob {
499                    // Check both workspace root and project root (deduplicate when equal)
500                    let check_roots: Vec<&Path> = if root == project_root {
501                        vec![root]
502                    } else {
503                        vec![root, project_root]
504                    };
505                    for check_root in check_roots {
506                        let abs_path = check_root.join(pat);
507                        if abs_path.is_file() && ws_seen_paths.insert(abs_path.clone()) {
508                            ws_json_configs.push((abs_path, *plugin));
509                            break; // Found it — don't check other roots for this pattern
510                        }
511                    }
512                } else {
513                    // Glob pattern (e.g., "**/project.json") — check directories
514                    // that contain discovered source files
515                    let filename = std::path::Path::new(pat)
516                        .file_name()
517                        .and_then(|n| n.to_str())
518                        .unwrap_or(pat);
519                    let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
520                    if let Some(matcher) = matcher {
521                        let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
522                        checked_dirs.insert(root);
523                        if root != project_root {
524                            checked_dirs.insert(project_root);
525                        }
526                        for (abs_path, _) in relative_files {
527                            if let Some(parent) = abs_path.parent() {
528                                checked_dirs.insert(parent);
529                            }
530                        }
531                        for dir in checked_dirs {
532                            let candidate = dir.join(filename);
533                            if candidate.is_file() && ws_seen_paths.insert(candidate.clone()) {
534                                let rel = candidate
535                                    .strip_prefix(project_root)
536                                    .map(|p| p.to_string_lossy())
537                                    .unwrap_or_default();
538                                if matcher.is_match(rel.as_ref()) {
539                                    ws_json_configs.push((candidate, *plugin));
540                                }
541                            }
542                        }
543                    }
544                }
545            }
546        }
547        // Parse discovered JSON config files
548        for (abs_path, plugin) in &ws_json_configs {
549            if let Ok(source) = std::fs::read_to_string(abs_path) {
550                let plugin_result = plugin.resolve_config(abs_path, &source, root);
551                if !plugin_result.is_empty() {
552                    let rel = abs_path
553                        .strip_prefix(project_root)
554                        .map(|p| p.to_string_lossy())
555                        .unwrap_or_default();
556                    tracing::debug!(
557                        plugin = plugin.name(),
558                        config = %rel,
559                        entries = plugin_result.entry_patterns.len(),
560                        deps = plugin_result.referenced_dependencies.len(),
561                        "resolved config (workspace filesystem fallback)"
562                    );
563                    process_config_result(plugin.name(), plugin_result, &mut result);
564                }
565            }
566        }
567
568        result
569    }
570
571    /// Pre-compile config pattern glob matchers for all plugins that have config patterns.
572    /// Returns a vec of (plugin, matchers) pairs that can be reused across multiple `run_workspace_fast` calls.
573    pub fn precompile_config_matchers(&self) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
574        self.plugins
575            .iter()
576            .filter(|p| !p.config_patterns().is_empty())
577            .map(|p| {
578                let matchers: Vec<globset::GlobMatcher> = p
579                    .config_patterns()
580                    .iter()
581                    .filter_map(|pat| globset::Glob::new(pat).ok().map(|g| g.compile_matcher()))
582                    .collect();
583                (p.as_ref(), matchers)
584            })
585            .collect()
586    }
587}
588
589/// Collect static patterns from a single plugin into the aggregated result.
590fn process_static_patterns(plugin: &dyn Plugin, root: &Path, result: &mut AggregatedPluginResult) {
591    result.active_plugins.push(plugin.name().to_string());
592
593    let pname = plugin.name().to_string();
594    for pat in plugin.entry_patterns() {
595        result
596            .entry_patterns
597            .push(((*pat).to_string(), pname.clone()));
598    }
599    for pat in plugin.config_patterns() {
600        result.config_patterns.push((*pat).to_string());
601    }
602    for pat in plugin.always_used() {
603        result.always_used.push(((*pat).to_string(), pname.clone()));
604    }
605    for (file_pat, exports) in plugin.used_exports() {
606        result.used_exports.push((
607            file_pat.to_string(),
608            exports.iter().map(|s| s.to_string()).collect(),
609        ));
610    }
611    for dep in plugin.tooling_dependencies() {
612        result.tooling_dependencies.push((*dep).to_string());
613    }
614    for prefix in plugin.virtual_module_prefixes() {
615        result.virtual_module_prefixes.push((*prefix).to_string());
616    }
617    for (prefix, replacement) in plugin.path_aliases(root) {
618        result.path_aliases.push((prefix.to_string(), replacement));
619    }
620}
621
622/// Process external plugin definitions, checking activation and aggregating patterns.
623fn process_external_plugins(
624    external_plugins: &[ExternalPluginDef],
625    all_deps: &[String],
626    root: &Path,
627    discovered_files: &[PathBuf],
628    result: &mut AggregatedPluginResult,
629) {
630    let all_dep_refs: Vec<&str> = all_deps.iter().map(|s| s.as_str()).collect();
631    for ext in external_plugins {
632        let is_active = if let Some(detection) = &ext.detection {
633            check_plugin_detection(detection, &all_dep_refs, root, discovered_files)
634        } else if !ext.enablers.is_empty() {
635            ext.enablers.iter().any(|enabler| {
636                if enabler.ends_with('/') {
637                    all_deps.iter().any(|d| d.starts_with(enabler))
638                } else {
639                    all_deps.iter().any(|d| d == enabler)
640                }
641            })
642        } else {
643            false
644        };
645        if is_active {
646            result.active_plugins.push(ext.name.clone());
647            result.entry_patterns.extend(
648                ext.entry_points
649                    .iter()
650                    .map(|p| (p.clone(), ext.name.clone())),
651            );
652            // Track config patterns for introspection (not used for AST parsing —
653            // external plugins cannot do resolve_config())
654            result.config_patterns.extend(ext.config_patterns.clone());
655            result.always_used.extend(
656                ext.config_patterns
657                    .iter()
658                    .chain(ext.always_used.iter())
659                    .map(|p| (p.clone(), ext.name.clone())),
660            );
661            result
662                .tooling_dependencies
663                .extend(ext.tooling_dependencies.clone());
664            for ue in &ext.used_exports {
665                result
666                    .used_exports
667                    .push((ue.pattern.clone(), ue.exports.clone()));
668            }
669        }
670    }
671}
672
673/// Discover JSON config files on the filesystem for plugins that weren't matched against
674/// discovered source files. Returns `(path, plugin)` pairs.
675fn discover_json_config_files<'a>(
676    config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
677    resolved_plugins: &FxHashSet<&str>,
678    relative_files: &[(&PathBuf, String)],
679    root: &Path,
680) -> Vec<(PathBuf, &'a dyn Plugin)> {
681    let mut json_configs: Vec<(PathBuf, &'a dyn Plugin)> = Vec::new();
682    for (plugin, _) in config_matchers {
683        if resolved_plugins.contains(plugin.name()) {
684            continue;
685        }
686        for pat in plugin.config_patterns() {
687            let has_glob = pat.contains("**") || pat.contains('*') || pat.contains('?');
688            if !has_glob {
689                // Simple pattern (e.g., "angular.json") — check at root
690                let abs_path = root.join(pat);
691                if abs_path.is_file() {
692                    json_configs.push((abs_path, *plugin));
693                }
694            } else {
695                // Glob pattern (e.g., "**/project.json") — check directories
696                // that contain discovered source files
697                let filename = std::path::Path::new(pat)
698                    .file_name()
699                    .and_then(|n| n.to_str())
700                    .unwrap_or(pat);
701                let matcher = globset::Glob::new(pat).ok().map(|g| g.compile_matcher());
702                if let Some(matcher) = matcher {
703                    let mut checked_dirs: FxHashSet<&Path> = FxHashSet::default();
704                    checked_dirs.insert(root);
705                    for (abs_path, _) in relative_files {
706                        if let Some(parent) = abs_path.parent() {
707                            checked_dirs.insert(parent);
708                        }
709                    }
710                    for dir in checked_dirs {
711                        let candidate = dir.join(filename);
712                        if candidate.is_file() {
713                            let rel = candidate
714                                .strip_prefix(root)
715                                .map(|p| p.to_string_lossy())
716                                .unwrap_or_default();
717                            if matcher.is_match(rel.as_ref()) {
718                                json_configs.push((candidate, *plugin));
719                            }
720                        }
721                    }
722                }
723            }
724        }
725    }
726    json_configs
727}
728
729/// Merge a `PluginResult` from config parsing into the aggregated result.
730fn process_config_result(
731    plugin_name: &str,
732    plugin_result: PluginResult,
733    result: &mut AggregatedPluginResult,
734) {
735    let pname = plugin_name.to_string();
736    result.entry_patterns.extend(
737        plugin_result
738            .entry_patterns
739            .into_iter()
740            .map(|p| (p, pname.clone())),
741    );
742    result
743        .referenced_dependencies
744        .extend(plugin_result.referenced_dependencies);
745    result.discovered_always_used.extend(
746        plugin_result
747            .always_used_files
748            .into_iter()
749            .map(|p| (p, pname.clone())),
750    );
751    result.setup_files.extend(
752        plugin_result
753            .setup_files
754            .into_iter()
755            .map(|p| (p, pname.clone())),
756    );
757}
758
759/// Check if a plugin already has a config file matched against discovered files.
760fn check_has_config_file(
761    plugin: &dyn Plugin,
762    config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
763    relative_files: &[(&PathBuf, String)],
764) -> bool {
765    !plugin.config_patterns().is_empty()
766        && config_matchers.iter().any(|(p, matchers)| {
767            p.name() == plugin.name()
768                && relative_files
769                    .iter()
770                    .any(|(_, rel)| matchers.iter().any(|m| m.is_match(rel.as_str())))
771        })
772}
773
774/// Check if a `PluginDetection` condition is satisfied.
775fn check_plugin_detection(
776    detection: &PluginDetection,
777    all_deps: &[&str],
778    root: &Path,
779    discovered_files: &[PathBuf],
780) -> bool {
781    match detection {
782        PluginDetection::Dependency { package } => all_deps.iter().any(|d| *d == package),
783        PluginDetection::FileExists { pattern } => {
784            // Check against discovered files first (fast path)
785            if let Ok(matcher) = globset::Glob::new(pattern).map(|g| g.compile_matcher()) {
786                for file in discovered_files {
787                    let relative = file.strip_prefix(root).unwrap_or(file);
788                    if matcher.is_match(relative) {
789                        return true;
790                    }
791                }
792            }
793            // Fall back to glob on disk for non-source files (e.g., config files)
794            let full_pattern = root.join(pattern).to_string_lossy().to_string();
795            glob::glob(&full_pattern)
796                .ok()
797                .is_some_and(|mut g| g.next().is_some())
798        }
799        PluginDetection::All { conditions } => conditions
800            .iter()
801            .all(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
802        PluginDetection::Any { conditions } => conditions
803            .iter()
804            .any(|c| check_plugin_detection(c, all_deps, root, discovered_files)),
805    }
806}
807
808impl Default for PluginRegistry {
809    fn default() -> Self {
810        Self::new(vec![])
811    }
812}
813
814#[cfg(test)]
815#[expect(clippy::disallowed_types)]
816mod tests {
817    use super::*;
818    use fallow_config::{ExternalPluginDef, ExternalUsedExport, PluginDetection};
819    use std::collections::HashMap;
820
821    /// Helper: build a PackageJson with given dependency names.
822    fn make_pkg(deps: &[&str]) -> PackageJson {
823        let map: HashMap<String, String> =
824            deps.iter().map(|d| (d.to_string(), "*".into())).collect();
825        PackageJson {
826            dependencies: Some(map),
827            ..Default::default()
828        }
829    }
830
831    /// Helper: build a PackageJson with dev dependencies.
832    fn make_pkg_dev(deps: &[&str]) -> PackageJson {
833        let map: HashMap<String, String> =
834            deps.iter().map(|d| (d.to_string(), "*".into())).collect();
835        PackageJson {
836            dev_dependencies: Some(map),
837            ..Default::default()
838        }
839    }
840
841    // ── Plugin detection via enablers ────────────────────────────
842
843    #[test]
844    fn nextjs_detected_when_next_in_deps() {
845        let registry = PluginRegistry::default();
846        let pkg = make_pkg(&["next", "react"]);
847        let result = registry.run(&pkg, Path::new("/project"), &[]);
848        assert!(
849            result.active_plugins.contains(&"nextjs".to_string()),
850            "nextjs plugin should be active when 'next' is in deps"
851        );
852    }
853
854    #[test]
855    fn nextjs_not_detected_without_next() {
856        let registry = PluginRegistry::default();
857        let pkg = make_pkg(&["react", "react-dom"]);
858        let result = registry.run(&pkg, Path::new("/project"), &[]);
859        assert!(
860            !result.active_plugins.contains(&"nextjs".to_string()),
861            "nextjs plugin should not be active without 'next' in deps"
862        );
863    }
864
865    #[test]
866    fn prefix_enabler_matches_scoped_packages() {
867        // Storybook uses "@storybook/" prefix matcher
868        let registry = PluginRegistry::default();
869        let pkg = make_pkg(&["@storybook/react"]);
870        let result = registry.run(&pkg, Path::new("/project"), &[]);
871        assert!(
872            result.active_plugins.contains(&"storybook".to_string()),
873            "storybook should activate via prefix match on @storybook/react"
874        );
875    }
876
877    #[test]
878    fn prefix_enabler_does_not_match_without_slash() {
879        // "storybook" (exact) should match, but "@storybook" (without /) should not match via prefix
880        let registry = PluginRegistry::default();
881        // This only has a package called "@storybookish" — it should NOT match
882        let mut map = HashMap::new();
883        map.insert("@storybookish".to_string(), "*".to_string());
884        let pkg = PackageJson {
885            dependencies: Some(map),
886            ..Default::default()
887        };
888        let result = registry.run(&pkg, Path::new("/project"), &[]);
889        assert!(
890            !result.active_plugins.contains(&"storybook".to_string()),
891            "storybook should not activate for '@storybookish' (no slash prefix match)"
892        );
893    }
894
895    #[test]
896    fn multiple_plugins_detected_simultaneously() {
897        let registry = PluginRegistry::default();
898        let pkg = make_pkg(&["next", "vitest", "typescript"]);
899        let result = registry.run(&pkg, Path::new("/project"), &[]);
900        assert!(result.active_plugins.contains(&"nextjs".to_string()));
901        assert!(result.active_plugins.contains(&"vitest".to_string()));
902        assert!(result.active_plugins.contains(&"typescript".to_string()));
903    }
904
905    #[test]
906    fn no_plugins_for_empty_deps() {
907        let registry = PluginRegistry::default();
908        let pkg = PackageJson::default();
909        let result = registry.run(&pkg, Path::new("/project"), &[]);
910        assert!(
911            result.active_plugins.is_empty(),
912            "no plugins should activate with empty package.json"
913        );
914    }
915
916    // ── Aggregation: entry patterns, tooling deps ────────────────
917
918    #[test]
919    fn active_plugin_contributes_entry_patterns() {
920        let registry = PluginRegistry::default();
921        let pkg = make_pkg(&["next"]);
922        let result = registry.run(&pkg, Path::new("/project"), &[]);
923        // Next.js should contribute App Router entry patterns
924        assert!(
925            result
926                .entry_patterns
927                .iter()
928                .any(|(p, _)| p.contains("app/**/page")),
929            "nextjs plugin should add app/**/page entry pattern"
930        );
931    }
932
933    #[test]
934    fn inactive_plugin_does_not_contribute_entry_patterns() {
935        let registry = PluginRegistry::default();
936        let pkg = make_pkg(&["react"]);
937        let result = registry.run(&pkg, Path::new("/project"), &[]);
938        // Next.js patterns should not be present
939        assert!(
940            !result
941                .entry_patterns
942                .iter()
943                .any(|(p, _)| p.contains("app/**/page")),
944            "nextjs patterns should not appear when plugin is inactive"
945        );
946    }
947
948    #[test]
949    fn active_plugin_contributes_tooling_deps() {
950        let registry = PluginRegistry::default();
951        let pkg = make_pkg(&["next"]);
952        let result = registry.run(&pkg, Path::new("/project"), &[]);
953        assert!(
954            result.tooling_dependencies.contains(&"next".to_string()),
955            "nextjs plugin should list 'next' as a tooling dependency"
956        );
957    }
958
959    #[test]
960    fn dev_deps_also_trigger_plugins() {
961        let registry = PluginRegistry::default();
962        let pkg = make_pkg_dev(&["vitest"]);
963        let result = registry.run(&pkg, Path::new("/project"), &[]);
964        assert!(
965            result.active_plugins.contains(&"vitest".to_string()),
966            "vitest should activate from devDependencies"
967        );
968    }
969
970    // ── External plugins ─────────────────────────────────────────
971
972    #[test]
973    fn external_plugin_detected_by_enablers() {
974        let ext = ExternalPluginDef {
975            schema: None,
976            name: "my-framework".to_string(),
977            detection: None,
978            enablers: vec!["my-framework".to_string()],
979            entry_points: vec!["src/routes/**/*.ts".to_string()],
980            config_patterns: vec![],
981            always_used: vec!["my.config.ts".to_string()],
982            tooling_dependencies: vec!["my-framework-cli".to_string()],
983            used_exports: vec![],
984        };
985        let registry = PluginRegistry::new(vec![ext]);
986        let pkg = make_pkg(&["my-framework"]);
987        let result = registry.run(&pkg, Path::new("/project"), &[]);
988        assert!(result.active_plugins.contains(&"my-framework".to_string()));
989        assert!(
990            result
991                .entry_patterns
992                .iter()
993                .any(|(p, _)| p == "src/routes/**/*.ts")
994        );
995        assert!(
996            result
997                .tooling_dependencies
998                .contains(&"my-framework-cli".to_string())
999        );
1000    }
1001
1002    #[test]
1003    fn external_plugin_not_detected_when_dep_missing() {
1004        let ext = ExternalPluginDef {
1005            schema: None,
1006            name: "my-framework".to_string(),
1007            detection: None,
1008            enablers: vec!["my-framework".to_string()],
1009            entry_points: vec!["src/routes/**/*.ts".to_string()],
1010            config_patterns: vec![],
1011            always_used: vec![],
1012            tooling_dependencies: vec![],
1013            used_exports: vec![],
1014        };
1015        let registry = PluginRegistry::new(vec![ext]);
1016        let pkg = make_pkg(&["react"]);
1017        let result = registry.run(&pkg, Path::new("/project"), &[]);
1018        assert!(!result.active_plugins.contains(&"my-framework".to_string()));
1019        assert!(
1020            !result
1021                .entry_patterns
1022                .iter()
1023                .any(|(p, _)| p == "src/routes/**/*.ts")
1024        );
1025    }
1026
1027    #[test]
1028    fn external_plugin_prefix_enabler() {
1029        let ext = ExternalPluginDef {
1030            schema: None,
1031            name: "custom-plugin".to_string(),
1032            detection: None,
1033            enablers: vec!["@custom/".to_string()],
1034            entry_points: vec!["custom/**/*.ts".to_string()],
1035            config_patterns: vec![],
1036            always_used: vec![],
1037            tooling_dependencies: vec![],
1038            used_exports: vec![],
1039        };
1040        let registry = PluginRegistry::new(vec![ext]);
1041        let pkg = make_pkg(&["@custom/core"]);
1042        let result = registry.run(&pkg, Path::new("/project"), &[]);
1043        assert!(result.active_plugins.contains(&"custom-plugin".to_string()));
1044    }
1045
1046    #[test]
1047    fn external_plugin_detection_dependency() {
1048        let ext = ExternalPluginDef {
1049            schema: None,
1050            name: "detected-plugin".to_string(),
1051            detection: Some(PluginDetection::Dependency {
1052                package: "special-dep".to_string(),
1053            }),
1054            enablers: vec![],
1055            entry_points: vec!["special/**/*.ts".to_string()],
1056            config_patterns: vec![],
1057            always_used: vec![],
1058            tooling_dependencies: vec![],
1059            used_exports: vec![],
1060        };
1061        let registry = PluginRegistry::new(vec![ext]);
1062        let pkg = make_pkg(&["special-dep"]);
1063        let result = registry.run(&pkg, Path::new("/project"), &[]);
1064        assert!(
1065            result
1066                .active_plugins
1067                .contains(&"detected-plugin".to_string())
1068        );
1069    }
1070
1071    #[test]
1072    fn external_plugin_detection_any_combinator() {
1073        let ext = ExternalPluginDef {
1074            schema: None,
1075            name: "any-plugin".to_string(),
1076            detection: Some(PluginDetection::Any {
1077                conditions: vec![
1078                    PluginDetection::Dependency {
1079                        package: "pkg-a".to_string(),
1080                    },
1081                    PluginDetection::Dependency {
1082                        package: "pkg-b".to_string(),
1083                    },
1084                ],
1085            }),
1086            enablers: vec![],
1087            entry_points: vec!["any/**/*.ts".to_string()],
1088            config_patterns: vec![],
1089            always_used: vec![],
1090            tooling_dependencies: vec![],
1091            used_exports: vec![],
1092        };
1093        let registry = PluginRegistry::new(vec![ext]);
1094        // Only pkg-b present — should still match via Any
1095        let pkg = make_pkg(&["pkg-b"]);
1096        let result = registry.run(&pkg, Path::new("/project"), &[]);
1097        assert!(result.active_plugins.contains(&"any-plugin".to_string()));
1098    }
1099
1100    #[test]
1101    fn external_plugin_detection_all_combinator_fails_partial() {
1102        let ext = ExternalPluginDef {
1103            schema: None,
1104            name: "all-plugin".to_string(),
1105            detection: Some(PluginDetection::All {
1106                conditions: vec![
1107                    PluginDetection::Dependency {
1108                        package: "pkg-a".to_string(),
1109                    },
1110                    PluginDetection::Dependency {
1111                        package: "pkg-b".to_string(),
1112                    },
1113                ],
1114            }),
1115            enablers: vec![],
1116            entry_points: vec![],
1117            config_patterns: vec![],
1118            always_used: vec![],
1119            tooling_dependencies: vec![],
1120            used_exports: vec![],
1121        };
1122        let registry = PluginRegistry::new(vec![ext]);
1123        // Only pkg-a present — All requires both
1124        let pkg = make_pkg(&["pkg-a"]);
1125        let result = registry.run(&pkg, Path::new("/project"), &[]);
1126        assert!(!result.active_plugins.contains(&"all-plugin".to_string()));
1127    }
1128
1129    #[test]
1130    fn external_plugin_used_exports_aggregated() {
1131        let ext = ExternalPluginDef {
1132            schema: None,
1133            name: "ue-plugin".to_string(),
1134            detection: None,
1135            enablers: vec!["ue-dep".to_string()],
1136            entry_points: vec![],
1137            config_patterns: vec![],
1138            always_used: vec![],
1139            tooling_dependencies: vec![],
1140            used_exports: vec![ExternalUsedExport {
1141                pattern: "pages/**/*.tsx".to_string(),
1142                exports: vec!["default".to_string(), "getServerSideProps".to_string()],
1143            }],
1144        };
1145        let registry = PluginRegistry::new(vec![ext]);
1146        let pkg = make_pkg(&["ue-dep"]);
1147        let result = registry.run(&pkg, Path::new("/project"), &[]);
1148        assert!(result.used_exports.iter().any(|(pat, exports)| {
1149            pat == "pages/**/*.tsx" && exports.contains(&"default".to_string())
1150        }));
1151    }
1152
1153    #[test]
1154    fn external_plugin_without_enablers_or_detection_stays_inactive() {
1155        let ext = ExternalPluginDef {
1156            schema: None,
1157            name: "orphan-plugin".to_string(),
1158            detection: None,
1159            enablers: vec![],
1160            entry_points: vec!["orphan/**/*.ts".to_string()],
1161            config_patterns: vec![],
1162            always_used: vec![],
1163            tooling_dependencies: vec![],
1164            used_exports: vec![],
1165        };
1166        let registry = PluginRegistry::new(vec![ext]);
1167        let pkg = make_pkg(&["anything"]);
1168        let result = registry.run(&pkg, Path::new("/project"), &[]);
1169        assert!(!result.active_plugins.contains(&"orphan-plugin".to_string()));
1170    }
1171
1172    // ── Virtual module prefixes ──────────────────────────────────
1173
1174    #[test]
1175    fn nuxt_contributes_virtual_module_prefixes() {
1176        let registry = PluginRegistry::default();
1177        let pkg = make_pkg(&["nuxt"]);
1178        let result = registry.run(&pkg, Path::new("/project"), &[]);
1179        assert!(
1180            result.virtual_module_prefixes.contains(&"#".to_string()),
1181            "nuxt should contribute '#' virtual module prefix"
1182        );
1183    }
1184
1185    // ── process_static_patterns: always_used aggregation ─────────
1186
1187    #[test]
1188    fn active_plugin_contributes_always_used_files() {
1189        let registry = PluginRegistry::default();
1190        let pkg = make_pkg(&["next"]);
1191        let result = registry.run(&pkg, Path::new("/project"), &[]);
1192        // Next.js marks next.config.{ts,js,mjs,cjs} as always used
1193        assert!(
1194            result
1195                .always_used
1196                .iter()
1197                .any(|(p, name)| p.contains("next.config") && name == "nextjs"),
1198            "nextjs plugin should add next.config to always_used"
1199        );
1200    }
1201
1202    #[test]
1203    fn active_plugin_contributes_config_patterns() {
1204        let registry = PluginRegistry::default();
1205        let pkg = make_pkg(&["next"]);
1206        let result = registry.run(&pkg, Path::new("/project"), &[]);
1207        assert!(
1208            result
1209                .config_patterns
1210                .iter()
1211                .any(|p| p.contains("next.config")),
1212            "nextjs plugin should add next.config to config_patterns"
1213        );
1214    }
1215
1216    #[test]
1217    fn active_plugin_contributes_used_exports() {
1218        let registry = PluginRegistry::default();
1219        let pkg = make_pkg(&["next"]);
1220        let result = registry.run(&pkg, Path::new("/project"), &[]);
1221        // Next.js has used_exports for page patterns (default, getServerSideProps, etc.)
1222        assert!(
1223            !result.used_exports.is_empty(),
1224            "nextjs plugin should contribute used_exports"
1225        );
1226        assert!(
1227            result
1228                .used_exports
1229                .iter()
1230                .any(|(_, exports)| exports.contains(&"default".to_string())),
1231            "nextjs used_exports should include 'default'"
1232        );
1233    }
1234
1235    #[test]
1236    fn sveltekit_contributes_path_aliases() {
1237        let registry = PluginRegistry::default();
1238        let pkg = make_pkg(&["@sveltejs/kit"]);
1239        let result = registry.run(&pkg, Path::new("/project"), &[]);
1240        assert!(
1241            result
1242                .path_aliases
1243                .iter()
1244                .any(|(prefix, _)| prefix == "$lib/"),
1245            "sveltekit plugin should contribute $lib/ path alias"
1246        );
1247    }
1248
1249    #[test]
1250    fn docusaurus_contributes_virtual_module_prefixes() {
1251        let registry = PluginRegistry::default();
1252        let pkg = make_pkg(&["@docusaurus/core"]);
1253        let result = registry.run(&pkg, Path::new("/project"), &[]);
1254        assert!(
1255            result
1256                .virtual_module_prefixes
1257                .iter()
1258                .any(|p| p == "@theme/"),
1259            "docusaurus should contribute @theme/ virtual module prefix"
1260        );
1261    }
1262
1263    // ── External plugin: detection takes priority over enablers ──
1264
1265    #[test]
1266    fn external_plugin_detection_overrides_enablers() {
1267        // When detection is set AND enablers is set, detection should be used.
1268        // Detection says "requires pkg-x", enablers says "pkg-y".
1269        // With only pkg-y in deps, plugin should NOT activate because detection takes priority.
1270        let ext = ExternalPluginDef {
1271            schema: None,
1272            name: "priority-test".to_string(),
1273            detection: Some(PluginDetection::Dependency {
1274                package: "pkg-x".to_string(),
1275            }),
1276            enablers: vec!["pkg-y".to_string()],
1277            entry_points: vec!["src/**/*.ts".to_string()],
1278            config_patterns: vec![],
1279            always_used: vec![],
1280            tooling_dependencies: vec![],
1281            used_exports: vec![],
1282        };
1283        let registry = PluginRegistry::new(vec![ext]);
1284        let pkg = make_pkg(&["pkg-y"]);
1285        let result = registry.run(&pkg, Path::new("/project"), &[]);
1286        assert!(
1287            !result.active_plugins.contains(&"priority-test".to_string()),
1288            "detection should take priority over enablers — pkg-x not present"
1289        );
1290    }
1291
1292    #[test]
1293    fn external_plugin_detection_overrides_enablers_positive() {
1294        // Same as above but with pkg-x present — should activate via detection
1295        let ext = ExternalPluginDef {
1296            schema: None,
1297            name: "priority-test".to_string(),
1298            detection: Some(PluginDetection::Dependency {
1299                package: "pkg-x".to_string(),
1300            }),
1301            enablers: vec!["pkg-y".to_string()],
1302            entry_points: vec![],
1303            config_patterns: vec![],
1304            always_used: vec![],
1305            tooling_dependencies: vec![],
1306            used_exports: vec![],
1307        };
1308        let registry = PluginRegistry::new(vec![ext]);
1309        let pkg = make_pkg(&["pkg-x"]);
1310        let result = registry.run(&pkg, Path::new("/project"), &[]);
1311        assert!(
1312            result.active_plugins.contains(&"priority-test".to_string()),
1313            "detection should activate when pkg-x is present"
1314        );
1315    }
1316
1317    // ── External plugin: config_patterns are added to always_used ─
1318
1319    #[test]
1320    fn external_plugin_config_patterns_added_to_always_used() {
1321        let ext = ExternalPluginDef {
1322            schema: None,
1323            name: "cfg-plugin".to_string(),
1324            detection: None,
1325            enablers: vec!["cfg-dep".to_string()],
1326            entry_points: vec![],
1327            config_patterns: vec!["my-tool.config.ts".to_string()],
1328            always_used: vec!["setup.ts".to_string()],
1329            tooling_dependencies: vec![],
1330            used_exports: vec![],
1331        };
1332        let registry = PluginRegistry::new(vec![ext]);
1333        let pkg = make_pkg(&["cfg-dep"]);
1334        let result = registry.run(&pkg, Path::new("/project"), &[]);
1335        // Both config_patterns AND always_used should be in the always_used result
1336        assert!(
1337            result
1338                .always_used
1339                .iter()
1340                .any(|(p, _)| p == "my-tool.config.ts"),
1341            "external plugin config_patterns should be in always_used"
1342        );
1343        assert!(
1344            result.always_used.iter().any(|(p, _)| p == "setup.ts"),
1345            "external plugin always_used should be in always_used"
1346        );
1347    }
1348
1349    // ── External plugin: All combinator succeeds when all present ─
1350
1351    #[test]
1352    fn external_plugin_detection_all_combinator_succeeds() {
1353        let ext = ExternalPluginDef {
1354            schema: None,
1355            name: "all-pass".to_string(),
1356            detection: Some(PluginDetection::All {
1357                conditions: vec![
1358                    PluginDetection::Dependency {
1359                        package: "pkg-a".to_string(),
1360                    },
1361                    PluginDetection::Dependency {
1362                        package: "pkg-b".to_string(),
1363                    },
1364                ],
1365            }),
1366            enablers: vec![],
1367            entry_points: vec!["all/**/*.ts".to_string()],
1368            config_patterns: vec![],
1369            always_used: vec![],
1370            tooling_dependencies: vec![],
1371            used_exports: vec![],
1372        };
1373        let registry = PluginRegistry::new(vec![ext]);
1374        let pkg = make_pkg(&["pkg-a", "pkg-b"]);
1375        let result = registry.run(&pkg, Path::new("/project"), &[]);
1376        assert!(
1377            result.active_plugins.contains(&"all-pass".to_string()),
1378            "All combinator should pass when all dependencies present"
1379        );
1380    }
1381
1382    // ── External plugin: nested Any inside All ───────────────────
1383
1384    #[test]
1385    fn external_plugin_nested_any_inside_all() {
1386        let ext = ExternalPluginDef {
1387            schema: None,
1388            name: "nested-plugin".to_string(),
1389            detection: Some(PluginDetection::All {
1390                conditions: vec![
1391                    PluginDetection::Dependency {
1392                        package: "required-dep".to_string(),
1393                    },
1394                    PluginDetection::Any {
1395                        conditions: vec![
1396                            PluginDetection::Dependency {
1397                                package: "optional-a".to_string(),
1398                            },
1399                            PluginDetection::Dependency {
1400                                package: "optional-b".to_string(),
1401                            },
1402                        ],
1403                    },
1404                ],
1405            }),
1406            enablers: vec![],
1407            entry_points: vec![],
1408            config_patterns: vec![],
1409            always_used: vec![],
1410            tooling_dependencies: vec![],
1411            used_exports: vec![],
1412        };
1413        let registry = PluginRegistry::new(vec![ext.clone()]);
1414        // Has required-dep + optional-b → should pass
1415        let pkg = make_pkg(&["required-dep", "optional-b"]);
1416        let result = registry.run(&pkg, Path::new("/project"), &[]);
1417        assert!(
1418            result.active_plugins.contains(&"nested-plugin".to_string()),
1419            "nested Any inside All: should pass with required-dep + optional-b"
1420        );
1421
1422        // Has only required-dep (missing any optional) → should fail
1423        let registry2 = PluginRegistry::new(vec![ext]);
1424        let pkg2 = make_pkg(&["required-dep"]);
1425        let result2 = registry2.run(&pkg2, Path::new("/project"), &[]);
1426        assert!(
1427            !result2
1428                .active_plugins
1429                .contains(&"nested-plugin".to_string()),
1430            "nested Any inside All: should fail with only required-dep (no optional)"
1431        );
1432    }
1433
1434    // ── External plugin: FileExists detection ────────────────────
1435
1436    #[test]
1437    fn external_plugin_detection_file_exists_against_discovered() {
1438        // FileExists checks discovered_files first
1439        let ext = ExternalPluginDef {
1440            schema: None,
1441            name: "file-check".to_string(),
1442            detection: Some(PluginDetection::FileExists {
1443                pattern: "src/special.ts".to_string(),
1444            }),
1445            enablers: vec![],
1446            entry_points: vec!["special/**/*.ts".to_string()],
1447            config_patterns: vec![],
1448            always_used: vec![],
1449            tooling_dependencies: vec![],
1450            used_exports: vec![],
1451        };
1452        let registry = PluginRegistry::new(vec![ext]);
1453        let pkg = PackageJson::default();
1454        let discovered = vec![PathBuf::from("/project/src/special.ts")];
1455        let result = registry.run(&pkg, Path::new("/project"), &discovered);
1456        assert!(
1457            result.active_plugins.contains(&"file-check".to_string()),
1458            "FileExists detection should match against discovered files"
1459        );
1460    }
1461
1462    #[test]
1463    fn external_plugin_detection_file_exists_no_match() {
1464        let ext = ExternalPluginDef {
1465            schema: None,
1466            name: "file-miss".to_string(),
1467            detection: Some(PluginDetection::FileExists {
1468                pattern: "src/nonexistent.ts".to_string(),
1469            }),
1470            enablers: vec![],
1471            entry_points: vec![],
1472            config_patterns: vec![],
1473            always_used: vec![],
1474            tooling_dependencies: vec![],
1475            used_exports: vec![],
1476        };
1477        let registry = PluginRegistry::new(vec![ext]);
1478        let pkg = PackageJson::default();
1479        let result = registry.run(&pkg, Path::new("/nonexistent-project-root-xyz"), &[]);
1480        assert!(
1481            !result.active_plugins.contains(&"file-miss".to_string()),
1482            "FileExists detection should not match when file doesn't exist"
1483        );
1484    }
1485
1486    // ── check_plugin_detection unit tests ────────────────────────
1487
1488    #[test]
1489    fn check_plugin_detection_dependency_matches() {
1490        let detection = PluginDetection::Dependency {
1491            package: "react".to_string(),
1492        };
1493        let deps = vec!["react", "react-dom"];
1494        assert!(check_plugin_detection(
1495            &detection,
1496            &deps,
1497            Path::new("/project"),
1498            &[]
1499        ));
1500    }
1501
1502    #[test]
1503    fn check_plugin_detection_dependency_no_match() {
1504        let detection = PluginDetection::Dependency {
1505            package: "vue".to_string(),
1506        };
1507        let deps = vec!["react"];
1508        assert!(!check_plugin_detection(
1509            &detection,
1510            &deps,
1511            Path::new("/project"),
1512            &[]
1513        ));
1514    }
1515
1516    #[test]
1517    fn check_plugin_detection_file_exists_discovered_files() {
1518        let detection = PluginDetection::FileExists {
1519            pattern: "src/index.ts".to_string(),
1520        };
1521        let discovered = vec![PathBuf::from("/root/src/index.ts")];
1522        assert!(check_plugin_detection(
1523            &detection,
1524            &[],
1525            Path::new("/root"),
1526            &discovered
1527        ));
1528    }
1529
1530    #[test]
1531    fn check_plugin_detection_file_exists_glob_pattern_in_discovered() {
1532        let detection = PluginDetection::FileExists {
1533            pattern: "src/**/*.config.ts".to_string(),
1534        };
1535        let discovered = vec![
1536            PathBuf::from("/root/src/app.config.ts"),
1537            PathBuf::from("/root/src/utils/helper.ts"),
1538        ];
1539        assert!(check_plugin_detection(
1540            &detection,
1541            &[],
1542            Path::new("/root"),
1543            &discovered
1544        ));
1545    }
1546
1547    #[test]
1548    fn check_plugin_detection_file_exists_no_discovered_match() {
1549        let detection = PluginDetection::FileExists {
1550            pattern: "src/specific.ts".to_string(),
1551        };
1552        let discovered = vec![PathBuf::from("/root/src/other.ts")];
1553        // No discovered match, and disk glob won't find anything in nonexistent path
1554        assert!(!check_plugin_detection(
1555            &detection,
1556            &[],
1557            Path::new("/nonexistent-root-xyz"),
1558            &discovered
1559        ));
1560    }
1561
1562    #[test]
1563    fn check_plugin_detection_all_empty_conditions() {
1564        // All with empty conditions → vacuously true
1565        let detection = PluginDetection::All { conditions: vec![] };
1566        assert!(check_plugin_detection(
1567            &detection,
1568            &[],
1569            Path::new("/project"),
1570            &[]
1571        ));
1572    }
1573
1574    #[test]
1575    fn check_plugin_detection_any_empty_conditions() {
1576        // Any with empty conditions → vacuously false
1577        let detection = PluginDetection::Any { conditions: vec![] };
1578        assert!(!check_plugin_detection(
1579            &detection,
1580            &[],
1581            Path::new("/project"),
1582            &[]
1583        ));
1584    }
1585
1586    // ── process_config_result ────────────────────────────────────
1587
1588    #[test]
1589    fn process_config_result_merges_all_fields() {
1590        let mut aggregated = AggregatedPluginResult::default();
1591        let config_result = PluginResult {
1592            entry_patterns: vec!["src/routes/**/*.ts".to_string()],
1593            referenced_dependencies: vec!["lodash".to_string(), "axios".to_string()],
1594            always_used_files: vec!["setup.ts".to_string()],
1595            setup_files: vec![PathBuf::from("/project/test/setup.ts")],
1596        };
1597        process_config_result("test-plugin", config_result, &mut aggregated);
1598
1599        assert_eq!(aggregated.entry_patterns.len(), 1);
1600        assert_eq!(aggregated.entry_patterns[0].0, "src/routes/**/*.ts");
1601        assert_eq!(aggregated.entry_patterns[0].1, "test-plugin");
1602
1603        assert_eq!(aggregated.referenced_dependencies.len(), 2);
1604        assert!(
1605            aggregated
1606                .referenced_dependencies
1607                .contains(&"lodash".to_string())
1608        );
1609        assert!(
1610            aggregated
1611                .referenced_dependencies
1612                .contains(&"axios".to_string())
1613        );
1614
1615        assert_eq!(aggregated.discovered_always_used.len(), 1);
1616        assert_eq!(aggregated.discovered_always_used[0].0, "setup.ts");
1617        assert_eq!(aggregated.discovered_always_used[0].1, "test-plugin");
1618
1619        assert_eq!(aggregated.setup_files.len(), 1);
1620        assert_eq!(
1621            aggregated.setup_files[0].0,
1622            PathBuf::from("/project/test/setup.ts")
1623        );
1624        assert_eq!(aggregated.setup_files[0].1, "test-plugin");
1625    }
1626
1627    #[test]
1628    fn process_config_result_accumulates_across_multiple_calls() {
1629        let mut aggregated = AggregatedPluginResult::default();
1630
1631        let result1 = PluginResult {
1632            entry_patterns: vec!["a.ts".to_string()],
1633            referenced_dependencies: vec!["dep-a".to_string()],
1634            always_used_files: vec![],
1635            setup_files: vec![PathBuf::from("/project/setup-a.ts")],
1636        };
1637        let result2 = PluginResult {
1638            entry_patterns: vec!["b.ts".to_string()],
1639            referenced_dependencies: vec!["dep-b".to_string()],
1640            always_used_files: vec!["c.ts".to_string()],
1641            setup_files: vec![],
1642        };
1643
1644        process_config_result("plugin-a", result1, &mut aggregated);
1645        process_config_result("plugin-b", result2, &mut aggregated);
1646
1647        // Verify entry patterns are tagged with the correct plugin name
1648        assert_eq!(aggregated.entry_patterns.len(), 2);
1649        assert_eq!(aggregated.entry_patterns[0].0, "a.ts");
1650        assert_eq!(aggregated.entry_patterns[0].1, "plugin-a");
1651        assert_eq!(aggregated.entry_patterns[1].0, "b.ts");
1652        assert_eq!(aggregated.entry_patterns[1].1, "plugin-b");
1653
1654        // Verify referenced dependencies from both calls
1655        assert_eq!(aggregated.referenced_dependencies.len(), 2);
1656        assert!(
1657            aggregated
1658                .referenced_dependencies
1659                .contains(&"dep-a".to_string())
1660        );
1661        assert!(
1662            aggregated
1663                .referenced_dependencies
1664                .contains(&"dep-b".to_string())
1665        );
1666
1667        // Verify always_used_files tagged with plugin-b
1668        assert_eq!(aggregated.discovered_always_used.len(), 1);
1669        assert_eq!(aggregated.discovered_always_used[0].0, "c.ts");
1670        assert_eq!(aggregated.discovered_always_used[0].1, "plugin-b");
1671
1672        // Verify setup_files tagged with plugin-a
1673        assert_eq!(aggregated.setup_files.len(), 1);
1674        assert_eq!(
1675            aggregated.setup_files[0].0,
1676            PathBuf::from("/project/setup-a.ts")
1677        );
1678        assert_eq!(aggregated.setup_files[0].1, "plugin-a");
1679    }
1680
1681    // ── PluginResult::is_empty ───────────────────────────────────
1682
1683    #[test]
1684    fn plugin_result_is_empty_for_default() {
1685        assert!(
1686            PluginResult::default().is_empty(),
1687            "default PluginResult should be empty"
1688        );
1689    }
1690
1691    #[test]
1692    fn plugin_result_not_empty_when_any_field_set() {
1693        let fields: Vec<PluginResult> = vec![
1694            PluginResult {
1695                entry_patterns: vec!["src/**/*.ts".to_string()],
1696                ..Default::default()
1697            },
1698            PluginResult {
1699                referenced_dependencies: vec!["lodash".to_string()],
1700                ..Default::default()
1701            },
1702            PluginResult {
1703                always_used_files: vec!["setup.ts".to_string()],
1704                ..Default::default()
1705            },
1706            PluginResult {
1707                setup_files: vec![PathBuf::from("/project/setup.ts")],
1708                ..Default::default()
1709            },
1710        ];
1711        for (i, result) in fields.iter().enumerate() {
1712            assert!(
1713                !result.is_empty(),
1714                "PluginResult with field index {i} set should not be empty"
1715            );
1716        }
1717    }
1718
1719    // ── check_has_config_file ────────────────────────────────────
1720
1721    #[test]
1722    fn check_has_config_file_returns_true_when_file_matches() {
1723        let registry = PluginRegistry::default();
1724        let matchers = registry.precompile_config_matchers();
1725
1726        // Find the nextjs plugin entry in matchers
1727        let has_next = matchers.iter().any(|(p, _)| p.name() == "nextjs");
1728        assert!(has_next, "nextjs should be in precompiled matchers");
1729
1730        let next_plugin: &dyn Plugin = &NextJsPlugin;
1731        // A file matching next.config.ts should be detected
1732        let abs = PathBuf::from("/project/next.config.ts");
1733        let relative_files: Vec<(&PathBuf, String)> = vec![(&abs, "next.config.ts".to_string())];
1734
1735        assert!(
1736            check_has_config_file(next_plugin, &matchers, &relative_files),
1737            "check_has_config_file should return true when config file matches"
1738        );
1739    }
1740
1741    #[test]
1742    fn check_has_config_file_returns_false_when_no_match() {
1743        let registry = PluginRegistry::default();
1744        let matchers = registry.precompile_config_matchers();
1745
1746        let next_plugin: &dyn Plugin = &NextJsPlugin;
1747        let abs = PathBuf::from("/project/src/index.ts");
1748        let relative_files: Vec<(&PathBuf, String)> = vec![(&abs, "src/index.ts".to_string())];
1749
1750        assert!(
1751            !check_has_config_file(next_plugin, &matchers, &relative_files),
1752            "check_has_config_file should return false when no config file matches"
1753        );
1754    }
1755
1756    #[test]
1757    fn check_has_config_file_returns_false_for_plugin_without_config_patterns() {
1758        let registry = PluginRegistry::default();
1759        let matchers = registry.precompile_config_matchers();
1760
1761        // MSW plugin has no config_patterns
1762        let msw_plugin: &dyn Plugin = &super::super::msw::MswPlugin;
1763        let abs = PathBuf::from("/project/something.ts");
1764        let relative_files: Vec<(&PathBuf, String)> = vec![(&abs, "something.ts".to_string())];
1765
1766        assert!(
1767            !check_has_config_file(msw_plugin, &matchers, &relative_files),
1768            "plugin with no config_patterns should return false"
1769        );
1770    }
1771
1772    // ── discover_json_config_files ───────────────────────────────
1773
1774    #[test]
1775    fn discover_json_config_files_skips_resolved_plugins() {
1776        let registry = PluginRegistry::default();
1777        let matchers = registry.precompile_config_matchers();
1778
1779        let mut resolved: FxHashSet<&str> = FxHashSet::default();
1780        // Mark all plugins as resolved — should return empty
1781        for (plugin, _) in &matchers {
1782            resolved.insert(plugin.name());
1783        }
1784
1785        let json_configs =
1786            discover_json_config_files(&matchers, &resolved, &[], Path::new("/project"));
1787        assert!(
1788            json_configs.is_empty(),
1789            "discover_json_config_files should skip all resolved plugins"
1790        );
1791    }
1792
1793    #[test]
1794    fn discover_json_config_files_returns_empty_for_nonexistent_root() {
1795        let registry = PluginRegistry::default();
1796        let matchers = registry.precompile_config_matchers();
1797        let resolved: FxHashSet<&str> = FxHashSet::default();
1798
1799        let json_configs = discover_json_config_files(
1800            &matchers,
1801            &resolved,
1802            &[],
1803            Path::new("/nonexistent-root-xyz-abc"),
1804        );
1805        assert!(
1806            json_configs.is_empty(),
1807            "discover_json_config_files should return empty for nonexistent root"
1808        );
1809    }
1810
1811    // ── process_static_patterns: comprehensive ───────────────────
1812
1813    #[test]
1814    fn process_static_patterns_populates_all_fields() {
1815        let mut result = AggregatedPluginResult::default();
1816        let plugin: &dyn Plugin = &NextJsPlugin;
1817        process_static_patterns(plugin, Path::new("/project"), &mut result);
1818
1819        assert!(result.active_plugins.contains(&"nextjs".to_string()));
1820        assert!(!result.entry_patterns.is_empty());
1821        assert!(!result.config_patterns.is_empty());
1822        assert!(!result.always_used.is_empty());
1823        assert!(!result.tooling_dependencies.is_empty());
1824        // Next.js has used_exports for page patterns
1825        assert!(!result.used_exports.is_empty());
1826    }
1827
1828    #[test]
1829    fn process_static_patterns_entry_patterns_tagged_with_plugin_name() {
1830        let mut result = AggregatedPluginResult::default();
1831        let plugin: &dyn Plugin = &NextJsPlugin;
1832        process_static_patterns(plugin, Path::new("/project"), &mut result);
1833
1834        for (_, name) in &result.entry_patterns {
1835            assert_eq!(
1836                name, "nextjs",
1837                "all entry patterns should be tagged with 'nextjs'"
1838            );
1839        }
1840    }
1841
1842    #[test]
1843    fn process_static_patterns_always_used_tagged_with_plugin_name() {
1844        let mut result = AggregatedPluginResult::default();
1845        let plugin: &dyn Plugin = &NextJsPlugin;
1846        process_static_patterns(plugin, Path::new("/project"), &mut result);
1847
1848        for (_, name) in &result.always_used {
1849            assert_eq!(
1850                name, "nextjs",
1851                "all always_used should be tagged with 'nextjs'"
1852            );
1853        }
1854    }
1855
1856    // ── Multiple external plugins ────────────────────────────────
1857
1858    #[test]
1859    fn multiple_external_plugins_independently_activated() {
1860        let ext_a = ExternalPluginDef {
1861            schema: None,
1862            name: "ext-a".to_string(),
1863            detection: None,
1864            enablers: vec!["dep-a".to_string()],
1865            entry_points: vec!["a/**/*.ts".to_string()],
1866            config_patterns: vec![],
1867            always_used: vec![],
1868            tooling_dependencies: vec![],
1869            used_exports: vec![],
1870        };
1871        let ext_b = ExternalPluginDef {
1872            schema: None,
1873            name: "ext-b".to_string(),
1874            detection: None,
1875            enablers: vec!["dep-b".to_string()],
1876            entry_points: vec!["b/**/*.ts".to_string()],
1877            config_patterns: vec![],
1878            always_used: vec![],
1879            tooling_dependencies: vec![],
1880            used_exports: vec![],
1881        };
1882        let registry = PluginRegistry::new(vec![ext_a, ext_b]);
1883        // Only dep-a present
1884        let pkg = make_pkg(&["dep-a"]);
1885        let result = registry.run(&pkg, Path::new("/project"), &[]);
1886        assert!(result.active_plugins.contains(&"ext-a".to_string()));
1887        assert!(!result.active_plugins.contains(&"ext-b".to_string()));
1888        assert!(result.entry_patterns.iter().any(|(p, _)| p == "a/**/*.ts"));
1889        assert!(!result.entry_patterns.iter().any(|(p, _)| p == "b/**/*.ts"));
1890    }
1891
1892    // ── External plugin: multiple used_exports ───────────────────
1893
1894    #[test]
1895    fn external_plugin_multiple_used_exports() {
1896        let ext = ExternalPluginDef {
1897            schema: None,
1898            name: "multi-ue".to_string(),
1899            detection: None,
1900            enablers: vec!["multi-dep".to_string()],
1901            entry_points: vec![],
1902            config_patterns: vec![],
1903            always_used: vec![],
1904            tooling_dependencies: vec![],
1905            used_exports: vec![
1906                ExternalUsedExport {
1907                    pattern: "routes/**/*.ts".to_string(),
1908                    exports: vec!["loader".to_string(), "action".to_string()],
1909                },
1910                ExternalUsedExport {
1911                    pattern: "api/**/*.ts".to_string(),
1912                    exports: vec!["GET".to_string(), "POST".to_string()],
1913                },
1914            ],
1915        };
1916        let registry = PluginRegistry::new(vec![ext]);
1917        let pkg = make_pkg(&["multi-dep"]);
1918        let result = registry.run(&pkg, Path::new("/project"), &[]);
1919        assert_eq!(
1920            result.used_exports.len(),
1921            2,
1922            "should have two used_export entries"
1923        );
1924        assert!(result.used_exports.iter().any(|(pat, exports)| {
1925            pat == "routes/**/*.ts" && exports.contains(&"loader".to_string())
1926        }));
1927        assert!(result.used_exports.iter().any(|(pat, exports)| {
1928            pat == "api/**/*.ts" && exports.contains(&"GET".to_string())
1929        }));
1930    }
1931
1932    // ── Registry creation / default ──────────────────────────────
1933
1934    #[test]
1935    fn default_registry_has_all_builtin_plugins() {
1936        let registry = PluginRegistry::default();
1937        // Verify we have the expected number of built-in plugins (84 as per docs)
1938        // We test a representative sample to avoid brittle exact count checks.
1939        let pkg = make_pkg(&[
1940            "next",
1941            "vitest",
1942            "eslint",
1943            "typescript",
1944            "tailwindcss",
1945            "prisma",
1946        ]);
1947        let result = registry.run(&pkg, Path::new("/project"), &[]);
1948        assert!(result.active_plugins.contains(&"nextjs".to_string()));
1949        assert!(result.active_plugins.contains(&"vitest".to_string()));
1950        assert!(result.active_plugins.contains(&"eslint".to_string()));
1951        assert!(result.active_plugins.contains(&"typescript".to_string()));
1952        assert!(result.active_plugins.contains(&"tailwind".to_string()));
1953        assert!(result.active_plugins.contains(&"prisma".to_string()));
1954    }
1955
1956    // ── run_workspace_fast: early exit with no active plugins ────
1957
1958    #[test]
1959    fn run_workspace_fast_returns_empty_for_no_active_plugins() {
1960        let registry = PluginRegistry::default();
1961        let matchers = registry.precompile_config_matchers();
1962        let pkg = PackageJson::default();
1963        let relative_files: Vec<(&PathBuf, String)> = vec![];
1964        let result = registry.run_workspace_fast(
1965            &pkg,
1966            Path::new("/workspace/pkg"),
1967            Path::new("/workspace"),
1968            &matchers,
1969            &relative_files,
1970        );
1971        assert!(result.active_plugins.is_empty());
1972        assert!(result.entry_patterns.is_empty());
1973        assert!(result.config_patterns.is_empty());
1974        assert!(result.always_used.is_empty());
1975    }
1976
1977    #[test]
1978    fn run_workspace_fast_detects_active_plugins() {
1979        let registry = PluginRegistry::default();
1980        let matchers = registry.precompile_config_matchers();
1981        let pkg = make_pkg(&["next"]);
1982        let relative_files: Vec<(&PathBuf, String)> = vec![];
1983        let result = registry.run_workspace_fast(
1984            &pkg,
1985            Path::new("/workspace/pkg"),
1986            Path::new("/workspace"),
1987            &matchers,
1988            &relative_files,
1989        );
1990        assert!(result.active_plugins.contains(&"nextjs".to_string()));
1991        assert!(!result.entry_patterns.is_empty());
1992    }
1993
1994    #[test]
1995    fn run_workspace_fast_filters_matchers_to_active_plugins() {
1996        let registry = PluginRegistry::default();
1997        let matchers = registry.precompile_config_matchers();
1998
1999        // With only 'next' in deps, config matchers for other plugins (jest, vite, etc.)
2000        // should be excluded from the workspace run.
2001        let pkg = make_pkg(&["next"]);
2002        let relative_files: Vec<(&PathBuf, String)> = vec![];
2003        let result = registry.run_workspace_fast(
2004            &pkg,
2005            Path::new("/workspace/pkg"),
2006            Path::new("/workspace"),
2007            &matchers,
2008            &relative_files,
2009        );
2010        // Only nextjs should be active
2011        assert!(result.active_plugins.contains(&"nextjs".to_string()));
2012        assert!(
2013            !result.active_plugins.contains(&"jest".to_string()),
2014            "jest should not be active without jest dep"
2015        );
2016    }
2017
2018    // ── process_external_plugins edge cases ──────────────────────
2019
2020    #[test]
2021    fn process_external_plugins_empty_list() {
2022        let mut result = AggregatedPluginResult::default();
2023        process_external_plugins(&[], &[], Path::new("/project"), &[], &mut result);
2024        assert!(result.active_plugins.is_empty());
2025    }
2026
2027    #[test]
2028    fn process_external_plugins_prefix_enabler_requires_slash() {
2029        // Prefix enabler "@org/" should NOT match "@organism" (no trailing slash)
2030        let ext = ExternalPluginDef {
2031            schema: None,
2032            name: "prefix-strict".to_string(),
2033            detection: None,
2034            enablers: vec!["@org/".to_string()],
2035            entry_points: vec![],
2036            config_patterns: vec![],
2037            always_used: vec![],
2038            tooling_dependencies: vec![],
2039            used_exports: vec![],
2040        };
2041        let mut result = AggregatedPluginResult::default();
2042        let deps = vec!["@organism".to_string()];
2043        process_external_plugins(&[ext], &deps, Path::new("/project"), &[], &mut result);
2044        assert!(
2045            !result.active_plugins.contains(&"prefix-strict".to_string()),
2046            "@org/ prefix should not match @organism"
2047        );
2048    }
2049
2050    #[test]
2051    fn process_external_plugins_prefix_enabler_matches_scoped() {
2052        let ext = ExternalPluginDef {
2053            schema: None,
2054            name: "prefix-match".to_string(),
2055            detection: None,
2056            enablers: vec!["@org/".to_string()],
2057            entry_points: vec![],
2058            config_patterns: vec![],
2059            always_used: vec![],
2060            tooling_dependencies: vec![],
2061            used_exports: vec![],
2062        };
2063        let mut result = AggregatedPluginResult::default();
2064        let deps = vec!["@org/core".to_string()];
2065        process_external_plugins(&[ext], &deps, Path::new("/project"), &[], &mut result);
2066        assert!(
2067            result.active_plugins.contains(&"prefix-match".to_string()),
2068            "@org/ prefix should match @org/core"
2069        );
2070    }
2071
2072    // ── Config file matching with filesystem ─────────────────────
2073
2074    #[test]
2075    fn run_with_config_file_in_discovered_files() {
2076        // When a config file is in the discovered files list, config resolution
2077        // should be attempted. We can test this with a temp dir.
2078        let tmp = tempfile::tempdir().unwrap();
2079        let root = tmp.path();
2080
2081        // Create a vitest config file
2082        std::fs::write(
2083            root.join("vitest.config.ts"),
2084            r#"
2085import { defineConfig } from 'vitest/config';
2086export default defineConfig({
2087    test: {
2088        include: ['tests/**/*.test.ts'],
2089        setupFiles: ['./test/setup.ts'],
2090    }
2091});
2092"#,
2093        )
2094        .unwrap();
2095
2096        let registry = PluginRegistry::default();
2097        let pkg = make_pkg(&["vitest"]);
2098        let config_path = root.join("vitest.config.ts");
2099        let discovered = vec![config_path];
2100        let result = registry.run(&pkg, root, &discovered);
2101
2102        assert!(result.active_plugins.contains(&"vitest".to_string()));
2103        // Config parsing should have discovered additional entry patterns
2104        assert!(
2105            result
2106                .entry_patterns
2107                .iter()
2108                .any(|(p, _)| p == "tests/**/*.test.ts"),
2109            "config parsing should extract test.include patterns"
2110        );
2111        // Config parsing should have discovered setup files
2112        assert!(
2113            !result.setup_files.is_empty(),
2114            "config parsing should extract setupFiles"
2115        );
2116        // vitest/config should be a referenced dependency (from the import)
2117        assert!(
2118            result.referenced_dependencies.iter().any(|d| d == "vitest"),
2119            "config parsing should extract imports as referenced dependencies"
2120        );
2121    }
2122
2123    #[test]
2124    fn run_discovers_json_config_on_disk_fallback() {
2125        // JSON config files like angular.json are not in the discovered source file set.
2126        // They should be found via the filesystem fallback (Phase 3b).
2127        let tmp = tempfile::tempdir().unwrap();
2128        let root = tmp.path();
2129
2130        // Create a minimal angular.json
2131        std::fs::write(
2132            root.join("angular.json"),
2133            r#"{
2134                "version": 1,
2135                "projects": {
2136                    "app": {
2137                        "root": "",
2138                        "architect": {
2139                            "build": {
2140                                "options": {
2141                                    "main": "src/main.ts"
2142                                }
2143                            }
2144                        }
2145                    }
2146                }
2147            }"#,
2148        )
2149        .unwrap();
2150
2151        let registry = PluginRegistry::default();
2152        let pkg = make_pkg(&["@angular/core"]);
2153        // No source files discovered — angular.json should be found via disk fallback
2154        let result = registry.run(&pkg, root, &[]);
2155
2156        assert!(result.active_plugins.contains(&"angular".to_string()));
2157        // Angular config parsing should extract main entry point
2158        assert!(
2159            result
2160                .entry_patterns
2161                .iter()
2162                .any(|(p, _)| p.contains("src/main.ts")),
2163            "angular.json parsing should extract main entry point"
2164        );
2165    }
2166
2167    // ── Peer and optional dependencies trigger plugins ────────────
2168
2169    #[test]
2170    fn peer_deps_trigger_plugins() {
2171        let mut map = HashMap::new();
2172        map.insert("next".to_string(), "^14.0.0".to_string());
2173        let pkg = PackageJson {
2174            peer_dependencies: Some(map),
2175            ..Default::default()
2176        };
2177        let registry = PluginRegistry::default();
2178        let result = registry.run(&pkg, Path::new("/project"), &[]);
2179        assert!(
2180            result.active_plugins.contains(&"nextjs".to_string()),
2181            "peerDependencies should trigger plugin detection"
2182        );
2183    }
2184
2185    #[test]
2186    fn optional_deps_trigger_plugins() {
2187        let mut map = HashMap::new();
2188        map.insert("next".to_string(), "^14.0.0".to_string());
2189        let pkg = PackageJson {
2190            optional_dependencies: Some(map),
2191            ..Default::default()
2192        };
2193        let registry = PluginRegistry::default();
2194        let result = registry.run(&pkg, Path::new("/project"), &[]);
2195        assert!(
2196            result.active_plugins.contains(&"nextjs".to_string()),
2197            "optionalDependencies should trigger plugin detection"
2198        );
2199    }
2200
2201    // ── FileExists detection with glob in discovered files ───────
2202
2203    #[test]
2204    fn check_plugin_detection_file_exists_wildcard_in_discovered() {
2205        let detection = PluginDetection::FileExists {
2206            pattern: "**/*.svelte".to_string(),
2207        };
2208        let discovered = vec![
2209            PathBuf::from("/root/src/App.svelte"),
2210            PathBuf::from("/root/src/utils.ts"),
2211        ];
2212        assert!(
2213            check_plugin_detection(&detection, &[], Path::new("/root"), &discovered),
2214            "FileExists with glob should match discovered .svelte file"
2215        );
2216    }
2217
2218    // ── External plugin: FileExists with All combinator ──────────
2219
2220    #[test]
2221    fn external_plugin_detection_all_with_file_and_dep() {
2222        let ext = ExternalPluginDef {
2223            schema: None,
2224            name: "combo-check".to_string(),
2225            detection: Some(PluginDetection::All {
2226                conditions: vec![
2227                    PluginDetection::Dependency {
2228                        package: "my-lib".to_string(),
2229                    },
2230                    PluginDetection::FileExists {
2231                        pattern: "src/setup.ts".to_string(),
2232                    },
2233                ],
2234            }),
2235            enablers: vec![],
2236            entry_points: vec!["src/**/*.ts".to_string()],
2237            config_patterns: vec![],
2238            always_used: vec![],
2239            tooling_dependencies: vec![],
2240            used_exports: vec![],
2241        };
2242        let registry = PluginRegistry::new(vec![ext]);
2243        let pkg = make_pkg(&["my-lib"]);
2244        let discovered = vec![PathBuf::from("/project/src/setup.ts")];
2245        let result = registry.run(&pkg, Path::new("/project"), &discovered);
2246        assert!(
2247            result.active_plugins.contains(&"combo-check".to_string()),
2248            "All(dep + fileExists) should pass when both conditions met"
2249        );
2250    }
2251
2252    #[test]
2253    fn external_plugin_detection_all_dep_and_file_missing_file() {
2254        let ext = ExternalPluginDef {
2255            schema: None,
2256            name: "combo-fail".to_string(),
2257            detection: Some(PluginDetection::All {
2258                conditions: vec![
2259                    PluginDetection::Dependency {
2260                        package: "my-lib".to_string(),
2261                    },
2262                    PluginDetection::FileExists {
2263                        pattern: "src/nonexistent-xyz.ts".to_string(),
2264                    },
2265                ],
2266            }),
2267            enablers: vec![],
2268            entry_points: vec![],
2269            config_patterns: vec![],
2270            always_used: vec![],
2271            tooling_dependencies: vec![],
2272            used_exports: vec![],
2273        };
2274        let registry = PluginRegistry::new(vec![ext]);
2275        let pkg = make_pkg(&["my-lib"]);
2276        let result = registry.run(&pkg, Path::new("/nonexistent-root-xyz"), &[]);
2277        assert!(
2278            !result.active_plugins.contains(&"combo-fail".to_string()),
2279            "All(dep + fileExists) should fail when file is missing"
2280        );
2281    }
2282
2283    // ── Vitest file-based activation ─────────────────────────────
2284
2285    #[test]
2286    fn vitest_activates_by_config_file_existence() {
2287        // Vitest has a custom is_enabled_with_deps that also checks for config files
2288        let tmp = tempfile::tempdir().unwrap();
2289        let root = tmp.path();
2290        std::fs::write(root.join("vitest.config.ts"), "").unwrap();
2291
2292        let registry = PluginRegistry::default();
2293        // No vitest in deps, but config file exists
2294        let pkg = PackageJson::default();
2295        let result = registry.run(&pkg, root, &[]);
2296        assert!(
2297            result.active_plugins.contains(&"vitest".to_string()),
2298            "vitest should activate when vitest.config.ts exists on disk"
2299        );
2300    }
2301
2302    #[test]
2303    fn eslint_activates_by_config_file_existence() {
2304        // ESLint also has file-based activation
2305        let tmp = tempfile::tempdir().unwrap();
2306        let root = tmp.path();
2307        std::fs::write(root.join("eslint.config.js"), "").unwrap();
2308
2309        let registry = PluginRegistry::default();
2310        let pkg = PackageJson::default();
2311        let result = registry.run(&pkg, root, &[]);
2312        assert!(
2313            result.active_plugins.contains(&"eslint".to_string()),
2314            "eslint should activate when eslint.config.js exists on disk"
2315        );
2316    }
2317
2318    // ── discover_json_config_files: glob pattern in subdirectories
2319
2320    #[test]
2321    fn discover_json_config_files_finds_in_subdirectory() {
2322        // Nx plugin has "**/project.json" config pattern — glob-based discovery
2323        // should check directories where discovered source files live.
2324        // The function checks the parent directory of each discovered source file.
2325        let tmp = tempfile::tempdir().unwrap();
2326        let root = tmp.path();
2327        let subdir = root.join("packages").join("app");
2328        std::fs::create_dir_all(&subdir).unwrap();
2329        std::fs::write(subdir.join("project.json"), r#"{"name": "app"}"#).unwrap();
2330
2331        let registry = PluginRegistry::default();
2332        let matchers = registry.precompile_config_matchers();
2333        let resolved: FxHashSet<&str> = FxHashSet::default();
2334
2335        // The source file's parent must be packages/app/ so that project.json
2336        // is found via dir.join("project.json")
2337        let src_file = subdir.join("index.ts");
2338        let relative_files: Vec<(&PathBuf, String)> =
2339            vec![(&src_file, "packages/app/index.ts".to_string())];
2340
2341        let json_configs = discover_json_config_files(&matchers, &resolved, &relative_files, root);
2342        // Check if any nx project.json was discovered
2343        let found_project_json = json_configs
2344            .iter()
2345            .any(|(path, _)| path.ends_with("project.json"));
2346        assert!(
2347            found_project_json,
2348            "discover_json_config_files should find project.json in parent dir of discovered source file"
2349        );
2350    }
2351}