Skip to main content

fallow_core/plugins/registry/
mod.rs

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