1#![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
19pub struct PluginRegistry {
21 plugins: Vec<Box<dyn Plugin>>,
22 external_plugins: Vec<ExternalPluginDef>,
23}
24
25#[derive(Debug, Default)]
27pub struct AggregatedPluginResult {
28 pub entry_patterns: Vec<(String, String)>,
30 pub config_patterns: Vec<String>,
32 pub always_used: Vec<(String, String)>,
34 pub used_exports: Vec<(String, Vec<String>)>,
36 pub referenced_dependencies: Vec<String>,
38 pub discovered_always_used: Vec<(String, String)>,
40 pub setup_files: Vec<(PathBuf, String)>,
42 pub tooling_dependencies: Vec<String>,
44 pub script_used_packages: FxHashSet<String>,
46 pub virtual_module_prefixes: Vec<String>,
49 pub path_aliases: Vec<(String, String)>,
52 pub active_plugins: Vec<String>,
54}
55
56impl PluginRegistry {
57 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
59 Self {
60 plugins: builtin::create_builtin_plugins(),
61 external_plugins: external,
62 }
63 }
64
65 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 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 for plugin in &active {
99 process_static_patterns(*plugin, root, &mut result);
100 }
101
102 process_external_plugins(
104 &self.external_plugins,
105 &all_deps,
106 root,
107 discovered_files,
108 &mut result,
109 );
110
111 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 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 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 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 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 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 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 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 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 if active.is_empty() {
264 return result;
265 }
266
267 for plugin in &active {
269 process_static_patterns(*plugin, root, &mut result);
270 }
271
272 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 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 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 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; }
331 }
332 } else {
333 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 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 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 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 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 #[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 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 let registry = PluginRegistry::default();
484 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 #[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 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 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 #[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 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 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 #[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 #[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 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 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 #[test]
869 fn external_plugin_detection_overrides_enablers() {
870 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 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 #[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 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 #[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 #[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 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 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 #[test]
1040 fn external_plugin_detection_file_exists_against_discovered() {
1041 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 #[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 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 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 let detection = PluginDetection::Any { conditions: vec![] };
1181 assert!(!check_plugin_detection(
1182 &detection,
1183 &[],
1184 Path::new("/project"),
1185 &[]
1186 ));
1187 }
1188
1189 #[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 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 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 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 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 #[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 #[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 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 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 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 #[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 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 #[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 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 #[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 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 #[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 #[test]
1538 fn default_registry_has_all_builtin_plugins() {
1539 let registry = PluginRegistry::default();
1540 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 #[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 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 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 #[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 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 #[test]
1678 fn run_with_config_file_in_discovered_files() {
1679 let tmp = tempfile::tempdir().unwrap();
1682 let root = tmp.path();
1683
1684 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 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 assert!(
1716 !result.setup_files.is_empty(),
1717 "config parsing should extract setupFiles"
1718 );
1719 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 let tmp = tempfile::tempdir().unwrap();
1731 let root = tmp.path();
1732
1733 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 let result = registry.run(&pkg, root, &[]);
1758
1759 assert!(result.active_plugins.contains(&"angular".to_string()));
1760 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 #[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 #[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 #[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 #[test]
1889 fn vitest_activates_by_config_file_existence() {
1890 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 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 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 #[test]
1924 fn discover_json_config_files_finds_in_subdirectory() {
1925 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 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 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 #[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 #[test]
2017 fn process_static_patterns_with_minimal_plugin() {
2018 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 #[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 #[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 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 #[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}