Skip to main content

fallow_core/plugins/
registry.rs

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