use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use fallow_config::{
ManifestEntryRule, ManifestFormat, ScopedUsedClassMemberRule, UsedClassMemberRule,
};
use super::manifest_entries::{parse_manifest, seed_parsed_manifest};
use super::{Plugin, PluginResult};
const MANIFEST_FILE_NAME: &str = "kibana.jsonc";
const CONFIG_PATTERNS: &[&str] = &[MANIFEST_FILE_NAME];
const ALWAYS_USED: &[&str] = &[MANIFEST_FILE_NAME, "**/kibana.jsonc"];
const PLUGIN_INTERFACES: &[&str] = &["Plugin", "PrebootPlugin", "AsyncPlugin"];
const PLUGIN_LIFECYCLE_MEMBERS: &[&str] = &["setup", "start", "stop"];
const RUNTIME_RULES: &str = r#"[
{
"manifests": "**/kibana.jsonc",
"when": { "type": "plugin" },
"entries": [
{ "path": "public/index.{ts,tsx}", "when": { "plugin.browser": true } },
{ "path": "server/index.{ts,tsx}", "when": { "plugin.server": true } },
{ "path": "common/index.{ts,tsx}" },
{ "path": "${plugin.extraPublicDirs}/index.{ts,tsx}" }
]
}
]"#;
const SUPPORT_RULES: &str = r#"[
{
"manifests": "**/kibana.jsonc",
"entries": [
{ "path": "test/scout/**/*playwright.config.ts" },
{ "path": "test/scout/**/global.{setup,teardown}.ts" }
]
}
]"#;
fn parse_rules(source: &'static str) -> Vec<ManifestEntryRule> {
serde_json::from_str(source).unwrap_or_default()
}
fn runtime_rules() -> &'static [ManifestEntryRule] {
static RULES: OnceLock<Vec<ManifestEntryRule>> = OnceLock::new();
RULES.get_or_init(|| parse_rules(RUNTIME_RULES))
}
fn support_rules() -> &'static [ManifestEntryRule] {
static RULES: OnceLock<Vec<ManifestEntryRule>> = OnceLock::new();
RULES.get_or_init(|| parse_rules(SUPPORT_RULES))
}
pub struct KibanaPlugin;
impl Plugin for KibanaPlugin {
fn name(&self) -> &'static str {
"kibana"
}
fn is_enabled_with_deps(&self, _deps: &[String], root: &Path) -> bool {
root.join(MANIFEST_FILE_NAME).is_file()
}
fn is_enabled_with_files(
&self,
deps: &[String],
root: &Path,
discovered_files: &[PathBuf],
candidate_index: Option<&super::registry::ConfigCandidateIndex>,
) -> bool {
if self.is_enabled_with_deps(deps, root) {
return true;
}
let name = OsStr::new(MANIFEST_FILE_NAME);
match candidate_index {
Some(index) => index.any_descendant_contains(root, name),
None => has_manifest_above_discovered_file(root, discovered_files),
}
}
fn config_patterns(&self) -> &'static [&'static str] {
CONFIG_PATTERNS
}
fn always_used(&self) -> &'static [&'static str] {
ALWAYS_USED
}
fn used_class_member_rules(&self) -> Vec<UsedClassMemberRule> {
PLUGIN_INTERFACES
.iter()
.map(|interface| {
UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
extends: None,
implements: Some((*interface).to_string()),
members: PLUGIN_LIFECYCLE_MEMBERS
.iter()
.map(|member| (*member).to_string())
.collect(),
})
})
.collect()
}
fn resolve_config(&self, config_path: &Path, source: &str, root: &Path) -> PluginResult {
let mut result = PluginResult::default();
if config_path.file_name() != Some(OsStr::new(MANIFEST_FILE_NAME)) {
return result;
}
let Some(manifest) = parse_manifest(source, ManifestFormat::Jsonc) else {
return result;
};
result.extend_entry_patterns(seed_rules(runtime_rules(), &manifest, config_path, root));
result
.always_used_files
.extend(seed_rules(support_rules(), &manifest, config_path, root));
result
}
}
fn seed_rules(
rules: &[ManifestEntryRule],
manifest: &serde_json::Value,
manifest_path: &Path,
root: &Path,
) -> Vec<String> {
let mut seeded: Vec<String> = rules
.iter()
.flat_map(|rule| seed_parsed_manifest(rule, manifest, manifest_path, root))
.collect();
seeded.sort();
seeded.dedup();
seeded
}
fn has_manifest_above_discovered_file(root: &Path, discovered_files: &[PathBuf]) -> bool {
let mut seen: rustc_hash::FxHashSet<&Path> = rustc_hash::FxHashSet::default();
for file in discovered_files {
let mut current = file.parent();
while let Some(dir) = current {
if !dir.starts_with(root) || !seen.insert(dir) {
break;
}
if dir.join(MANIFEST_FILE_NAME).is_file() {
return true;
}
current = dir.parent();
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_config::EntryPointRole;
fn resolve(manifest_rel: &str, source: &str) -> PluginResult {
let root = Path::new("/repo");
KibanaPlugin.resolve_config(&root.join(manifest_rel), source, root)
}
fn entries(result: &PluginResult) -> Vec<String> {
result
.entry_patterns
.iter()
.map(|rule| rule.pattern.clone())
.collect()
}
#[test]
fn embedded_rules_parse() {
assert_eq!(runtime_rules().len(), 1);
assert_eq!(support_rules().len(), 1);
}
#[test]
fn plugin_manifest_seeds_browser_server_and_common_entries() {
let result = resolve(
"x-pack/plugins/alpha/kibana.jsonc",
r#"{
"type": "plugin",
"id": "@kbn/alpha-plugin",
// comment
"plugin": { "id": "alpha", "server": true, "browser": true, }
}"#,
);
assert_eq!(
entries(&result),
vec![
"x-pack/plugins/alpha/common/index.{ts,tsx}",
"x-pack/plugins/alpha/public/index.{ts,tsx}",
"x-pack/plugins/alpha/server/index.{ts,tsx}",
]
);
}
#[test]
fn plugin_flags_gate_public_and_server_entries() {
let result = resolve(
"plugins/beta/kibana.jsonc",
r#"{ "type": "plugin", "plugin": { "id": "beta", "server": false, "browser": true } }"#,
);
assert_eq!(
entries(&result),
vec![
"plugins/beta/common/index.{ts,tsx}",
"plugins/beta/public/index.{ts,tsx}",
]
);
}
#[test]
fn extra_public_dirs_seed_their_index_files() {
let result = resolve(
"plugins/data/kibana.jsonc",
r#"{
"type": "plugin",
"plugin": { "id": "data", "browser": true, "extraPublicDirs": ["common", "common/api"] }
}"#,
);
assert_eq!(
entries(&result),
vec![
"plugins/data/common/api/index.{ts,tsx}",
"plugins/data/common/index.{ts,tsx}",
"plugins/data/public/index.{ts,tsx}",
]
);
}
#[test]
fn package_manifest_seeds_no_plugin_entries() {
let result = resolve(
"packages/kbn-shared/kibana.jsonc",
r#"{ "type": "shared-common", "id": "@kbn/shared" }"#,
);
assert!(entries(&result).is_empty());
}
#[test]
fn every_manifest_keeps_scout_playwright_files() {
let result = resolve(
"packages/kbn-shared/kibana.jsonc",
r#"{ "type": "shared-common", "id": "@kbn/shared" }"#,
);
assert_eq!(
result.always_used_files,
vec![
"packages/kbn-shared/test/scout/**/*playwright.config.ts",
"packages/kbn-shared/test/scout/**/global.{setup,teardown}.ts",
]
);
}
#[test]
fn unreadable_or_foreign_manifest_seeds_nothing() {
assert!(resolve("plugins/x/kibana.jsonc", "{ not json").is_empty());
let root = Path::new("/repo");
let other = KibanaPlugin.resolve_config(
&root.join("plugins/x/other.jsonc"),
r#"{ "type": "plugin", "plugin": { "browser": true } }"#,
root,
);
assert!(other.is_empty());
}
#[test]
fn extra_public_dir_outside_the_root_is_skipped() {
let result = resolve(
"kibana.jsonc",
r#"{ "type": "plugin", "plugin": { "extraPublicDirs": ["../outside"] } }"#,
);
assert_eq!(entries(&result), vec!["common/index.{ts,tsx}"]);
}
#[test]
fn activates_from_a_nested_manifest_on_disk() {
let dir = tempfile::tempdir().unwrap();
let plugin = dir.path().join("x-pack/plugins/alpha");
std::fs::create_dir_all(plugin.join("public")).unwrap();
std::fs::write(plugin.join(MANIFEST_FILE_NAME), r#"{"type":"plugin"}"#).unwrap();
let file = plugin.join("public/index.ts");
std::fs::write(&file, "").unwrap();
assert!(KibanaPlugin.is_enabled_with_files(&[], dir.path(), &[file], None));
}
#[test]
fn activates_from_a_nested_manifest_in_the_candidate_index() {
let root = Path::new("/repo");
let manifest = root.join("x-pack/plugins/alpha/kibana.jsonc");
let index = super::super::registry::ConfigCandidateIndex::build(std::iter::once(
manifest.as_path(),
));
assert!(KibanaPlugin.is_enabled_with_files(&[], root, &[], Some(&index)));
}
#[test]
fn inactive_without_a_manifest() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("src/index.ts");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "").unwrap();
assert!(!KibanaPlugin.is_enabled_with_files(&[], dir.path(), &[file], None));
let index = super::super::registry::ConfigCandidateIndex::build(std::iter::empty());
assert!(!KibanaPlugin.is_enabled_with_files(&[], dir.path(), &[], Some(&index)));
}
#[test]
fn plugin_class_lifecycle_members_are_used() {
let rules = KibanaPlugin.used_class_member_rules();
let mut interfaces = Vec::new();
for rule in &rules {
let fallow_config::UsedClassMemberRule::Scoped(rule) = rule else {
panic!("each lifecycle rule must be scoped to a plugin interface");
};
assert_eq!(rule.extends, None);
assert_eq!(rule.members, vec!["setup", "start", "stop"]);
interfaces.push(rule.implements.clone().unwrap_or_default());
}
assert_eq!(interfaces, vec!["Plugin", "PrebootPlugin", "AsyncPlugin"]);
}
#[test]
fn is_runtime_entry_role() {
assert_eq!(KibanaPlugin.entry_point_role(), EntryPointRole::Runtime);
}
}