1use serde::{Deserialize, Serialize};
8
9pub const PLUGIN_API_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Permission {
16 RepoRead,
17 GraphRead,
18 GraphContribute,
19 #[serde(rename = "evidence.contribute")]
20 EvidenceContribute,
21 StateRead,
22 StateWrite,
23 Network,
24 Subprocess,
25}
26
27impl Permission {
29pub fn as_str(&self) -> &'static str {
31 match self {
32 Permission::RepoRead => "repo.read",
33 Permission::GraphRead => "graph.read",
34 Permission::GraphContribute => "graph.contribute",
35 Permission::EvidenceContribute => "evidence.contribute",
36 Permission::StateRead => "state.read",
37 Permission::StateWrite => "state.write",
38 Permission::Network => "network",
39 Permission::Subprocess => "subprocess",
40 }
41 }
42
43 pub fn parse(s: &str) -> Result<Self, String> {
48 match s {
49 "repo.read" => Ok(Permission::RepoRead),
50 "graph.read" => Ok(Permission::GraphRead),
51 "graph.contribute" => Ok(Permission::GraphContribute),
52 "evidence.contribute" => Ok(Permission::EvidenceContribute),
53 "state.read" => Ok(Permission::StateRead),
54 "state.write" => Ok(Permission::StateWrite),
55 "network" => Ok(Permission::Network),
56 "subprocess" => Ok(Permission::Subprocess),
57 other => Err(format!(
58 "unknown permission '{other}' (known: repo.read, graph.read, graph.contribute, evidence.contribute, state.read, state.write, network, subprocess)"
59 )),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct PluginManifest {
67 pub id: String,
68 pub name: String,
69 pub version: String,
70 #[serde(default = "default_api")]
71 pub api: String,
72 #[serde(default)]
73 pub operations: Vec<String>,
74 #[serde(default)]
75 pub permissions: Vec<Permission>,
76 #[serde(default = "default_timeout")]
77 pub timeout_ms: u64,
78 #[serde(default = "default_policy")]
79 pub failure_policy: String,
80 #[serde(default = "default_true")]
81 pub deterministic: bool,
82 #[serde(skip)]
83 pub command: Vec<String>,
84 #[serde(default = "default_runtime")]
85 pub runtime: PluginRuntime,
86 #[serde(default)]
90 pub extensions: Vec<ExtensionRegistration>,
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94pub struct ExtensionRegistration {
96 #[serde(rename = "type")]
97 pub extension_type: String,
98 pub id: String,
99 #[serde(default)]
100 pub priority: i32,
101 #[serde(default)]
102 pub after: Vec<String>,
103 #[serde(default)]
104 pub before: Vec<String>,
105}
106
107impl ExtensionRegistration {
109 pub fn canonical_id(&self) -> String { format!("{}:{}", self.extension_type, self.id) }
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum PluginRuntime {
118 #[default]
121 Process,
122 Wasm,
126}
127
128fn default_runtime() -> PluginRuntime { PluginRuntime::Process }
130
131fn default_api() -> String { "1".into() }
133fn default_timeout() -> u64 { 5000 }
135fn default_policy() -> String { "warn".into() }
137fn default_true() -> bool { true }
139
140impl PluginManifest {
142pub fn from_toml(text: &str) -> Result<Self, String> {
144 let mut m = PluginManifest {
145 id: String::new(), name: String::new(), version: String::new(),
146 api: default_api(), operations: Vec::new(), permissions: Vec::new(),
147 timeout_ms: default_timeout(), failure_policy: default_policy(),
148 deterministic: true, command: Vec::new(), runtime: PluginRuntime::Process, extensions: Vec::new(),
149 };
150 let mut section = String::new();
151 for (ln, raw) in text.lines().enumerate() {
152 let line = raw.split('#').next().unwrap_or("").trim();
153 if line.is_empty() { continue; }
154 if line.starts_with('[') && line.ends_with(']') {
155 section = line[1..line.len()-1].trim().to_string();
156 continue;
157 }
158 let (k, v) = line.split_once('=').ok_or(format!("line {}: expected key = value", ln + 1))?;
159 let (k, v) = (k.trim(), v.trim());
160 let unq = |s: &str| s.trim_matches('"').trim_matches('\'').to_string();
161 let flag = v == "true";
162 match (section.as_str(), k) {
163 ("plugin", "id") => m.id = unq(v),
164 ("plugin", "name") => m.name = unq(v),
165 ("plugin", "version") => m.version = unq(v),
166 ("plugin", "api") => m.api = unq(v),
167 ("plugin", "operations") => m.operations = parse_str_array(v)?,
168 ("plugin", "timeout_ms") => m.timeout_ms = v.parse().unwrap_or(5000),
169 ("plugin", "failure_policy") => m.failure_policy = unq(v),
170 ("plugin", "deterministic") => m.deterministic = flag,
171 ("runtime", "command") => m.command = parse_str_array(v)?,
172 ("runtime", "type") => m.runtime = match unq(v).as_str() {
173 "wasm" => PluginRuntime::Wasm,
174 _ => PluginRuntime::Process,
175 },
176 ("extensions", k) => m.extensions.push(parse_extension(k, v)?),
177 ("permissions", key) => {
178 let perm = match key {
179 "repo_read" => Permission::RepoRead,
180 "graph_read" => Permission::GraphRead,
181 "graph_contribute" => Permission::GraphContribute,
182 "evidence_contribute" => Permission::EvidenceContribute,
183 "state_read" => Permission::StateRead,
184 "state_write" => Permission::StateWrite,
185 "network" => Permission::Network,
186 "subprocess" => Permission::Subprocess,
187 _ => continue,
188 };
189 if flag { m.permissions.push(perm); }
190 }
191 _ => {}
192 }
193 }
194 if m.id.is_empty() { return Err("missing plugin.id".into()); }
195 if m.command.is_empty() { return Err("missing runtime.command".into()); }
196 Ok(m)
197 }
198
199 pub fn check_api_compatible(&self) -> Result<(), String> {
202 let major = self.api.split('.').next().unwrap_or("");
203 if major == "1" || self.api == "1" { Ok(()) } else {
204 Err(format!("plugin '{}' requires plugin API {}, host provides {}", self.id, self.api, PLUGIN_API_VERSION))
205 }
206 }
207}
208
209fn parse_str_array(s: &str) -> Result<Vec<String>, String> {
211 let s = s.trim();
212 if !s.starts_with('[') || !s.ends_with(']') { return Err(format!("expected string array, got {s:?}")); }
213 let mut out = Vec::new();
214 for part in s[1..s.len()-1].split(',') {
215 let part = part.trim();
216 if part.is_empty() { continue; }
217 out.push(part.trim_matches('"').trim_matches('\'').to_string());
218 }
219 Ok(out)
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct PluginRequest {
225 pub operation: String,
226 pub input: serde_json::Value,
227 pub config: serde_json::Value,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct PluginResponse {
233 #[serde(default)]
234 pub output: serde_json::Value,
235 #[serde(default)]
236 pub error: Option<String>,
237}
238
239fn parse_extension(key: &str, value: &str) -> Result<ExtensionRegistration, String> {
241 let key = key.trim_matches('"').trim_matches('\'');
244 let (extension_type, id) = key.split_once(':').ok_or(format!("bad extension key {key:?} (want \"type:id\")"))?;
245 let mut reg = ExtensionRegistration {
246 extension_type: extension_type.trim().into(),
247 id: id.trim().into(),
248 ..Default::default()
249 };
250 let body = value.trim().trim_matches(|c| c == '{' || c == '}');
251 for part in body.split(',') {
252 let part = part.trim();
253 if part.is_empty() { continue; }
254 let (k, v) = part.split_once('=').ok_or(format!("bad extension field {part:?}"))?;
255 match k.trim() {
256 "priority" => reg.priority = v.trim().parse().unwrap_or(0),
257 "after" => reg.after = parse_str_array(v)?,
258 "before" => reg.before = parse_str_array(v)?,
259 _ => {}
260 }
261 }
262 if reg.extension_type.is_empty() || reg.id.is_empty() {
263 return Err(format!("bad extension key {key:?}"));
264 }
265 Ok(reg)
266}
267
268pub const PLUGIN_WIT: &str = include_str!("plugin.wit");
276
277#[cfg(test)]
278mod tests {
279 #[test]
280fn manifest_parses() {
282 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();
283 assert_eq!(m.id, "acme.demo");
284 assert_eq!(m.command, vec!["python3", "p.py"]);
285 assert!(m.check_api_compatible().is_ok());
286 }
287
288 #[test]
289fn incompatible_api_rejected() {
291 let m = super::PluginManifest::from_toml("[plugin]\nid = \"x\"\napi = \"2\"\n[runtime]\ncommand = [\"a\"]\n").unwrap();
292 assert!(m.check_api_compatible().is_err());
293 }
294
295 #[test]
296fn extensions_parse_with_ordering() {
298 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();
299 assert_eq!(m.extensions.len(), 1);
300 let e = &m.extensions[0];
301 assert_eq!(e.canonical_id(), "rank-feature:acme.risk");
302 assert_eq!(e.priority, 10);
303 assert_eq!(e.after, vec!["core.lex"]);
304 }
305
306 #[test]
307fn wit_contract_declares_plugin_world() {
309 assert!(super::PLUGIN_WIT.contains("world scc-plugin"));
312 assert!(super::PLUGIN_WIT.contains("invoke-hook"));
313 assert!(super::PLUGIN_WIT.contains("manifest: func()"));
314 assert!(super::PLUGIN_WIT.contains("register: func()"));
315 }
316}
317