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