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