use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use fallow_config::{ExternalPluginDef, ManifestEntryRule};
use serde_json::Value;
use super::PathRule;
use super::config_parser::normalize_config_path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WarningKind {
ManifestsMatchedNone,
WhenExcludedAll,
FieldPathUnresolved,
EntriesEmpty,
ManifestParseFailed,
EntryOutsideRoot,
SeededPathsMissing,
}
impl WarningKind {
#[must_use]
pub fn as_kebab(self) -> &'static str {
match self {
Self::ManifestsMatchedNone => "manifests-matched-none",
Self::WhenExcludedAll => "when-excluded-all",
Self::FieldPathUnresolved => "field-path-unresolved",
Self::EntriesEmpty => "entries-empty",
Self::ManifestParseFailed => "manifest-parse-failed",
Self::EntryOutsideRoot => "entry-outside-root",
Self::SeededPathsMissing => "seeded-paths-missing",
}
}
}
#[derive(Debug, Clone)]
pub struct CheckWarning {
pub kind: WarningKind,
pub glob: Option<String>,
pub field_path: Option<String>,
pub manifest: Option<String>,
pub entry: Option<String>,
}
impl CheckWarning {
fn glob(kind: WarningKind, glob: &str) -> Self {
Self {
kind,
glob: Some(glob.to_string()),
field_path: None,
manifest: None,
entry: None,
}
}
fn field(kind: WarningKind, field_path: String) -> Self {
Self {
kind,
glob: None,
field_path: Some(field_path),
manifest: None,
entry: None,
}
}
fn manifest(kind: WarningKind, manifest: String) -> Self {
Self {
kind,
glob: None,
field_path: None,
manifest: Some(manifest),
entry: None,
}
}
}
#[derive(Debug, Clone)]
pub struct ManifestResult {
pub path: String,
pub when_passed: bool,
pub seeded: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct RuleReport {
pub manifests: String,
pub manifests_matched: Vec<String>,
pub matched: Vec<ManifestResult>,
pub warnings: Vec<CheckWarning>,
}
#[must_use]
pub fn evaluate_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<PathRule> {
let mut out = Vec::new();
for rule in &ext.manifest_entries {
let report = build_rule_report(rule, root);
for manifest in &report.matched {
for seed in &manifest.seeded {
out.push(PathRule::new(seed.clone()));
}
}
emit_report_warnings(&ext.name, &report);
}
out
}
#[must_use]
pub fn check_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<RuleReport> {
ext.manifest_entries
.iter()
.map(|rule| build_rule_report(rule, root))
.collect()
}
fn build_rule_report(rule: &ManifestEntryRule, root: &Path) -> RuleReport {
let mut report = RuleReport {
manifests: rule.manifests.clone(),
manifests_matched: Vec::new(),
matched: Vec::new(),
warnings: Vec::new(),
};
if rule.entries.is_empty() {
report.warnings.push(CheckWarning::glob(
WarningKind::EntriesEmpty,
&rule.manifests,
));
return report;
}
let Ok(glob) = globset::Glob::new(&rule.manifests) else {
report.warnings.push(CheckWarning::glob(
WarningKind::ManifestsMatchedNone,
&rule.manifests,
));
return report;
};
let matcher = glob.compile_matcher();
let referenced = referenced_field_paths(rule);
let mut resolved: BTreeMap<&str, bool> =
referenced.iter().map(|p| (p.as_str(), false)).collect();
let mut passed = 0usize;
let mut parsed = 0usize;
for file in discover_manifest_paths(root, &matcher) {
let rel_manifest = root_relative_forward_slash(&file, root)
.unwrap_or_else(|| file.to_string_lossy().replace('\\', "/"));
report.manifests_matched.push(rel_manifest.clone());
let manifest: Value = match std::fs::read_to_string(&file)
.ok()
.and_then(|source| fallow_config::jsonc::parse_to_value(&source).ok())
{
Some(value) => value,
None => {
report.warnings.push(CheckWarning::manifest(
WarningKind::ManifestParseFailed,
rel_manifest,
));
continue;
}
};
parsed += 1;
let when_passed = when_matches(&manifest, &rule.when);
let mut seeded = Vec::new();
if when_passed {
passed += 1;
for path in &referenced {
if dotted_lookup(&manifest, path).is_some()
&& let Some(flag) = resolved.get_mut(path.as_str())
{
*flag = true;
}
}
let (entries, mut entry_warnings) = seed_rule_entries(rule, &manifest, &file, root);
seeded = entries;
report.warnings.append(&mut entry_warnings);
}
report.matched.push(ManifestResult {
path: rel_manifest,
when_passed,
seeded,
});
}
report.warnings.extend(rule_level_warnings(
&rule.manifests,
report.manifests_matched.len(),
parsed,
passed,
&resolved,
));
report.matched.sort_by(|a, b| a.path.cmp(&b.path));
report.warnings.sort_by(|a, b| {
a.kind
.as_kebab()
.cmp(b.kind.as_kebab())
.then_with(|| a.manifest.cmp(&b.manifest))
.then_with(|| a.entry.cmp(&b.entry))
.then_with(|| a.field_path.cmp(&b.field_path))
});
report
}
fn rule_level_warnings(
manifests: &str,
matched: usize,
parsed: usize,
passed: usize,
resolved: &BTreeMap<&str, bool>,
) -> Vec<CheckWarning> {
let mut out = Vec::new();
if matched == 0 {
out.push(CheckWarning::glob(
WarningKind::ManifestsMatchedNone,
manifests,
));
return out;
}
if parsed > 0 && passed == 0 {
out.push(CheckWarning::glob(WarningKind::WhenExcludedAll, manifests));
return out;
}
if passed == 0 {
return out;
}
for (path, was_resolved) in resolved {
if !was_resolved {
out.push(CheckWarning::field(
WarningKind::FieldPathUnresolved,
(*path).to_string(),
));
}
}
out
}
fn seed_rule_entries(
rule: &ManifestEntryRule,
manifest: &Value,
manifest_path: &Path,
root: &Path,
) -> (Vec<String>, Vec<CheckWarning>) {
let rel_manifest = root_relative_forward_slash(manifest_path, root);
let mut seeded = Vec::new();
let mut warnings = Vec::new();
for seed in &rule.entries {
if !when_matches(manifest, &seed.when) {
continue;
}
for concrete in expand_interpolations(&seed.path, manifest) {
match normalize_config_path(&concrete, manifest_path, root) {
Some(rel) => seeded.push(rel),
None => warnings.push(CheckWarning {
kind: WarningKind::EntryOutsideRoot,
glob: None,
field_path: None,
manifest: rel_manifest.clone(),
entry: Some(concrete),
}),
}
}
}
(seeded, warnings)
}
fn emit_report_warnings(plugin_name: &str, report: &RuleReport) {
for warning in &report.warnings {
match warning.kind {
WarningKind::EntriesEmpty => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries rule for '{}' has an empty 'entries' \
list; it seeds nothing.",
report.manifests
),
WarningKind::ManifestsMatchedNone => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries 'manifests' glob '{}' matched no files. \
Check the glob and whether the manifests live under an ignored directory.",
report.manifests
),
WarningKind::ManifestParseFailed => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries skipped manifest '{}' (glob '{}') because \
it could not be read or parsed.",
warning.manifest.as_deref().unwrap_or(""),
report.manifests
),
WarningKind::WhenExcludedAll => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries 'when' gate excluded all matched \
manifest(s) for glob '{}'. No entries were seeded.",
report.manifests
),
WarningKind::FieldPathUnresolved => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries field path '{}' resolved in none of the \
gated manifest(s). Likely a typo in a 'when' key or a ${{...}} interpolation.",
warning.field_path.as_deref().unwrap_or("")
),
WarningKind::EntryOutsideRoot => tracing::warn!(
"Plugin '{plugin_name}': manifestEntries entry '{}' (from manifest '{}') resolved \
outside the project root and was skipped.",
warning.entry.as_deref().unwrap_or(""),
warning.manifest.as_deref().unwrap_or("")
),
WarningKind::SeededPathsMissing => {}
}
}
}
fn referenced_field_paths(rule: &ManifestEntryRule) -> Vec<String> {
let mut paths: Vec<String> = rule.when.keys().cloned().collect();
for seed in &rule.entries {
paths.extend(seed.when.keys().cloned());
paths.extend(interpolation_field_paths(&seed.path));
}
paths.sort();
paths.dedup();
paths
}
fn interpolation_field_paths(path: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = path;
while let Some(start) = rest.find("${") {
let after = &rest[start + 2..];
if let Some(end) = after.find('}') {
out.push(after[..end].to_string());
rest = &after[end + 1..];
} else {
break;
}
}
out
}
fn expand_interpolations(path: &str, manifest: &Value) -> Vec<String> {
let Some(start) = path.find("${") else {
return vec![path.to_string()];
};
let prefix = &path[..start];
let after = &path[start + 2..];
let Some(end) = after.find('}') else {
return Vec::new();
};
let field = &after[..end];
let suffix = &after[end + 1..];
let mut out = Vec::new();
let tails = expand_interpolations(suffix, manifest);
for value in field_segment_values(manifest, field) {
for tail in &tails {
out.push(format!("{prefix}{value}{tail}"));
}
}
out
}
fn field_segment_values(manifest: &Value, field: &str) -> Vec<String> {
match dotted_lookup(manifest, field) {
Some(Value::String(s)) if !s.is_empty() => vec![s.clone()],
Some(Value::Number(n)) => vec![n.to_string()],
Some(Value::Array(items)) => items.iter().filter_map(scalar_segment).collect(),
_ => Vec::new(),
}
}
fn scalar_segment(value: &Value) -> Option<String> {
match value {
Value::String(s) if !s.is_empty() => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn when_matches(manifest: &Value, when: &BTreeMap<String, Value>) -> bool {
when.iter()
.all(|(path, expected)| dotted_lookup(manifest, path) == Some(expected))
}
fn dotted_lookup<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
let mut current = value;
for segment in path.split('.') {
current = current.get(segment)?;
}
Some(current)
}
fn discover_manifest_paths(root: &Path, matcher: &globset::GlobMatcher) -> Vec<PathBuf> {
let mut out = Vec::new();
let walker = ignore::WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.filter_entry(|entry| entry.file_name() != "node_modules")
.build();
for entry in walker.flatten() {
if entry.file_type().is_none_or(|ft| ft.is_dir()) {
continue;
}
let path = entry.path();
if let Some(rel) = root_relative_forward_slash(path, root)
&& matcher.is_match(Path::new(&rel))
{
out.push(path.to_path_buf());
}
}
out.sort();
out
}
fn root_relative_forward_slash(file: &Path, root: &Path) -> Option<String> {
let rel = file.strip_prefix(root).ok()?;
Some(rel.to_string_lossy().replace('\\', "/"))
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_config::{EntryPointRole, ManifestFormat, ManifestSeedRule};
fn json(text: &str) -> Value {
serde_json::from_str(text).unwrap()
}
fn seed(path: &str, when: &[(&str, Value)]) -> ManifestSeedRule {
ManifestSeedRule {
path: path.to_string(),
when: when
.iter()
.map(|(k, v)| ((*k).to_string(), v.clone()))
.collect(),
}
}
#[test]
fn dotted_lookup_traverses_nested_fields() {
let m = json(r#"{"plugin": {"browser": true, "id": "actions"}}"#);
assert_eq!(
dotted_lookup(&m, "plugin.browser"),
Some(&Value::Bool(true))
);
assert_eq!(
dotted_lookup(&m, "plugin.id"),
Some(&Value::String("actions".into()))
);
assert_eq!(dotted_lookup(&m, "plugin.missing"), None);
assert_eq!(dotted_lookup(&m, "absent.field"), None);
}
#[test]
fn when_matches_is_strict_equality_and_presence_is_not_matched() {
let m = json(r#"{"type": "plugin", "plugin": {"browser": false}}"#);
let mut when = BTreeMap::new();
when.insert("type".to_string(), Value::String("plugin".into()));
assert!(when_matches(&m, &when));
let mut when_browser = BTreeMap::new();
when_browser.insert("plugin.browser".to_string(), Value::Bool(true));
assert!(!when_matches(&m, &when_browser));
assert!(when_matches(&m, &BTreeMap::new()));
}
#[test]
fn expand_interpolations_string_array_and_missing() {
let m = json(r#"{"plugin": {"extraPublicDirs": ["common", "types"], "id": "actions"}}"#);
assert_eq!(
expand_interpolations("${plugin.id}/index.ts", &m),
vec!["actions/index.ts"]
);
assert_eq!(
expand_interpolations("${plugin.extraPublicDirs}/index.{ts,tsx}", &m),
vec!["common/index.{ts,tsx}", "types/index.{ts,tsx}"]
);
assert!(expand_interpolations("${plugin.absent}/index.ts", &m).is_empty());
assert_eq!(
expand_interpolations("public/index.{ts,tsx}", &m),
vec!["public/index.{ts,tsx}"]
);
}
#[test]
fn evaluate_seeds_relative_to_manifest_dir_with_when_and_fanout() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let manifest_dir = root.join("x-pack/plugins/actions");
std::fs::create_dir_all(&manifest_dir).unwrap();
let manifest_path = manifest_dir.join("kibana.jsonc");
std::fs::write(
&manifest_path,
r#"{
// a real Kibana-shaped manifest
"type": "plugin",
"plugin": { "browser": true, "server": false, "extraPublicDirs": ["common"] },
}"#,
)
.unwrap();
let ext = ExternalPluginDef {
schema: None,
name: "kibana".to_string(),
detection: None,
enablers: vec![],
entry_points: vec![],
entry_point_role: EntryPointRole::Runtime,
manifest_entries: vec![ManifestEntryRule {
manifests: "**/kibana.jsonc".to_string(),
format: ManifestFormat::Jsonc,
when: BTreeMap::from([("type".to_string(), Value::String("plugin".into()))]),
entries: vec![
seed(
"public/index.{ts,tsx}",
&[("plugin.browser", Value::Bool(true))],
),
seed(
"server/index.{ts,tsx}",
&[("plugin.server", Value::Bool(true))],
),
seed("${plugin.extraPublicDirs}/index.{ts,tsx}", &[]),
],
}],
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
};
let rules = evaluate_manifest_entries(&ext, root);
let paths: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect();
assert!(paths.contains(&"x-pack/plugins/actions/public/index.{ts,tsx}"));
assert!(paths.contains(&"x-pack/plugins/actions/common/index.{ts,tsx}"));
assert!(
!paths.iter().any(|p| p.contains("server/index")),
"server:false must not seed the server entry, got {paths:?}"
);
}
fn plugin_with(rules: Vec<ManifestEntryRule>) -> ExternalPluginDef {
ExternalPluginDef {
schema: None,
name: "kibana".to_string(),
detection: None,
enablers: vec![],
entry_points: vec![],
entry_point_role: EntryPointRole::Runtime,
manifest_entries: rules,
config_patterns: vec![],
always_used: vec![],
tooling_dependencies: vec![],
used_exports: vec![],
used_class_members: vec![],
}
}
fn rule(
manifests: &str,
when: &[(&str, Value)],
entries: Vec<ManifestSeedRule>,
) -> ManifestEntryRule {
ManifestEntryRule {
manifests: manifests.to_string(),
format: ManifestFormat::Jsonc,
when: when
.iter()
.map(|(k, v)| ((*k).to_string(), v.clone()))
.collect(),
entries,
}
}
fn write_manifest(root: &Path, rel: &str, body: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
fn kinds(reports: &[RuleReport]) -> Vec<WarningKind> {
reports
.iter()
.flat_map(|r| r.warnings.iter().map(|w| w.kind))
.collect()
}
#[test]
fn check_reports_matched_manifests_when_gate_and_seeded_entries() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(
root,
"plugins/alpha/kibana.jsonc",
r#"{"type":"plugin","plugin":{"browser":true,"server":true}}"#,
);
write_manifest(
root,
"plugins/beta/kibana.jsonc",
r#"{"type":"plugin","plugin":{"browser":true,"server":false}}"#,
);
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![
seed(
"public/index.{ts,tsx}",
&[("plugin.browser", Value::Bool(true))],
),
seed(
"server/index.{ts,tsx}",
&[("plugin.server", Value::Bool(true))],
),
],
)]);
let reports = check_manifest_entries(&ext, root);
assert_eq!(reports.len(), 1);
let report = &reports[0];
assert!(
report.warnings.is_empty(),
"clean plugin, got {:?}",
report.warnings
);
assert_eq!(
report.manifests_matched,
vec![
"plugins/alpha/kibana.jsonc".to_string(),
"plugins/beta/kibana.jsonc".to_string()
]
);
let beta = report
.matched
.iter()
.find(|m| m.path == "plugins/beta/kibana.jsonc")
.expect("beta matched");
assert!(beta.when_passed);
assert!(beta.seeded.iter().any(|s| s.contains("beta/public/index")));
assert!(
!beta.seeded.iter().any(|s| s.contains("server/index")),
"beta server:false must not seed the server entry, got {:?}",
beta.seeded
);
}
#[test]
fn check_warns_manifests_matched_none() {
let dir = tempfile::tempdir().unwrap();
let ext = plugin_with(vec![rule(
"**/nonexistent.jsonc",
&[],
vec![seed("public/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, dir.path());
assert!(kinds(&reports).contains(&WarningKind::ManifestsMatchedNone));
assert_eq!(
reports[0].warnings[0].glob.as_deref(),
Some("**/nonexistent.jsonc")
);
}
#[test]
fn check_warns_field_path_unresolved_on_typo() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(
root,
"plugins/alpha/kibana.jsonc",
r#"{"type":"plugin","plugin":{"browser":true}}"#,
);
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![seed("${plugin.extarPublicDirs}/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
let warn = reports[0]
.warnings
.iter()
.find(|w| w.kind == WarningKind::FieldPathUnresolved)
.expect("field-path-unresolved warning");
assert_eq!(warn.field_path.as_deref(), Some("plugin.extarPublicDirs"));
}
#[test]
fn check_warns_when_excluded_all() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"package"}"#);
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![seed("public/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
assert!(kinds(&reports).contains(&WarningKind::WhenExcludedAll));
}
#[test]
fn check_warns_manifest_parse_failed_per_file() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(root, "plugins/good/kibana.jsonc", r#"{"type":"plugin"}"#);
write_manifest(root, "plugins/bad/kibana.jsonc", "{ this is not valid json");
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![seed("public/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
let warn = reports[0]
.warnings
.iter()
.find(|w| w.kind == WarningKind::ManifestParseFailed)
.expect("manifest-parse-failed warning");
assert_eq!(warn.manifest.as_deref(), Some("plugins/bad/kibana.jsonc"));
}
#[test]
fn check_output_is_deterministic_across_walk_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for name in ["mmm", "aaa", "zzz", "ccc"] {
write_manifest(
root,
&format!("plugins/{name}/kibana.jsonc"),
r#"{"type":"plugin"}"#,
);
}
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![seed("../../../../escape/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
let r = &reports[0];
let mut sorted = r.manifests_matched.clone();
sorted.sort();
assert_eq!(
r.manifests_matched, sorted,
"manifests_matched must be sorted"
);
let warn_manifests: Vec<&str> = r
.warnings
.iter()
.filter_map(|w| w.manifest.as_deref())
.collect();
let mut sorted_w = warn_manifests.clone();
sorted_w.sort_unstable();
assert_eq!(
warn_manifests, sorted_w,
"entry-outside-root warnings must be sorted"
);
}
#[test]
fn check_warns_entry_outside_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"plugin"}"#);
let ext = plugin_with(vec![rule(
"**/kibana.jsonc",
&[("type", Value::String("plugin".into()))],
vec![seed("../../../../escape/index.ts", &[])],
)]);
let reports = check_manifest_entries(&ext, root);
let warn = reports[0]
.warnings
.iter()
.find(|w| w.kind == WarningKind::EntryOutsideRoot)
.expect("entry-outside-root warning");
assert!(warn.entry.as_deref().is_some_and(|e| e.contains("escape")));
assert_eq!(warn.manifest.as_deref(), Some("plugins/alpha/kibana.jsonc"));
}
}