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