use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginDeclaration {
pub name: String,
pub kind: String,
#[serde(default)]
pub hooks: Vec<String>,
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default)]
pub config: Option<serde_yaml::Value>,
#[serde(default)]
pub on_error: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginOverride {
#[serde(default)]
pub config: Option<serde_yaml::Value>,
#[serde(default)]
pub capabilities: Option<Vec<String>>,
#[serde(default)]
pub on_error: Option<String>,
}
pub type PluginRegistry = HashMap<String, PluginDeclaration>;
#[derive(Debug, Clone)]
pub struct EffectivePlugin<'a> {
pub name: &'a str,
pub kind: &'a str,
pub hooks: &'a [String],
pub capabilities: CapsView<'a>,
pub config: Option<&'a serde_yaml::Value>,
pub on_error: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub enum CapsView<'a> {
Global(&'a [String]),
Override(&'a [String]),
}
impl<'a> CapsView<'a> {
pub fn as_slice(&self) -> &'a [String] {
match self {
Self::Global(s) | Self::Override(s) => s,
}
}
}
impl<'a> EffectivePlugin<'a> {
pub fn resolve(
name: &str,
registry: &'a PluginRegistry,
route_overrides: &'a HashMap<String, PluginOverride>,
) -> Option<Self> {
let global = registry.get(name)?;
let ovr = route_overrides.get(name);
let capabilities = match ovr.and_then(|o| o.capabilities.as_deref()) {
Some(c) => CapsView::Override(c),
None => CapsView::Global(global.capabilities.as_slice()),
};
let config = ovr
.and_then(|o| o.config.as_ref())
.or(global.config.as_ref());
let on_error = ovr
.and_then(|o| o.on_error.as_deref())
.or(global.on_error.as_deref());
Some(Self {
name: global.name.as_str(),
kind: global.kind.as_str(),
hooks: global.hooks.as_slice(),
capabilities,
config,
on_error,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn yaml(s: &str) -> serde_yaml::Value {
serde_yaml::from_str(s).unwrap()
}
fn registry_with(decl: PluginDeclaration) -> PluginRegistry {
let mut r = PluginRegistry::new();
r.insert(decl.name.clone(), decl);
r
}
#[test]
fn resolve_with_no_override_returns_global_values() {
let registry = registry_with(PluginDeclaration {
name: "rate_limiter".into(),
kind: "native".into(),
hooks: vec!["tool_pre_invoke".into()],
capabilities: vec!["read_subject".into()],
config: Some(yaml("max_requests: 100")),
on_error: Some("fail".into()),
extra: HashMap::new(),
});
let overrides = HashMap::new();
let eff = EffectivePlugin::resolve("rate_limiter", ®istry, &overrides).unwrap();
assert_eq!(eff.name, "rate_limiter");
assert_eq!(eff.kind, "native");
assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]);
assert_eq!(eff.capabilities.as_slice(), &["read_subject".to_string()]);
assert_eq!(eff.on_error, Some("fail"));
assert!(matches!(eff.capabilities, CapsView::Global(_)));
}
#[test]
fn resolve_with_override_replaces_config_and_capabilities_and_on_error() {
let registry = registry_with(PluginDeclaration {
name: "rate_limiter".into(),
kind: "native".into(),
hooks: vec!["tool_pre_invoke".into()],
capabilities: vec!["read_subject".into()],
config: Some(yaml("max_requests: 100")),
on_error: Some("fail".into()),
extra: HashMap::new(),
});
let mut overrides = HashMap::new();
overrides.insert(
"rate_limiter".to_string(),
PluginOverride {
config: Some(yaml("max_requests: 10")),
capabilities: Some(vec!["read_subject".into(), "read_labels".into()]),
on_error: Some("ignore".into()),
},
);
let eff = EffectivePlugin::resolve("rate_limiter", ®istry, &overrides).unwrap();
assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]);
assert_eq!(
eff.capabilities.as_slice(),
&["read_subject".to_string(), "read_labels".to_string()]
);
assert!(matches!(eff.capabilities, CapsView::Override(_)));
assert_eq!(eff.on_error, Some("ignore"));
let cfg = eff.config.expect("config present");
assert_eq!(cfg["max_requests"], yaml("10"));
}
#[test]
fn resolve_with_partial_override_only_replaces_present_keys() {
let registry = registry_with(PluginDeclaration {
name: "audit".into(),
kind: "native".into(),
hooks: vec!["tool_post_invoke".into()],
capabilities: vec!["read_labels".into()],
config: Some(yaml("log_level: info")),
on_error: Some("ignore".into()),
extra: HashMap::new(),
});
let mut overrides = HashMap::new();
overrides.insert(
"audit".to_string(),
PluginOverride {
config: None,
capabilities: None,
on_error: Some("fail".into()),
},
);
let eff = EffectivePlugin::resolve("audit", ®istry, &overrides).unwrap();
assert_eq!(eff.on_error, Some("fail")); assert_eq!(eff.capabilities.as_slice(), &["read_labels".to_string()]); let cfg = eff.config.expect("config inherited");
assert_eq!(cfg["log_level"], yaml("info")); }
#[test]
fn resolve_returns_none_for_unknown_plugin() {
let registry = PluginRegistry::new();
let overrides = HashMap::new();
assert!(EffectivePlugin::resolve("missing", ®istry, &overrides).is_none());
}
}