Skip to main content

scc_plugin_api/
lib.rs

1//! Stable SCC plugin contracts (spec sections 15-16, 21, 31).
2//!
3//! Plugins depend on THIS crate only — never on `scc-engine` or `scc-cli`.
4//! Manifests are `scc-plugin.toml`; the process-plugin wire protocol is
5//! plain JSON over stdio using [`PluginRequest`]/[`PluginResponse`].
6
7use serde::{Deserialize, Serialize};
8
9// trace:exempt reason=internal-detail
10pub const PLUGIN_API_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14// trace:exempt reason=internal-detail
15pub enum Permission {
16    RepoRead,
17    GraphRead,
18    GraphContribute,
19    StateRead,
20    StateWrite,
21    Network,
22    Subprocess,
23}
24
25// trace:exempt reason=internal-detail
26impl Permission {
27// trace:exempt reason=internal-detail
28    pub 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    /// Parse a project-config grant name. Unknown names are an `Err` —
41    /// callers must surface it (diagnostic or hard error), never silently
42    /// narrow the grant set (a typo'd permission must not read as "deny").
43    // trace:exempt reason=internal-detail
44    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)]
61// trace:v1 id=impl.crates-scc-plugin-api-src-lib.plugin-manifest work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
62pub 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    /// Declared extension registrations (spec §17): (extension-type, id,
83    /// priority, after, before). Process plugins declare these in
84    /// `[extensions] "rank-feature:acme.id" = {priority=10, after=[...]}`.
85    #[serde(default)]
86    pub extensions: Vec<ExtensionRegistration>,
87}
88
89#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
90// trace:exempt reason=internal-detail
91pub 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
103// trace:exempt reason=internal-detail
104impl ExtensionRegistration {
105    /// Canonical id `type:id` (e.g. `rank-feature:acme.risk`).
106// trace:exempt reason=internal-detail
107    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")]
112// trace:exempt reason=internal-detail
113pub enum PluginRuntime {
114    /// External process over stdio (spec §15). The only supported runtime
115    /// today; `command` carries the argv.
116    #[default]
117    Process,
118    /// WASM Component Model (spec §14): declared via the checked-in WIT.
119    /// Loading is a loud `UnsupportedRuntime` diagnostic until the wasmtime
120    /// host lands — never silent misloading.
121    Wasm,
122}
123
124// trace:exempt reason=internal-detail
125fn default_runtime() -> PluginRuntime { PluginRuntime::Process }
126
127// trace:exempt reason=internal-detail
128fn default_api() -> String { "1".into() }
129// trace:exempt reason=internal-detail
130fn default_timeout() -> u64 { 5000 }
131// trace:exempt reason=internal-detail
132fn default_policy() -> String { "warn".into() }
133// trace:exempt reason=internal-detail
134fn default_true() -> bool { true }
135
136// trace:exempt reason=internal-detail
137impl PluginManifest {
138// trace:exempt reason=internal-detail
139    pub 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    /// Compatibility check (spec 41): never "try it and see whether it crashes".
195// trace:exempt reason=internal-detail
196    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
204// trace:exempt reason=internal-detail
205fn 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)]
218// trace:exempt reason=internal-detail
219pub 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)]
226// trace:exempt reason=internal-detail
227pub struct PluginResponse {
228    #[serde(default)]
229    pub output: serde_json::Value,
230    #[serde(default)]
231    pub error: Option<String>,
232}
233
234// trace:v1 id=impl.crates-scc-plugin-api-src-lib.plugin-manifest-extension work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
235fn parse_extension(key: &str, value: &str) -> Result<ExtensionRegistration, String> {
236    // Key: `"rank-feature:acme.id"` (quotes stripped). Value: inline table
237    // `{priority=10, after=[...], before=[...]}`; bare values default.
238    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
263/// WIT interface definition for the WASM Component Model host (spec §14).
264/// Checked in as the versioned ABI contract: any runtime implementing this
265/// world (wasmtime-based or otherwise) hosts `scc-plugin.wit` plugins.
266/// The JSON shapes (`PluginRequest`/`PluginResponse`) are identical to the
267/// process-plugin wire protocol, so a plugin written against these schemas
268/// runs on either runtime unchanged.
269// trace:exempt reason=internal-detail
270pub const PLUGIN_WIT: &str = include_str!("plugin.wit");
271
272#[cfg(test)]
273mod tests {
274    #[test]
275// trace:exempt reason=unit-test
276    fn 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]
284// trace:exempt reason=unit-test
285    fn 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]
291// trace:exempt reason=unit-test
292    fn 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]
302// trace:exempt reason=unit-test
303    fn wit_contract_declares_plugin_world() {
304        // The checked-in WIT is the versioned ABI: world, host imports,
305        // and the three plugin exports must all be present.
306        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