use super::super::{PathRule, PluginResult, PluginUsedExportRule, UsedExportRule};
use super::*;
use fallow_config::{
ExternalPluginDef, ExternalUsedExport, PluginDetection, ScopedUsedClassMemberRule,
UsedClassMemberRule,
};
use helpers::{check_plugin_detection, discover_json_config_files, process_config_result};
fn deps_json(names: &[&str]) -> serde_json::Value {
let map: serde_json::Map<String, serde_json::Value> = names
.iter()
.map(|n| (n.to_string(), serde_json::Value::String("*".to_string())))
.collect();
serde_json::Value::Object(map)
}
fn make_pkg(deps: &[&str]) -> PackageJson {
let json = serde_json::json!({ "dependencies": deps_json(deps) });
serde_json::from_value(json).unwrap()
}
fn make_pkg_dev(deps: &[&str]) -> PackageJson {
let json = serde_json::json!({ "devDependencies": deps_json(deps) });
serde_json::from_value(json).unwrap()
}
fn path_rule(pattern: &str) -> PathRule {
PathRule::new(pattern)
}
fn used_export_rule(pattern: &str, exports: &[&str]) -> UsedExportRule {
UsedExportRule::new(pattern, exports.iter().copied())
}
fn plugin_used_export_rule(
plugin_name: &str,
pattern: &str,
exports: &[&str],
) -> PluginUsedExportRule {
PluginUsedExportRule::new(plugin_name, used_export_rule(pattern, exports))
}
#[test]
fn nextjs_detected_when_next_in_deps() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["next", "react"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.active_plugins.contains(&"nextjs".to_string()),
"nextjs plugin should be active when 'next' is in deps"
);
}
#[test]
fn nextjs_not_detected_without_next() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["react", "react-dom"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
!result.active_plugins.contains(&"nextjs".to_string()),
"nextjs plugin should not be active without 'next' in deps"
);
}
#[test]
fn prefix_enabler_matches_scoped_packages() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["@storybook/react"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.active_plugins.contains(&"storybook".to_string()),
"storybook should activate via prefix match on @storybook/react"
);
}
#[test]
fn prefix_enabler_does_not_match_without_slash() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["@storybookish"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
!result.active_plugins.contains(&"storybook".to_string()),
"storybook should not activate for '@storybookish' (no slash prefix match)"
);
}
#[test]
fn multiple_plugins_detected_simultaneously() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["next", "vitest", "typescript"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"nextjs".to_string()));
assert!(result.active_plugins.contains(&"vitest".to_string()));
assert!(result.active_plugins.contains(&"typescript".to_string()));
}
#[test]
fn expo_router_detected_when_dependency_present() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["expo", "expo-router"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"expo-router".to_string()));
assert!(
!result.active_plugins.contains(&"expo".to_string()),
"plain expo plugin should not activate for expo-router projects"
);
}
#[test]
fn tanstack_router_detected_for_solid_start() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["@tanstack/solid-start"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result
.active_plugins
.contains(&"tanstack-router".to_string())
);
}
#[test]
fn no_plugins_for_empty_deps() {
let registry = PluginRegistry::default();
let pkg = PackageJson::default();
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.active_plugins.is_empty(),
"no plugins should activate with empty package.json"
);
}
#[test]
fn active_plugin_contributes_entry_patterns() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["next"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result
.entry_patterns
.iter()
.any(|(p, _)| p.contains("app/**/page")),
"nextjs plugin should add app/**/page entry pattern"
);
}
#[test]
fn inactive_plugin_does_not_contribute_entry_patterns() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["react"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
!result
.entry_patterns
.iter()
.any(|(p, _)| p.contains("app/**/page")),
"nextjs patterns should not appear when plugin is inactive"
);
}
#[test]
fn active_plugin_contributes_tooling_deps() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["next"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.tooling_dependencies.contains(&"next".to_string()),
"nextjs plugin should list 'next' as a tooling dependency"
);
}
#[test]
fn dev_deps_also_trigger_plugins() {
let registry = PluginRegistry::default();
let pkg = make_pkg_dev(&["vitest"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.active_plugins.contains(&"vitest".to_string()),
"vitest should activate from devDependencies"
);
}
#[test]
fn external_plugin_detected_by_enablers() {
let ext = ExternalPluginDef {
schema: None,
name: "my-framework".to_string(),
detection: None,
enablers: vec!["my-framework".to_string()],
entry_points: vec!["src/routes/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec!["my.config.ts".to_string()],
tooling_dependencies: vec!["my-framework-cli".to_string()],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["my-framework"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"my-framework".to_string()));
assert!(
result
.entry_patterns
.iter()
.any(|(p, _)| p == "src/routes/**/*.ts")
);
assert!(
result
.tooling_dependencies
.contains(&"my-framework-cli".to_string())
);
}
#[test]
fn external_plugin_not_detected_when_dep_missing() {
let ext = ExternalPluginDef {
schema: None,
name: "my-framework".to_string(),
detection: None,
enablers: vec!["my-framework".to_string()],
entry_points: vec!["src/routes/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["react"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(!result.active_plugins.contains(&"my-framework".to_string()));
assert!(
!result
.entry_patterns
.iter()
.any(|(p, _)| p == "src/routes/**/*.ts")
);
}
#[test]
fn external_plugin_prefix_enabler() {
let ext = ExternalPluginDef {
schema: None,
name: "custom-plugin".to_string(),
detection: None,
enablers: vec!["@custom/".to_string()],
entry_points: vec!["custom/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["@custom/core"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"custom-plugin".to_string()));
}
#[test]
fn external_plugin_detection_dependency() {
let ext = ExternalPluginDef {
schema: None,
name: "detected-plugin".to_string(),
detection: Some(PluginDetection::Dependency {
package: "special-dep".to_string(),
}),
enablers: vec![],
entry_points: vec!["special/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["special-dep"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result
.active_plugins
.contains(&"detected-plugin".to_string())
);
}
#[test]
fn external_plugin_detection_any_combinator() {
let ext = ExternalPluginDef {
schema: None,
name: "any-plugin".to_string(),
detection: Some(PluginDetection::Any {
conditions: vec![
PluginDetection::Dependency {
package: "pkg-a".to_string(),
},
PluginDetection::Dependency {
package: "pkg-b".to_string(),
},
],
}),
enablers: vec![],
entry_points: vec!["any/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["pkg-b"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"any-plugin".to_string()));
}
#[test]
fn external_plugin_detection_all_combinator_fails_partial() {
let ext = ExternalPluginDef {
schema: None,
name: "all-plugin".to_string(),
detection: Some(PluginDetection::All {
conditions: vec![
PluginDetection::Dependency {
package: "pkg-a".to_string(),
},
PluginDetection::Dependency {
package: "pkg-b".to_string(),
},
],
}),
enablers: vec![],
entry_points: vec![],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["pkg-a"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(!result.active_plugins.contains(&"all-plugin".to_string()));
}
#[test]
fn external_plugin_used_exports_aggregated() {
let ext = ExternalPluginDef {
schema: None,
name: "ue-plugin".to_string(),
detection: None,
enablers: vec!["ue-dep".to_string()],
entry_points: vec![],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![ExternalUsedExport {
pattern: "pages/**/*.tsx".to_string(),
exports: vec!["default".to_string(), "getServerSideProps".to_string()],
}],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["ue-dep"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(result.used_exports.iter().any(|rule| {
rule.rule.path.pattern == "pages/**/*.tsx"
&& rule.rule.exports.contains(&"default".to_string())
}));
}
#[test]
fn external_plugin_without_enablers_or_detection_stays_inactive() {
let ext = ExternalPluginDef {
schema: None,
name: "orphan-plugin".to_string(),
detection: None,
enablers: vec![],
entry_points: vec!["orphan/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["anything"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(!result.active_plugins.contains(&"orphan-plugin".to_string()));
}
#[test]
fn nuxt_contributes_virtual_module_prefixes() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["nuxt"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.virtual_module_prefixes.contains(&"#".to_string()),
"nuxt should contribute '#' virtual module prefix"
);
}
#[test]
fn active_plugin_contributes_always_used_files() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["next"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result
.always_used
.iter()
.any(|(p, name)| p.contains("next.config") && name == "nextjs"),
"nextjs plugin should add next.config to always_used"
);
}
#[test]
fn active_plugin_contributes_config_patterns() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["next"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result
.config_patterns
.iter()
.any(|p| p.contains("next.config")),
"nextjs plugin should add next.config to config_patterns"
);
}
#[test]
fn active_plugin_contributes_used_exports() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["next"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
!result.used_exports.is_empty(),
"nextjs plugin should contribute used_exports"
);
assert!(
result
.used_exports
.iter()
.any(|rule| rule.rule.exports.contains(&"default".to_string())),
"nextjs used_exports should include 'default'"
);
}
#[test]
fn sveltekit_contributes_path_aliases() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["@sveltejs/kit"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result
.path_aliases
.iter()
.any(|(prefix, _)| prefix == "$lib/"),
"sveltekit plugin should contribute $lib/ path alias"
);
}
#[test]
fn docusaurus_contributes_virtual_module_prefixes() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&["@docusaurus/core"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result
.virtual_module_prefixes
.iter()
.any(|p| p == "@theme/"),
"docusaurus should contribute @theme/ virtual module prefix"
);
}
#[test]
fn external_plugin_detection_overrides_enablers() {
let ext = ExternalPluginDef {
schema: None,
name: "priority-test".to_string(),
detection: Some(PluginDetection::Dependency {
package: "pkg-x".to_string(),
}),
enablers: vec!["pkg-y".to_string()],
entry_points: vec!["src/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["pkg-y"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
!result.active_plugins.contains(&"priority-test".to_string()),
"detection should take priority over enablers — pkg-x not present"
);
}
#[test]
fn external_plugin_detection_overrides_enablers_positive() {
let ext = ExternalPluginDef {
schema: None,
name: "priority-test".to_string(),
detection: Some(PluginDetection::Dependency {
package: "pkg-x".to_string(),
}),
enablers: vec!["pkg-y".to_string()],
entry_points: vec![],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["pkg-x"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.active_plugins.contains(&"priority-test".to_string()),
"detection should activate when pkg-x is present"
);
}
#[test]
fn external_plugin_config_patterns_added_to_always_used() {
let ext = ExternalPluginDef {
schema: None,
name: "cfg-plugin".to_string(),
detection: None,
enablers: vec!["cfg-dep".to_string()],
entry_points: vec![],
config_patterns: vec!["my-tool.config.ts".to_string()],
always_used: vec!["setup.ts".to_string()],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["cfg-dep"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result
.always_used
.iter()
.any(|(p, _)| p == "my-tool.config.ts"),
"external plugin config_patterns should be in always_used"
);
assert!(
result.always_used.iter().any(|(p, _)| p == "setup.ts"),
"external plugin always_used should be in always_used"
);
}
#[test]
fn external_plugin_detection_all_combinator_succeeds() {
let ext = ExternalPluginDef {
schema: None,
name: "all-pass".to_string(),
detection: Some(PluginDetection::All {
conditions: vec![
PluginDetection::Dependency {
package: "pkg-a".to_string(),
},
PluginDetection::Dependency {
package: "pkg-b".to_string(),
},
],
}),
enablers: vec![],
entry_points: vec!["all/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["pkg-a", "pkg-b"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.active_plugins.contains(&"all-pass".to_string()),
"All combinator should pass when all dependencies present"
);
}
#[test]
fn external_plugin_nested_any_inside_all() {
let ext = ExternalPluginDef {
schema: None,
name: "nested-plugin".to_string(),
detection: Some(PluginDetection::All {
conditions: vec![
PluginDetection::Dependency {
package: "required-dep".to_string(),
},
PluginDetection::Any {
conditions: vec![
PluginDetection::Dependency {
package: "optional-a".to_string(),
},
PluginDetection::Dependency {
package: "optional-b".to_string(),
},
],
},
],
}),
enablers: vec![],
entry_points: vec![],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext.clone()]);
let pkg = make_pkg(&["required-dep", "optional-b"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.active_plugins.contains(&"nested-plugin".to_string()),
"nested Any inside All: should pass with required-dep + optional-b"
);
let registry2 = PluginRegistry::new(vec![ext]);
let pkg2 = make_pkg(&["required-dep"]);
let result2 = registry2.run(&pkg2, Path::new("/project"), &[]);
assert!(
!result2
.active_plugins
.contains(&"nested-plugin".to_string()),
"nested Any inside All: should fail with only required-dep (no optional)"
);
}
#[test]
fn external_plugin_detection_file_exists_against_discovered() {
let ext = ExternalPluginDef {
schema: None,
name: "file-check".to_string(),
detection: Some(PluginDetection::FileExists {
pattern: "src/special.ts".to_string(),
}),
enablers: vec![],
entry_points: vec!["special/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = PackageJson::default();
let discovered = vec![PathBuf::from("/project/src/special.ts")];
let result = registry.run(&pkg, Path::new("/project"), &discovered);
assert!(
result.active_plugins.contains(&"file-check".to_string()),
"FileExists detection should match against discovered files"
);
}
#[test]
fn external_plugin_detection_file_exists_no_match() {
let ext = ExternalPluginDef {
schema: None,
name: "file-miss".to_string(),
detection: Some(PluginDetection::FileExists {
pattern: "src/nonexistent.ts".to_string(),
}),
enablers: vec![],
entry_points: vec![],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = PackageJson::default();
let result = registry.run(&pkg, Path::new("/nonexistent-project-root-xyz"), &[]);
assert!(
!result.active_plugins.contains(&"file-miss".to_string()),
"FileExists detection should not match when file doesn't exist"
);
}
#[test]
fn check_plugin_detection_dependency_matches() {
let detection = PluginDetection::Dependency {
package: "react".to_string(),
};
let deps = vec!["react", "react-dom"];
assert!(check_plugin_detection(
&detection,
&deps,
Path::new("/project"),
&[]
));
}
#[test]
fn check_plugin_detection_dependency_no_match() {
let detection = PluginDetection::Dependency {
package: "vue".to_string(),
};
let deps = vec!["react"];
assert!(!check_plugin_detection(
&detection,
&deps,
Path::new("/project"),
&[]
));
}
#[test]
fn check_plugin_detection_file_exists_discovered_files() {
let detection = PluginDetection::FileExists {
pattern: "src/index.ts".to_string(),
};
let discovered = vec![PathBuf::from("/root/src/index.ts")];
assert!(check_plugin_detection(
&detection,
&[],
Path::new("/root"),
&discovered
));
}
#[test]
fn check_plugin_detection_file_exists_glob_pattern_in_discovered() {
let detection = PluginDetection::FileExists {
pattern: "src/**/*.config.ts".to_string(),
};
let discovered = vec![
PathBuf::from("/root/src/app.config.ts"),
PathBuf::from("/root/src/utils/helper.ts"),
];
assert!(check_plugin_detection(
&detection,
&[],
Path::new("/root"),
&discovered
));
}
#[test]
fn check_plugin_detection_file_exists_no_discovered_match() {
let detection = PluginDetection::FileExists {
pattern: "src/specific.ts".to_string(),
};
let discovered = vec![PathBuf::from("/root/src/other.ts")];
assert!(!check_plugin_detection(
&detection,
&[],
Path::new("/nonexistent-root-xyz"),
&discovered
));
}
#[test]
fn check_plugin_detection_all_empty_conditions() {
let detection = PluginDetection::All { conditions: vec![] };
assert!(check_plugin_detection(
&detection,
&[],
Path::new("/project"),
&[]
));
}
#[test]
fn check_plugin_detection_any_empty_conditions() {
let detection = PluginDetection::Any { conditions: vec![] };
assert!(!check_plugin_detection(
&detection,
&[],
Path::new("/project"),
&[]
));
}
#[test]
fn process_config_result_merges_all_fields() {
let mut aggregated = AggregatedPluginResult::default();
let config_result = PluginResult {
entry_patterns: vec![path_rule("src/routes/**/*.ts")],
replace_entry_patterns: false,
replace_used_export_rules: false,
used_exports: vec![used_export_rule("src/routes/**/*.ts", &["loader"])],
used_class_members: vec![fallow_config::UsedClassMemberRule::from("agInit")],
referenced_dependencies: vec!["lodash".to_string(), "axios".to_string()],
always_used_files: vec!["setup.ts".to_string()],
path_aliases: vec![],
setup_files: vec![PathBuf::from("/project/test/setup.ts")],
fixture_patterns: vec![],
scss_include_paths: vec![],
};
process_config_result("test-plugin", config_result, &mut aggregated);
assert_eq!(aggregated.entry_patterns.len(), 1);
assert_eq!(aggregated.entry_patterns[0].0, "src/routes/**/*.ts");
assert_eq!(aggregated.entry_patterns[0].1, "test-plugin");
assert_eq!(aggregated.used_exports.len(), 1);
assert_eq!(aggregated.used_exports[0].plugin_name, "test-plugin");
assert_eq!(
aggregated.used_exports[0].rule.path.pattern,
"src/routes/**/*.ts"
);
assert_eq!(
aggregated.used_exports[0].rule.exports,
vec!["loader".to_string()]
);
assert_eq!(
aggregated.used_class_members,
vec![UsedClassMemberRule::from("agInit")]
);
assert_eq!(aggregated.referenced_dependencies.len(), 2);
assert!(
aggregated
.referenced_dependencies
.contains(&"lodash".to_string())
);
assert!(
aggregated
.referenced_dependencies
.contains(&"axios".to_string())
);
assert_eq!(aggregated.discovered_always_used.len(), 1);
assert_eq!(aggregated.discovered_always_used[0].0, "setup.ts");
assert_eq!(aggregated.discovered_always_used[0].1, "test-plugin");
assert_eq!(aggregated.setup_files.len(), 1);
assert_eq!(
aggregated.setup_files[0].0,
PathBuf::from("/project/test/setup.ts")
);
assert_eq!(aggregated.setup_files[0].1, "test-plugin");
}
#[test]
fn process_config_result_preserves_scoped_used_class_member_rules() {
let mut aggregated = AggregatedPluginResult::default();
let config_result = PluginResult {
used_class_members: vec![UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
extends: Some("BaseCommand".to_string()),
implements: Some("CanActivate".to_string()),
members: vec!["execute".to_string()],
})],
..PluginResult::default()
};
process_config_result("test-plugin", config_result, &mut aggregated);
assert_eq!(
aggregated.used_class_members,
vec![UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
extends: Some("BaseCommand".to_string()),
implements: Some("CanActivate".to_string()),
members: vec!["execute".to_string()],
})]
);
}
#[test]
fn process_config_result_accumulates_across_multiple_calls() {
let mut aggregated = AggregatedPluginResult::default();
let result1 = PluginResult {
entry_patterns: vec![path_rule("a.ts")],
replace_entry_patterns: false,
replace_used_export_rules: false,
used_exports: vec![used_export_rule("a.ts", &["default"])],
used_class_members: vec![],
referenced_dependencies: vec!["dep-a".to_string()],
always_used_files: vec![],
path_aliases: vec![],
setup_files: vec![PathBuf::from("/project/setup-a.ts")],
fixture_patterns: vec![],
scss_include_paths: vec![],
};
let result2 = PluginResult {
entry_patterns: vec![path_rule("b.ts")],
replace_entry_patterns: false,
replace_used_export_rules: false,
used_exports: vec![used_export_rule("b.ts", &["loader"])],
used_class_members: vec![],
referenced_dependencies: vec!["dep-b".to_string()],
always_used_files: vec!["c.ts".to_string()],
path_aliases: vec![],
setup_files: vec![],
fixture_patterns: vec![],
scss_include_paths: vec![],
};
process_config_result("plugin-a", result1, &mut aggregated);
process_config_result("plugin-b", result2, &mut aggregated);
assert_eq!(aggregated.entry_patterns.len(), 2);
assert_eq!(aggregated.entry_patterns[0].0, "a.ts");
assert_eq!(aggregated.entry_patterns[0].1, "plugin-a");
assert_eq!(aggregated.entry_patterns[1].0, "b.ts");
assert_eq!(aggregated.entry_patterns[1].1, "plugin-b");
assert_eq!(aggregated.used_exports.len(), 2);
assert_eq!(aggregated.used_exports[0].plugin_name, "plugin-a");
assert_eq!(aggregated.used_exports[0].rule.path.pattern, "a.ts");
assert_eq!(aggregated.used_exports[1].plugin_name, "plugin-b");
assert_eq!(aggregated.used_exports[1].rule.path.pattern, "b.ts");
assert_eq!(aggregated.referenced_dependencies.len(), 2);
assert!(
aggregated
.referenced_dependencies
.contains(&"dep-a".to_string())
);
assert!(
aggregated
.referenced_dependencies
.contains(&"dep-b".to_string())
);
assert_eq!(aggregated.discovered_always_used.len(), 1);
assert_eq!(aggregated.discovered_always_used[0].0, "c.ts");
assert_eq!(aggregated.discovered_always_used[0].1, "plugin-b");
assert_eq!(aggregated.setup_files.len(), 1);
assert_eq!(
aggregated.setup_files[0].0,
PathBuf::from("/project/setup-a.ts")
);
assert_eq!(aggregated.setup_files[0].1, "plugin-a");
}
#[test]
fn process_config_result_path_aliases_override_existing_prefixes() {
let mut aggregated = AggregatedPluginResult {
path_aliases: vec![
("~/".to_string(), "app".to_string()),
("@/".to_string(), "app".to_string()),
("#shared".to_string(), "shared".to_string()),
],
..Default::default()
};
let config_result = PluginResult {
path_aliases: vec![
("~/".to_string(), "src".to_string()),
("@/".to_string(), "src".to_string()),
],
..Default::default()
};
process_config_result("nuxt", config_result, &mut aggregated);
let tilde_aliases: Vec<_> = aggregated
.path_aliases
.iter()
.filter(|(prefix, _)| prefix == "~/")
.collect();
assert_eq!(tilde_aliases.len(), 1);
assert_eq!(tilde_aliases[0].1, "src");
let at_aliases: Vec<_> = aggregated
.path_aliases
.iter()
.filter(|(prefix, _)| prefix == "@/")
.collect();
assert_eq!(at_aliases.len(), 1);
assert_eq!(at_aliases[0].1, "src");
assert!(
aggregated
.path_aliases
.contains(&("#shared".to_string(), "shared".to_string())),
"unrelated aliases should be preserved"
);
}
#[test]
fn process_config_result_replace_entry_patterns_removes_static_defaults() {
let mut aggregated = AggregatedPluginResult::default();
aggregated
.entry_patterns
.push((path_rule("**/*.test.ts"), "vitest".to_string()));
aggregated
.entry_patterns
.push((path_rule("**/*.spec.ts"), "vitest".to_string()));
aggregated
.entry_patterns
.push((path_rule("**/*.stories.tsx"), "storybook".to_string()));
let config_result = PluginResult {
entry_patterns: vec![path_rule("src/**/*.test.ts")],
replace_entry_patterns: true,
..Default::default()
};
process_config_result("vitest", config_result, &mut aggregated);
let vitest_patterns: Vec<_> = aggregated
.entry_patterns
.iter()
.filter(|(_, name)| name == "vitest")
.collect();
assert_eq!(
vitest_patterns.len(),
1,
"should have exactly the config pattern"
);
assert_eq!(vitest_patterns[0].0, "src/**/*.test.ts");
assert!(
aggregated
.entry_patterns
.iter()
.any(|(p, n)| p == "**/*.stories.tsx" && n == "storybook"),
"patterns from other plugins should be preserved"
);
}
#[test]
fn process_config_result_replace_used_export_rules_removes_static_defaults() {
let mut aggregated = AggregatedPluginResult::default();
aggregated.used_exports.push(plugin_used_export_rule(
"tanstack-router",
"src/routes/**/*.tsx",
&["Route"],
));
aggregated.used_exports.push(plugin_used_export_rule(
"tanstack-router",
"app/routes/**/*.tsx",
&["Route"],
));
aggregated.used_exports.push(plugin_used_export_rule(
"nextjs",
"app/**/page.tsx",
&["default"],
));
let config_result = PluginResult {
replace_used_export_rules: true,
used_exports: vec![used_export_rule("app/pages/**/*.tsx", &["Route"])],
..Default::default()
};
process_config_result("tanstack-router", config_result, &mut aggregated);
let tanstack_rules: Vec<_> = aggregated
.used_exports
.iter()
.filter(|rule| rule.plugin_name == "tanstack-router")
.collect();
assert_eq!(tanstack_rules.len(), 1);
assert_eq!(tanstack_rules[0].rule.path.pattern, "app/pages/**/*.tsx");
assert!(aggregated.used_exports.iter().any(|rule| {
rule.plugin_name == "nextjs" && rule.rule.path.pattern == "app/**/page.tsx"
}));
}
#[test]
fn process_config_result_replace_entry_patterns_noop_when_empty() {
let mut aggregated = AggregatedPluginResult::default();
aggregated
.entry_patterns
.push((path_rule("**/*.test.ts"), "vitest".to_string()));
let config_result = PluginResult {
entry_patterns: vec![],
replace_entry_patterns: true,
..Default::default()
};
process_config_result("vitest", config_result, &mut aggregated);
assert_eq!(
aggregated.entry_patterns.len(),
1,
"static pattern should survive when config provides none"
);
assert_eq!(aggregated.entry_patterns[0].0, "**/*.test.ts");
}
#[test]
fn process_config_result_replace_used_export_rules_noop_when_empty() {
let mut aggregated = AggregatedPluginResult::default();
aggregated.used_exports.push(plugin_used_export_rule(
"tanstack-router",
"src/routes/**/*.tsx",
&["Route"],
));
let config_result = PluginResult {
replace_used_export_rules: true,
used_exports: vec![],
..Default::default()
};
process_config_result("tanstack-router", config_result, &mut aggregated);
assert_eq!(aggregated.used_exports.len(), 1);
assert_eq!(
aggregated.used_exports[0].rule.path.pattern,
"src/routes/**/*.tsx"
);
}
#[test]
fn plugin_result_is_empty_for_default() {
assert!(
PluginResult::default().is_empty(),
"default PluginResult should be empty"
);
}
#[test]
fn plugin_result_not_empty_when_any_field_set() {
let fields: Vec<PluginResult> = vec![
PluginResult {
entry_patterns: vec![path_rule("src/**/*.ts")],
..Default::default()
},
PluginResult {
used_exports: vec![used_export_rule("src/**/*.ts", &["loader"])],
..Default::default()
},
PluginResult {
referenced_dependencies: vec!["lodash".to_string()],
..Default::default()
},
PluginResult {
always_used_files: vec!["setup.ts".to_string()],
..Default::default()
},
PluginResult {
path_aliases: vec![("@".to_string(), "src".to_string())],
..Default::default()
},
PluginResult {
setup_files: vec![PathBuf::from("/project/setup.ts")],
..Default::default()
},
];
for (i, result) in fields.iter().enumerate() {
assert!(
!result.is_empty(),
"PluginResult with field index {i} set should not be empty"
);
}
}
#[test]
fn check_has_config_file_returns_true_when_file_matches() {
let registry = PluginRegistry::default();
let matchers = registry.precompile_config_matchers();
let has_next = matchers.iter().any(|(p, _)| p.name() == "nextjs");
assert!(has_next, "nextjs should be in precompiled matchers");
let next_plugin: &dyn Plugin = &super::super::nextjs::NextJsPlugin;
let abs = PathBuf::from("/project/next.config.ts");
let relative_files: Vec<(&PathBuf, String)> = vec![(&abs, "next.config.ts".to_string())];
assert!(
check_has_config_file(next_plugin, &matchers, &relative_files),
"check_has_config_file should return true when config file matches"
);
}
#[test]
fn check_has_config_file_returns_false_when_no_match() {
let registry = PluginRegistry::default();
let matchers = registry.precompile_config_matchers();
let next_plugin: &dyn Plugin = &super::super::nextjs::NextJsPlugin;
let abs = PathBuf::from("/project/src/index.ts");
let relative_files: Vec<(&PathBuf, String)> = vec![(&abs, "src/index.ts".to_string())];
assert!(
!check_has_config_file(next_plugin, &matchers, &relative_files),
"check_has_config_file should return false when no config file matches"
);
}
#[test]
fn check_has_config_file_returns_false_for_plugin_without_config_patterns() {
let registry = PluginRegistry::default();
let matchers = registry.precompile_config_matchers();
let msw_plugin: &dyn Plugin = &super::super::msw::MswPlugin;
let abs = PathBuf::from("/project/something.ts");
let relative_files: Vec<(&PathBuf, String)> = vec![(&abs, "something.ts".to_string())];
assert!(
!check_has_config_file(msw_plugin, &matchers, &relative_files),
"plugin with no config_patterns should return false"
);
}
#[test]
fn discover_json_config_files_skips_resolved_plugins() {
let registry = PluginRegistry::default();
let matchers = registry.precompile_config_matchers();
let mut resolved: FxHashSet<&str> = FxHashSet::default();
for (plugin, _) in &matchers {
resolved.insert(plugin.name());
}
let json_configs = discover_json_config_files(&matchers, &resolved, &[], Path::new("/project"));
assert!(
json_configs.is_empty(),
"discover_json_config_files should skip all resolved plugins"
);
}
#[test]
fn discover_json_config_files_returns_empty_for_nonexistent_root() {
let registry = PluginRegistry::default();
let matchers = registry.precompile_config_matchers();
let resolved: FxHashSet<&str> = FxHashSet::default();
let json_configs = discover_json_config_files(
&matchers,
&resolved,
&[],
Path::new("/nonexistent-root-xyz-abc"),
);
assert!(
json_configs.is_empty(),
"discover_json_config_files should return empty for nonexistent root"
);
}
#[test]
fn process_static_patterns_populates_all_fields() {
let mut result = AggregatedPluginResult::default();
let plugin: &dyn Plugin = &super::super::nextjs::NextJsPlugin;
helpers::process_static_patterns(plugin, Path::new("/project"), &mut result);
assert!(result.active_plugins.contains(&"nextjs".to_string()));
assert!(!result.entry_patterns.is_empty());
assert!(!result.config_patterns.is_empty());
assert!(!result.always_used.is_empty());
assert!(!result.tooling_dependencies.is_empty());
assert!(!result.used_exports.is_empty());
}
#[test]
fn process_static_patterns_entry_patterns_tagged_with_plugin_name() {
let mut result = AggregatedPluginResult::default();
let plugin: &dyn Plugin = &super::super::nextjs::NextJsPlugin;
helpers::process_static_patterns(plugin, Path::new("/project"), &mut result);
for (_, name) in &result.entry_patterns {
assert_eq!(
name, "nextjs",
"all entry patterns should be tagged with 'nextjs'"
);
}
}
#[test]
fn process_static_patterns_always_used_tagged_with_plugin_name() {
let mut result = AggregatedPluginResult::default();
let plugin: &dyn Plugin = &super::super::nextjs::NextJsPlugin;
helpers::process_static_patterns(plugin, Path::new("/project"), &mut result);
for (_, name) in &result.always_used {
assert_eq!(
name, "nextjs",
"all always_used should be tagged with 'nextjs'"
);
}
}
#[test]
fn multiple_external_plugins_independently_activated() {
let ext_a = ExternalPluginDef {
schema: None,
name: "ext-a".to_string(),
detection: None,
enablers: vec!["dep-a".to_string()],
entry_points: vec!["a/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let ext_b = ExternalPluginDef {
schema: None,
name: "ext-b".to_string(),
detection: None,
enablers: vec!["dep-b".to_string()],
entry_points: vec!["b/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext_a, ext_b]);
let pkg = make_pkg(&["dep-a"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"ext-a".to_string()));
assert!(!result.active_plugins.contains(&"ext-b".to_string()));
assert!(result.entry_patterns.iter().any(|(p, _)| p == "a/**/*.ts"));
assert!(!result.entry_patterns.iter().any(|(p, _)| p == "b/**/*.ts"));
}
#[test]
fn external_plugin_multiple_used_exports() {
let ext = ExternalPluginDef {
schema: None,
name: "multi-ue".to_string(),
detection: None,
enablers: vec!["multi-dep".to_string()],
entry_points: vec![],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![
ExternalUsedExport {
pattern: "routes/**/*.ts".to_string(),
exports: vec!["loader".to_string(), "action".to_string()],
},
ExternalUsedExport {
pattern: "api/**/*.ts".to_string(),
exports: vec!["GET".to_string(), "POST".to_string()],
},
],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["multi-dep"]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert_eq!(
result.used_exports.len(),
2,
"should have two used_export entries"
);
assert!(result.used_exports.iter().any(|rule| {
rule.rule.path.pattern == "routes/**/*.ts"
&& rule.rule.exports.contains(&"loader".to_string())
}));
assert!(result.used_exports.iter().any(|rule| {
rule.rule.path.pattern == "api/**/*.ts" && rule.rule.exports.contains(&"GET".to_string())
}));
}
#[test]
fn default_registry_has_all_builtin_plugins() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&[
"next",
"vitest",
"eslint",
"typescript",
"tailwindcss",
"prisma",
]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"nextjs".to_string()));
assert!(result.active_plugins.contains(&"vitest".to_string()));
assert!(result.active_plugins.contains(&"eslint".to_string()));
assert!(result.active_plugins.contains(&"typescript".to_string()));
assert!(result.active_plugins.contains(&"tailwind".to_string()));
assert!(result.active_plugins.contains(&"prisma".to_string()));
}
#[test]
fn run_workspace_fast_returns_empty_for_no_active_plugins() {
let registry = PluginRegistry::default();
let matchers = registry.precompile_config_matchers();
let pkg = PackageJson::default();
let relative_files: Vec<(&PathBuf, String)> = vec![];
let result = registry.run_workspace_fast(
&pkg,
Path::new("/workspace/pkg"),
Path::new("/workspace"),
&matchers,
&relative_files,
);
assert!(result.active_plugins.is_empty());
assert!(result.entry_patterns.is_empty());
assert!(result.config_patterns.is_empty());
assert!(result.always_used.is_empty());
}
#[test]
fn run_workspace_fast_detects_active_plugins() {
let registry = PluginRegistry::default();
let matchers = registry.precompile_config_matchers();
let pkg = make_pkg(&["next"]);
let relative_files: Vec<(&PathBuf, String)> = vec![];
let result = registry.run_workspace_fast(
&pkg,
Path::new("/workspace/pkg"),
Path::new("/workspace"),
&matchers,
&relative_files,
);
assert!(result.active_plugins.contains(&"nextjs".to_string()));
assert!(!result.entry_patterns.is_empty());
}
#[test]
fn run_workspace_fast_filters_matchers_to_active_plugins() {
let registry = PluginRegistry::default();
let matchers = registry.precompile_config_matchers();
let pkg = make_pkg(&["next"]);
let relative_files: Vec<(&PathBuf, String)> = vec![];
let result = registry.run_workspace_fast(
&pkg,
Path::new("/workspace/pkg"),
Path::new("/workspace"),
&matchers,
&relative_files,
);
assert!(result.active_plugins.contains(&"nextjs".to_string()));
assert!(
!result.active_plugins.contains(&"jest".to_string()),
"jest should not be active without jest dep"
);
}
#[test]
fn process_external_plugins_empty_list() {
let mut result = AggregatedPluginResult::default();
helpers::process_external_plugins(&[], &[], Path::new("/project"), &[], &mut result);
assert!(result.active_plugins.is_empty());
}
#[test]
fn process_external_plugins_prefix_enabler_requires_slash() {
let ext = ExternalPluginDef {
schema: None,
name: "prefix-strict".to_string(),
detection: None,
enablers: vec!["@org/".to_string()],
entry_points: vec![],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let mut result = AggregatedPluginResult::default();
let deps = vec!["@organism".to_string()];
helpers::process_external_plugins(&[ext], &deps, Path::new("/project"), &[], &mut result);
assert!(
!result.active_plugins.contains(&"prefix-strict".to_string()),
"@org/ prefix should not match @organism"
);
}
#[test]
fn process_external_plugins_prefix_enabler_matches_scoped() {
let ext = ExternalPluginDef {
schema: None,
name: "prefix-match".to_string(),
detection: None,
enablers: vec!["@org/".to_string()],
entry_points: vec![],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let mut result = AggregatedPluginResult::default();
let deps = vec!["@org/core".to_string()];
helpers::process_external_plugins(&[ext], &deps, Path::new("/project"), &[], &mut result);
assert!(
result.active_plugins.contains(&"prefix-match".to_string()),
"@org/ prefix should match @org/core"
);
}
#[test]
fn run_with_config_file_in_discovered_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("vitest.config.ts"),
r"
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
setupFiles: ['./test/setup.ts'],
}
});
",
)
.unwrap();
let registry = PluginRegistry::default();
let pkg = make_pkg(&["vitest"]);
let config_path = root.join("vitest.config.ts");
let discovered = vec![config_path];
let result = registry.run(&pkg, root, &discovered);
assert!(result.active_plugins.contains(&"vitest".to_string()));
assert!(
result
.entry_patterns
.iter()
.any(|(p, _)| p == "tests/**/*.test.ts"),
"config parsing should extract test.include patterns"
);
let vitest_patterns: Vec<_> = result
.entry_patterns
.iter()
.filter(|(_, name)| name == "vitest")
.collect();
assert_eq!(
vitest_patterns.len(),
1,
"test.include should replace static defaults, not add to them; found: {vitest_patterns:?}"
);
assert_eq!(vitest_patterns[0].0, "tests/**/*.test.ts");
assert!(
!result.setup_files.is_empty(),
"config parsing should extract setupFiles"
);
assert!(
result.referenced_dependencies.iter().any(|d| d == "vitest"),
"config parsing should extract imports as referenced dependencies"
);
}
#[test]
fn run_discovers_json_config_on_disk_fallback() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("angular.json"),
r#"{
"version": 1,
"projects": {
"app": {
"root": "",
"architect": {
"build": {
"options": {
"main": "src/main.ts"
}
}
}
}
}
}"#,
)
.unwrap();
let registry = PluginRegistry::default();
let pkg = make_pkg(&["@angular/core"]);
let result = registry.run(&pkg, root, &[]);
assert!(result.active_plugins.contains(&"angular".to_string()));
assert!(
result
.entry_patterns
.iter()
.any(|(p, _)| p.contains("src/main.ts")),
"angular.json parsing should extract main entry point"
);
}
#[test]
fn peer_deps_trigger_plugins() {
let json = serde_json::json!({ "peerDependencies": deps_json(&["next"]) });
let pkg: PackageJson = serde_json::from_value(json).unwrap();
let registry = PluginRegistry::default();
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.active_plugins.contains(&"nextjs".to_string()),
"peerDependencies should trigger plugin detection"
);
}
#[test]
fn optional_deps_trigger_plugins() {
let json = serde_json::json!({ "optionalDependencies": deps_json(&["next"]) });
let pkg: PackageJson = serde_json::from_value(json).unwrap();
let registry = PluginRegistry::default();
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.active_plugins.contains(&"nextjs".to_string()),
"optionalDependencies should trigger plugin detection"
);
}
#[test]
fn check_plugin_detection_file_exists_wildcard_in_discovered() {
let detection = PluginDetection::FileExists {
pattern: "**/*.svelte".to_string(),
};
let discovered = vec![
PathBuf::from("/root/src/App.svelte"),
PathBuf::from("/root/src/utils.ts"),
];
assert!(
check_plugin_detection(&detection, &[], Path::new("/root"), &discovered),
"FileExists with glob should match discovered .svelte file"
);
}
#[test]
fn external_plugin_detection_all_with_file_and_dep() {
let ext = ExternalPluginDef {
schema: None,
name: "combo-check".to_string(),
detection: Some(PluginDetection::All {
conditions: vec![
PluginDetection::Dependency {
package: "my-lib".to_string(),
},
PluginDetection::FileExists {
pattern: "src/setup.ts".to_string(),
},
],
}),
enablers: vec![],
entry_points: vec!["src/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["my-lib"]);
let discovered = vec![PathBuf::from("/project/src/setup.ts")];
let result = registry.run(&pkg, Path::new("/project"), &discovered);
assert!(
result.active_plugins.contains(&"combo-check".to_string()),
"All(dep + fileExists) should pass when both conditions met"
);
}
#[test]
fn external_plugin_detection_all_dep_and_file_missing_file() {
let ext = ExternalPluginDef {
schema: None,
name: "combo-fail".to_string(),
detection: Some(PluginDetection::All {
conditions: vec![
PluginDetection::Dependency {
package: "my-lib".to_string(),
},
PluginDetection::FileExists {
pattern: "src/nonexistent-xyz.ts".to_string(),
},
],
}),
enablers: vec![],
entry_points: vec![],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let registry = PluginRegistry::new(vec![ext]);
let pkg = make_pkg(&["my-lib"]);
let result = registry.run(&pkg, Path::new("/nonexistent-root-xyz"), &[]);
assert!(
!result.active_plugins.contains(&"combo-fail".to_string()),
"All(dep + fileExists) should fail when file is missing"
);
}
#[test]
fn vitest_activates_by_config_file_existence() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("vitest.config.ts"), "").unwrap();
let registry = PluginRegistry::default();
let pkg = PackageJson::default();
let result = registry.run(&pkg, root, &[]);
assert!(
result.active_plugins.contains(&"vitest".to_string()),
"vitest should activate when vitest.config.ts exists on disk"
);
}
#[test]
fn eslint_activates_by_config_file_existence() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("eslint.config.js"), "").unwrap();
let registry = PluginRegistry::default();
let pkg = PackageJson::default();
let result = registry.run(&pkg, root, &[]);
assert!(
result.active_plugins.contains(&"eslint".to_string()),
"eslint should activate when eslint.config.js exists on disk"
);
}
#[test]
fn discover_json_config_files_finds_in_subdirectory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let subdir = root.join("packages").join("app");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(subdir.join("project.json"), r#"{"name": "app"}"#).unwrap();
let registry = PluginRegistry::default();
let matchers = registry.precompile_config_matchers();
let resolved: FxHashSet<&str> = FxHashSet::default();
let src_file = subdir.join("index.ts");
let relative_files: Vec<(&PathBuf, String)> =
vec![(&src_file, "packages/app/index.ts".to_string())];
let json_configs = discover_json_config_files(&matchers, &resolved, &relative_files, root);
let found_project_json = json_configs
.iter()
.any(|(path, _)| path.ends_with("project.json"));
assert!(
found_project_json,
"discover_json_config_files should find project.json in parent dir of discovered source file"
);
}
#[test]
fn create_builtin_plugins_returns_non_empty() {
let plugins = builtin::create_builtin_plugins();
assert!(
!plugins.is_empty(),
"create_builtin_plugins should return a non-empty list"
);
}
#[test]
fn create_builtin_plugins_all_have_unique_names() {
let plugins = builtin::create_builtin_plugins();
let mut seen = FxHashSet::default();
for plugin in &plugins {
let name = plugin.name();
assert!(seen.insert(name), "duplicate plugin name found: {name}");
}
}
#[test]
fn create_builtin_plugins_contains_critical_plugins() {
let plugins = builtin::create_builtin_plugins();
let names: Vec<&str> = plugins.iter().map(|p| p.name()).collect();
let critical = [
"typescript",
"eslint",
"jest",
"vitest",
"webpack",
"nextjs",
"vite",
"prettier",
"tailwind",
"storybook",
"prisma",
"babel",
];
for expected in &critical {
assert!(
names.contains(expected),
"critical plugin '{expected}' missing from builtin plugins"
);
}
}
#[test]
fn create_builtin_plugins_all_have_non_empty_names() {
let plugins = builtin::create_builtin_plugins();
for plugin in &plugins {
assert!(
!plugin.name().is_empty(),
"all builtin plugins must have a non-empty name"
);
}
}
#[test]
fn process_static_patterns_with_minimal_plugin() {
let mut result = AggregatedPluginResult::default();
let plugin: &dyn Plugin = &super::super::msw::MswPlugin;
helpers::process_static_patterns(plugin, Path::new("/project"), &mut result);
assert!(result.active_plugins.contains(&"msw".to_string()));
assert!(!result.entry_patterns.is_empty());
assert!(result.config_patterns.is_empty());
assert!(!result.always_used.is_empty());
assert!(!result.tooling_dependencies.is_empty());
}
#[test]
fn process_static_patterns_accumulates_across_plugins() {
let mut result = AggregatedPluginResult::default();
let next_plugin: &dyn Plugin = &super::super::nextjs::NextJsPlugin;
let msw_plugin: &dyn Plugin = &super::super::msw::MswPlugin;
helpers::process_static_patterns(next_plugin, Path::new("/project"), &mut result);
let count_after_first = result.entry_patterns.len();
helpers::process_static_patterns(msw_plugin, Path::new("/project"), &mut result);
assert!(
result.entry_patterns.len() > count_after_first,
"second plugin should add more entry patterns"
);
assert_eq!(result.active_plugins.len(), 2);
assert!(result.active_plugins.contains(&"nextjs".to_string()));
assert!(result.active_plugins.contains(&"msw".to_string()));
}
#[test]
fn process_config_result_empty_result_is_noop() {
let mut aggregated = AggregatedPluginResult::default();
let empty = PluginResult::default();
process_config_result("empty-plugin", empty, &mut aggregated);
assert!(aggregated.entry_patterns.is_empty());
assert!(aggregated.referenced_dependencies.is_empty());
assert!(aggregated.discovered_always_used.is_empty());
assert!(aggregated.setup_files.is_empty());
}
#[test]
fn check_plugin_detection_any_with_single_match() {
let detection = PluginDetection::Any {
conditions: vec![
PluginDetection::Dependency {
package: "missing-pkg".to_string(),
},
PluginDetection::Dependency {
package: "present-pkg".to_string(),
},
],
};
let deps = vec!["present-pkg"];
assert!(
check_plugin_detection(&detection, &deps, Path::new("/project"), &[]),
"Any should succeed when at least one condition matches"
);
}
#[test]
fn check_plugin_detection_all_with_all_matching() {
let detection = PluginDetection::All {
conditions: vec![
PluginDetection::Dependency {
package: "pkg-a".to_string(),
},
PluginDetection::Dependency {
package: "pkg-b".to_string(),
},
],
};
let deps = vec!["pkg-a", "pkg-b"];
assert!(
check_plugin_detection(&detection, &deps, Path::new("/project"), &[]),
"All should succeed when every condition matches"
);
}
#[test]
fn check_plugin_detection_all_with_partial_match() {
let detection = PluginDetection::All {
conditions: vec![
PluginDetection::Dependency {
package: "pkg-a".to_string(),
},
PluginDetection::Dependency {
package: "pkg-b".to_string(),
},
],
};
let deps = vec!["pkg-a"];
assert!(
!check_plugin_detection(&detection, &deps, Path::new("/project"), &[]),
"All should fail when only some conditions match"
);
}
#[test]
fn check_plugin_detection_any_with_no_matches() {
let detection = PluginDetection::Any {
conditions: vec![
PluginDetection::Dependency {
package: "missing-a".to_string(),
},
PluginDetection::Dependency {
package: "missing-b".to_string(),
},
],
};
let deps: Vec<&str> = vec!["unrelated"];
assert!(
!check_plugin_detection(&detection, &deps, Path::new("/project"), &[]),
"Any should fail when no conditions match"
);
}
#[test]
fn check_plugin_detection_nested_all_inside_any() {
let detection = PluginDetection::Any {
conditions: vec![
PluginDetection::All {
conditions: vec![
PluginDetection::Dependency {
package: "pkg-a".to_string(),
},
PluginDetection::Dependency {
package: "pkg-b".to_string(),
},
],
},
PluginDetection::Dependency {
package: "pkg-c".to_string(),
},
],
};
let deps = vec!["pkg-c"];
assert!(
check_plugin_detection(&detection, &deps, Path::new("/project"), &[]),
"nested All inside Any: should pass via the Any fallback branch"
);
}
#[test]
fn process_external_plugins_detection_dependency() {
let ext = ExternalPluginDef {
schema: None,
name: "detect-dep".to_string(),
detection: Some(PluginDetection::Dependency {
package: "my-dep".to_string(),
}),
enablers: vec![],
entry_points: vec!["src/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let mut result = AggregatedPluginResult::default();
let deps = vec!["my-dep".to_string()];
helpers::process_external_plugins(&[ext], &deps, Path::new("/project"), &[], &mut result);
assert!(result.active_plugins.contains(&"detect-dep".to_string()));
assert!(
result
.entry_patterns
.iter()
.any(|(p, _)| p == "src/**/*.ts")
);
}
#[test]
fn process_external_plugins_detection_not_matched() {
let ext = ExternalPluginDef {
schema: None,
name: "detect-miss".to_string(),
detection: Some(PluginDetection::Dependency {
package: "missing-dep".to_string(),
}),
enablers: vec![],
entry_points: vec!["src/**/*.ts".to_string()],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
entry_point_role: fallow_config::EntryPointRole::Runtime,
};
let mut result = AggregatedPluginResult::default();
let deps = vec!["other-dep".to_string()];
helpers::process_external_plugins(&[ext], &deps, Path::new("/project"), &[], &mut result);
assert!(!result.active_plugins.contains(&"detect-miss".to_string()));
assert!(result.entry_patterns.is_empty());
}
#[test]
fn all_builtin_plugins_activated_by_their_enablers() {
let plugins = builtin::create_builtin_plugins();
for plugin in &plugins {
let enablers = plugin.enablers();
for enabler in enablers {
let dep = if enabler.ends_with('/') {
format!("{enabler}test-pkg")
} else {
enabler.to_string()
};
let deps = vec![dep.clone()];
assert!(
plugin.is_enabled_with_deps(&deps, Path::new("/nonexistent-xyz")),
"plugin '{}' should be enabled by dep '{}' (enabler: '{}')",
plugin.name(),
dep,
enabler
);
}
}
}
#[test]
fn no_builtin_plugin_activated_by_random_dep() {
let plugins = builtin::create_builtin_plugins();
let random_dep = vec!["completely-unrelated-package-xyz-42".to_string()];
for plugin in &plugins {
let name = plugin.name();
if name == "vitest" || name == "eslint" {
continue;
}
assert!(
!plugin.is_enabled_with_deps(&random_dep, Path::new("/nonexistent-xyz")),
"plugin '{name}' should NOT activate for unrelated dep"
);
}
}
#[test]
fn database_plugins_have_correct_enablers() {
let registry = PluginRegistry::default();
let cases = vec![
("prisma", make_pkg(&["prisma"])),
("drizzle", make_pkg(&["drizzle-orm"])),
("typeorm", make_pkg(&["typeorm"])),
];
for (expected_plugin, pkg) in cases {
let result = registry.run(&pkg, Path::new("/project"), &[]);
assert!(
result.active_plugins.contains(&expected_plugin.to_string()),
"'{expected_plugin}' plugin should activate with its deps"
);
}
}
#[test]
fn monorepo_plugins_have_correct_enablers() {
let registry = PluginRegistry::default();
let nx_pkg = make_pkg(&["nx"]);
let result = registry.run(&nx_pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"nx".to_string()));
let turbo_pkg = make_pkg(&["turbo"]);
let result = registry.run(&turbo_pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"turborepo".to_string()));
}
#[test]
fn css_plugins_have_correct_enablers() {
let registry = PluginRegistry::default();
let tailwind_pkg = make_pkg(&["tailwindcss"]);
let result = registry.run(&tailwind_pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"tailwind".to_string()));
let postcss_pkg = make_pkg(&["postcss"]);
let result = registry.run(&postcss_pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"postcss".to_string()));
}
#[test]
fn transpiler_plugins_have_correct_enablers() {
let registry = PluginRegistry::default();
let ts_pkg = make_pkg(&["typescript"]);
let result = registry.run(&ts_pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"typescript".to_string()));
let babel_pkg = make_pkg(&["@babel/core"]);
let result = registry.run(&babel_pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"babel".to_string()));
let swc_pkg = make_pkg(&["@swc/core"]);
let result = registry.run(&swc_pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"swc".to_string()));
}
#[test]
fn deployment_plugins_have_correct_enablers() {
let registry = PluginRegistry::default();
let wrangler_pkg = make_pkg(&["wrangler"]);
let result = registry.run(&wrangler_pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"wrangler".to_string()));
let sentry_pkg = make_pkg(&["@sentry/node"]);
let result = registry.run(&sentry_pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"sentry".to_string()));
}
#[test]
fn git_hooks_plugins_have_correct_enablers() {
let registry = PluginRegistry::default();
let husky_pkg = make_pkg(&["husky"]);
let result = registry.run(&husky_pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"husky".to_string()));
let lint_staged_pkg = make_pkg(&["lint-staged"]);
let result = registry.run(&lint_staged_pkg, Path::new("/project"), &[]);
assert!(result.active_plugins.contains(&"lint-staged".to_string()));
}
#[test]
fn aggregated_result_default_is_empty() {
let result = AggregatedPluginResult::default();
assert!(result.entry_patterns.is_empty());
assert!(result.config_patterns.is_empty());
assert!(result.always_used.is_empty());
assert!(result.used_exports.is_empty());
assert!(result.referenced_dependencies.is_empty());
assert!(result.discovered_always_used.is_empty());
assert!(result.setup_files.is_empty());
assert!(result.tooling_dependencies.is_empty());
assert!(result.script_used_packages.is_empty());
assert!(result.virtual_module_prefixes.is_empty());
assert!(result.path_aliases.is_empty());
assert!(result.active_plugins.is_empty());
}
#[test]
fn full_stack_project_activates_expected_plugins() {
let registry = PluginRegistry::default();
let pkg = make_pkg(&[
"next",
"react",
"vitest",
"typescript",
"tailwindcss",
"prisma",
"eslint",
"@storybook/react",
]);
let result = registry.run(&pkg, Path::new("/project"), &[]);
let expected_plugins = [
"nextjs",
"vitest",
"typescript",
"tailwind",
"prisma",
"eslint",
"storybook",
];
for expected in &expected_plugins {
assert!(
result.active_plugins.contains(&expected.to_string()),
"full stack project should activate '{expected}' plugin"
);
}
assert!(!result.entry_patterns.is_empty());
assert!(!result.tooling_dependencies.is_empty());
assert!(!result.always_used.is_empty());
}
#[test]
fn precompile_config_matchers_covers_plugins_with_configs() {
let registry = PluginRegistry::default();
let matchers = registry.precompile_config_matchers();
let names: Vec<&str> = matchers.iter().map(|(p, _)| p.name()).collect();
assert!(
names.contains(&"jest"),
"precompiled matchers should include jest"
);
assert!(
names.contains(&"typescript"),
"precompiled matchers should include typescript"
);
assert!(
names.contains(&"nextjs"),
"precompiled matchers should include nextjs"
);
assert!(
!names.contains(&"msw"),
"precompiled matchers should not include msw (no config_patterns)"
);
}
#[test]
fn precompile_config_matchers_all_have_non_empty_matchers() {
let registry = PluginRegistry::default();
let matchers = registry.precompile_config_matchers();
for (plugin, matcher_list) in &matchers {
assert!(
!matcher_list.is_empty(),
"plugin '{}' has config_patterns but compiled to zero matchers",
plugin.name()
);
}
}
#[test]
fn run_with_jest_config_extracts_setup_and_transform() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("jest.config.js"),
r#"
module.exports = {
preset: "ts-jest",
setupFilesAfterEnv: ["./test/setup.ts"],
transform: { "^.+\\.tsx?$": "ts-jest" },
reporters: ["default", "jest-junit"]
};
"#,
)
.unwrap();
let registry = PluginRegistry::default();
let pkg = make_pkg(&["jest"]);
let config_path = root.join("jest.config.js");
let discovered = vec![config_path];
let result = registry.run(&pkg, root, &discovered);
assert!(result.active_plugins.contains(&"jest".to_string()));
assert!(
result
.referenced_dependencies
.contains(&"ts-jest".to_string()),
"jest config should extract preset as referenced dependency"
);
assert!(
result
.referenced_dependencies
.contains(&"jest-junit".to_string()),
"jest config should extract reporters as referenced dependency"
);
assert!(
result
.setup_files
.iter()
.any(|(p, _)| p.ends_with("test/setup.ts")),
"jest config should extract setupFilesAfterEnv"
);
}
#[test]
fn run_with_storybook_config_extracts_addons() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".storybook")).unwrap();
std::fs::write(
root.join(".storybook/main.ts"),
r#"
export default {
stories: ["../src/**/*.stories.tsx"],
addons: [
"@storybook/addon-essentials",
["@storybook/addon-a11y", { level: "AA" }]
],
framework: { name: "@storybook/react-vite" }
};
"#,
)
.unwrap();
let registry = PluginRegistry::default();
let pkg = make_pkg(&["storybook"]);
let config_path = root.join(".storybook/main.ts");
let discovered = vec![config_path];
let result = registry.run(&pkg, root, &discovered);
assert!(result.active_plugins.contains(&"storybook".to_string()));
assert!(
result
.referenced_dependencies
.contains(&"@storybook/addon-essentials".to_string()),
"storybook config should extract addons"
);
assert!(
result
.referenced_dependencies
.contains(&"@storybook/addon-a11y".to_string()),
"storybook config should extract addons from tuples"
);
assert!(
result
.referenced_dependencies
.contains(&"@storybook/react-vite".to_string()),
"storybook config should extract framework.name"
);
assert!(
result
.entry_patterns
.iter()
.any(|(p, _)| p.contains("stories")),
"storybook config should extract stories as entry patterns"
);
}