use serde::{Deserialize, Serialize};
pub const PLUGIN_API_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Permission {
RepoRead,
GraphRead,
GraphContribute,
StateRead,
StateWrite,
Network,
Subprocess,
}
impl Permission {
pub fn as_str(&self) -> &'static str {
match self {
Permission::RepoRead => "repo.read",
Permission::GraphRead => "graph.read",
Permission::GraphContribute => "graph.contribute",
Permission::StateRead => "state.read",
Permission::StateWrite => "state.write",
Permission::Network => "network",
Permission::Subprocess => "subprocess",
}
}
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"repo.read" => Ok(Permission::RepoRead),
"graph.read" => Ok(Permission::GraphRead),
"graph.contribute" => Ok(Permission::GraphContribute),
"state.read" => Ok(Permission::StateRead),
"state.write" => Ok(Permission::StateWrite),
"network" => Ok(Permission::Network),
"subprocess" => Ok(Permission::Subprocess),
other => Err(format!(
"unknown permission '{other}' (known: repo.read, graph.read, graph.contribute, state.read, state.write, network, subprocess)"
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub id: String,
pub name: String,
pub version: String,
#[serde(default = "default_api")]
pub api: String,
#[serde(default)]
pub operations: Vec<String>,
#[serde(default)]
pub permissions: Vec<Permission>,
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
#[serde(default = "default_policy")]
pub failure_policy: String,
#[serde(default = "default_true")]
pub deterministic: bool,
#[serde(skip)]
pub command: Vec<String>,
#[serde(default = "default_runtime")]
pub runtime: PluginRuntime,
#[serde(default)]
pub extensions: Vec<ExtensionRegistration>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExtensionRegistration {
#[serde(rename = "type")]
pub extension_type: String,
pub id: String,
#[serde(default)]
pub priority: i32,
#[serde(default)]
pub after: Vec<String>,
#[serde(default)]
pub before: Vec<String>,
}
impl ExtensionRegistration {
pub fn canonical_id(&self) -> String { format!("{}:{}", self.extension_type, self.id) }
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginRuntime {
#[default]
Process,
Wasm,
}
fn default_runtime() -> PluginRuntime { PluginRuntime::Process }
fn default_api() -> String { "1".into() }
fn default_timeout() -> u64 { 5000 }
fn default_policy() -> String { "warn".into() }
fn default_true() -> bool { true }
impl PluginManifest {
pub fn from_toml(text: &str) -> Result<Self, String> {
let mut m = PluginManifest {
id: String::new(), name: String::new(), version: String::new(),
api: default_api(), operations: Vec::new(), permissions: Vec::new(),
timeout_ms: default_timeout(), failure_policy: default_policy(),
deterministic: true, command: Vec::new(), runtime: PluginRuntime::Process, extensions: Vec::new(),
};
let mut section = String::new();
for (ln, raw) in text.lines().enumerate() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() { continue; }
if line.starts_with('[') && line.ends_with(']') {
section = line[1..line.len()-1].trim().to_string();
continue;
}
let (k, v) = line.split_once('=').ok_or(format!("line {}: expected key = value", ln + 1))?;
let (k, v) = (k.trim(), v.trim());
let unq = |s: &str| s.trim_matches('"').trim_matches('\'').to_string();
let flag = v == "true";
match (section.as_str(), k) {
("plugin", "id") => m.id = unq(v),
("plugin", "name") => m.name = unq(v),
("plugin", "version") => m.version = unq(v),
("plugin", "api") => m.api = unq(v),
("plugin", "operations") => m.operations = parse_str_array(v)?,
("plugin", "timeout_ms") => m.timeout_ms = v.parse().unwrap_or(5000),
("plugin", "failure_policy") => m.failure_policy = unq(v),
("plugin", "deterministic") => m.deterministic = flag,
("runtime", "command") => m.command = parse_str_array(v)?,
("runtime", "type") => m.runtime = match unq(v).as_str() {
"wasm" => PluginRuntime::Wasm,
_ => PluginRuntime::Process,
},
("extensions", k) => m.extensions.push(parse_extension(k, v)?),
("permissions", key) => {
let perm = match key {
"repo_read" => Permission::RepoRead,
"graph_read" => Permission::GraphRead,
"graph_contribute" => Permission::GraphContribute,
"state_read" => Permission::StateRead,
"state_write" => Permission::StateWrite,
"network" => Permission::Network,
"subprocess" => Permission::Subprocess,
_ => continue,
};
if flag { m.permissions.push(perm); }
}
_ => {}
}
}
if m.id.is_empty() { return Err("missing plugin.id".into()); }
if m.command.is_empty() { return Err("missing runtime.command".into()); }
Ok(m)
}
pub fn check_api_compatible(&self) -> Result<(), String> {
let major = self.api.split('.').next().unwrap_or("");
if major == "1" || self.api == "1" { Ok(()) } else {
Err(format!("plugin '{}' requires plugin API {}, host provides {}", self.id, self.api, PLUGIN_API_VERSION))
}
}
}
fn parse_str_array(s: &str) -> Result<Vec<String>, String> {
let s = s.trim();
if !s.starts_with('[') || !s.ends_with(']') { return Err(format!("expected string array, got {s:?}")); }
let mut out = Vec::new();
for part in s[1..s.len()-1].split(',') {
let part = part.trim();
if part.is_empty() { continue; }
out.push(part.trim_matches('"').trim_matches('\'').to_string());
}
Ok(out)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRequest {
pub operation: String,
pub input: serde_json::Value,
pub config: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginResponse {
#[serde(default)]
pub output: serde_json::Value,
#[serde(default)]
pub error: Option<String>,
}
fn parse_extension(key: &str, value: &str) -> Result<ExtensionRegistration, String> {
let key = key.trim_matches('"').trim_matches('\'');
let (extension_type, id) = key.split_once(':').ok_or(format!("bad extension key {key:?} (want \"type:id\")"))?;
let mut reg = ExtensionRegistration {
extension_type: extension_type.trim().into(),
id: id.trim().into(),
..Default::default()
};
let body = value.trim().trim_matches(|c| c == '{' || c == '}');
for part in body.split(',') {
let part = part.trim();
if part.is_empty() { continue; }
let (k, v) = part.split_once('=').ok_or(format!("bad extension field {part:?}"))?;
match k.trim() {
"priority" => reg.priority = v.trim().parse().unwrap_or(0),
"after" => reg.after = parse_str_array(v)?,
"before" => reg.before = parse_str_array(v)?,
_ => {}
}
}
if reg.extension_type.is_empty() || reg.id.is_empty() {
return Err(format!("bad extension key {key:?}"));
}
Ok(reg)
}
pub const PLUGIN_WIT: &str = include_str!("plugin.wit");
#[cfg(test)]
mod tests {
#[test]
fn manifest_parses() {
let m = super::PluginManifest::from_toml("[plugin]\nid = \"acme.demo\"\noperations = [\"acme.echo\"]\n[runtime]\ncommand = [\"python3\", \"p.py\"]\n[permissions]\nrepo_read = true\n").unwrap();
assert_eq!(m.id, "acme.demo");
assert_eq!(m.command, vec!["python3", "p.py"]);
assert!(m.check_api_compatible().is_ok());
}
#[test]
fn incompatible_api_rejected() {
let m = super::PluginManifest::from_toml("[plugin]\nid = \"x\"\napi = \"2\"\n[runtime]\ncommand = [\"a\"]\n").unwrap();
assert!(m.check_api_compatible().is_err());
}
#[test]
fn extensions_parse_with_ordering() {
let m = super::PluginManifest::from_toml("[plugin]\nid = \"x\"\n[runtime]\ncommand = [\"a\"]\n[extensions]\n\"rank-feature:acme.risk\" = {priority=10, after=[\"core.lex\"], before=[]}\n").unwrap();
assert_eq!(m.extensions.len(), 1);
let e = &m.extensions[0];
assert_eq!(e.canonical_id(), "rank-feature:acme.risk");
assert_eq!(e.priority, 10);
assert_eq!(e.after, vec!["core.lex"]);
}
#[test]
fn wit_contract_declares_plugin_world() {
assert!(super::PLUGIN_WIT.contains("world scc-plugin"));
assert!(super::PLUGIN_WIT.contains("invoke-hook"));
assert!(super::PLUGIN_WIT.contains("manifest: func()"));
assert!(super::PLUGIN_WIT.contains("register: func()"));
}
}