Skip to main content

uni_plugin_extism/
exports.rs

1//! Plugin-export readers — `manifest` and `register`.
2//!
3//! Every Extism plugin exposes two canonical-JSON control-surface exports:
4//!
5//! - **`manifest`** — returns the plugin's [`ExtismPluginManifest`]
6//!   (id, version, declared capabilities, resource limits, …). Read once
7//!   at load time to drive the capability intersection.
8//! - **`register`** — returns a [`RegistrationManifest`] enumerating every
9//!   qname the plugin provides plus its wire-level signature. Read after
10//!   capability negotiation; one [`RegistrationEntry`] is converted to a
11//!   `ScalarPluginFn` / `AggregatePluginFn` / `ProcedurePlugin` adapter
12//!   downstream (M6a.1.5).
13//!
14//! This module splits parsing (pure, byte-slice in / value out) from the
15//! Extism-call wrapper (`read_*_export`). The split lets us unit-test JSON
16//! contracts without standing up a wasm plugin; the call-wrapper is
17//! exercised end-to-end by the M6a.1.7 example plugin.
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::ExtismError;
22use crate::loader::ExtismPluginManifest;
23
24/// Wire-level scalar / aggregate / procedure signature shipped by a
25/// plugin's `register` export.
26///
27/// String-based for wire stability — plugins shouldn't have to encode
28/// `arrow_schema::DataType` JSON. Translation to internal `FnSignature`
29/// / `AggSignature` / `ProcedureSignature` happens at adapter
30/// construction time (M6a.1.5 / M6a.2).
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(deny_unknown_fields)]
33pub struct WireFnSignature {
34    /// Argument types in `WireArgType` form.
35    pub args: Vec<WireArgType>,
36    /// Return type.
37    pub returns: WireArgType,
38    /// Volatility — `"immutable"`, `"stable"`, or `"volatile"`. Default
39    /// `"immutable"`.
40    #[serde(default = "default_volatility")]
41    pub volatility: String,
42    /// Null handling — `"propagate"` (default) or `"user_handled"`.
43    #[serde(default = "default_null_handling")]
44    pub null_handling: String,
45}
46
47fn default_volatility() -> String {
48    "immutable".to_owned()
49}
50
51fn default_null_handling() -> String {
52    "propagate".to_owned()
53}
54
55/// Wire-level argument type shipped by a plugin.
56///
57/// Each variant maps to the corresponding `uni_plugin::traits::scalar::ArgType`
58/// at adapter time. Primitive types use the lowercase Arrow names
59/// (`"int64"`, `"float64"`, `"utf8"`, `"boolean"`, `"date64"`,
60/// `"timestamp_ms"`, `"binary"`, `"largebinary"`).
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
63pub enum WireArgType {
64    /// A native Arrow primitive — `kind: "primitive", arrow: "<name>"`.
65    Primitive {
66        /// Arrow primitive name.
67        arrow: String,
68    },
69    /// A `CypherValue` shipped via `LargeBinary` opaque transport.
70    CypherValue,
71    /// A fixed-size vector — `kind: "vector", len: N, element: "<arrow>"`.
72    Vector {
73        /// Number of elements per row.
74        len: usize,
75        /// Element type.
76        element: String,
77    },
78    /// Variadic — repeats `inner` zero or more times.
79    Variadic {
80        /// Inner element type.
81        inner: Box<WireArgType>,
82    },
83}
84
85/// One registration entry — a single qname plus its kind + signature.
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
88pub enum RegistrationEntry {
89    /// A Cypher scalar function.
90    Scalar {
91        /// Fully-qualified name (`"ns.fn"`).
92        qname: String,
93        /// Signature.
94        signature: WireFnSignature,
95    },
96    /// A Cypher aggregate function. Wire shape mirrors Scalar with the
97    /// state type carried as a separate `WireArgType`.
98    Aggregate {
99        /// Fully-qualified name.
100        qname: String,
101        /// Per-row input + return types.
102        signature: WireFnSignature,
103        /// State type — opaque to the wire; Adapter side wraps as
104        /// Arrow Binary.
105        state: WireArgType,
106    },
107    /// A Cypher procedure.
108    Procedure {
109        /// Fully-qualified name.
110        qname: String,
111        /// Argument signatures.
112        args: Vec<WireArgType>,
113        /// Yielded column types, in declared order.
114        yields: Vec<WireArgType>,
115        /// Mode — `"read"`, `"write"`, `"schema"`, or `"dbms"`. Default `"read"`.
116        #[serde(default = "default_proc_mode")]
117        mode: String,
118    },
119    /// A GraphCompute algorithm. Its guest export drives the coarse kernels via
120    /// the `uni_graph_call` host fn and emits its per-vertex result (proposal
121    /// §4.6). `yields` become the `AlgorithmSignature::output_fields`.
122    Algorithm {
123        /// Fully-qualified name.
124        qname: String,
125        /// Argument signatures (excluding the injected session id).
126        args: Vec<WireArgType>,
127        /// Yielded columns as `"name:type"` strings (e.g. `"score:float"`), so
128        /// the emitted column and the special `nodeId` column bind by name.
129        yields: Vec<String>,
130    },
131}
132
133fn default_proc_mode() -> String {
134    "read".to_owned()
135}
136
137impl RegistrationEntry {
138    /// Fully-qualified name of this entry.
139    #[must_use]
140    pub fn qname(&self) -> &str {
141        match self {
142            Self::Scalar { qname, .. }
143            | Self::Aggregate { qname, .. }
144            | Self::Procedure { qname, .. }
145            | Self::Algorithm { qname, .. } => qname,
146        }
147    }
148}
149
150/// Top-level `register` export payload.
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(deny_unknown_fields)]
153pub struct RegistrationManifest {
154    /// One entry per qname provided by the plugin.
155    pub entries: Vec<RegistrationEntry>,
156}
157
158/// Parse the bytes returned by a plugin's `manifest` export into an
159/// [`ExtismPluginManifest`].
160///
161/// # Errors
162///
163/// - [`ExtismError::ManifestInvalid`] if the JSON doesn't parse or
164///   doesn't match the expected shape.
165pub fn parse_manifest_json(bytes: &[u8]) -> Result<ExtismPluginManifest, ExtismError> {
166    serde_json::from_slice(bytes)
167        .map_err(|e| ExtismError::ManifestInvalid(format!("json parse: {e}")))
168}
169
170/// Parse the bytes returned by a plugin's `register` export into a
171/// [`RegistrationManifest`].
172///
173/// # Errors
174///
175/// - [`ExtismError::OutputDecode`] if the JSON doesn't parse or doesn't
176///   match the expected shape.
177pub fn parse_registration_json(bytes: &[u8]) -> Result<RegistrationManifest, ExtismError> {
178    serde_json::from_slice(bytes)
179        .map_err(|e| ExtismError::OutputDecode(format!("register json parse: {e}")))
180}
181
182/// Call a live plugin's `manifest` export and parse the response.
183///
184/// The `manifest` export takes no input and returns canonical-JSON
185/// matching [`ExtismPluginManifest`]. The plugin produces this once and
186/// caches internally; the host reads it once at load and never again.
187///
188/// # Errors
189///
190/// - [`ExtismError::InvalidPlugin`] if the export doesn't exist or the
191///   underlying Extism call fails.
192/// - [`ExtismError::ManifestInvalid`] if the returned JSON is malformed.
193pub fn read_manifest_export(
194    plugin: &mut extism::Plugin,
195) -> Result<ExtismPluginManifest, ExtismError> {
196    let bytes = read_required_export(plugin, "manifest")?;
197    parse_manifest_json(&bytes)
198}
199
200/// Call a required no-input plugin export and return its raw output bytes.
201///
202/// Shared by [`read_manifest_export`] and [`read_register_export`]: both
203/// require the export to exist, both pass an empty input, and both surface
204/// a missing export or a failed call as [`ExtismError::InvalidPlugin`].
205///
206/// # Errors
207///
208/// - [`ExtismError::InvalidPlugin`] if the export is absent or the call fails.
209fn read_required_export(plugin: &mut extism::Plugin, export: &str) -> Result<Vec<u8>, ExtismError> {
210    if !plugin.function_exists(export) {
211        return Err(ExtismError::InvalidPlugin(format!(
212            "plugin does not export required `{export}` function"
213        )));
214    }
215    plugin
216        .call::<&str, &[u8]>(export, "")
217        .map(<[u8]>::to_vec)
218        .map_err(|e| ExtismError::InvalidPlugin(format!("call {export}: {e}")))
219}
220
221/// Call a live plugin's `register` export and parse the response.
222///
223/// The `register` export takes no input and returns canonical-JSON
224/// matching [`RegistrationManifest`]. The host reads this after
225/// capability negotiation and converts each entry into an adapter
226/// implementing the corresponding capability trait.
227///
228/// # Errors
229///
230/// - [`ExtismError::InvalidPlugin`] if the export doesn't exist or the
231///   underlying Extism call fails.
232/// - [`ExtismError::OutputDecode`] if the returned JSON is malformed.
233pub fn read_register_export(
234    plugin: &mut extism::Plugin,
235) -> Result<RegistrationManifest, ExtismError> {
236    let bytes = read_required_export(plugin, "register")?;
237    parse_registration_json(&bytes)
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn parses_minimal_manifest() {
246        let json = br#"{"id":"a.b","version":"0.0.1"}"#;
247        let m = parse_manifest_json(json).unwrap();
248        assert_eq!(m.id, "a.b");
249        assert_eq!(m.version, "0.0.1");
250        assert!(m.capabilities.is_empty());
251        assert!(m.fuel_per_call.is_none());
252    }
253
254    #[test]
255    fn parses_manifest_with_resource_limits() {
256        let json = br#"{
257            "id": "a.b",
258            "version": "0.0.1",
259            "capabilities": ["filesystem"],
260            "fuel_per_call": 1000,
261            "memory_max_pages": 4,
262            "timeout_ms": 500
263        }"#;
264        let m = parse_manifest_json(json).unwrap();
265        assert_eq!(m.fuel_per_call, Some(1000));
266        assert_eq!(m.memory_max_pages, Some(4));
267        assert_eq!(m.timeout_ms, Some(500));
268        assert!(m.declared_capability_set().contains_variant(
269            &uni_plugin::Capability::Filesystem {
270                read: vec![],
271                write: vec![],
272            }
273        ));
274    }
275
276    #[test]
277    fn rejects_unknown_manifest_field() {
278        let json = br#"{"id":"a.b","version":"0.0.1","mystery":"surprise"}"#;
279        let err = parse_manifest_json(json).unwrap_err();
280        assert!(matches!(err, ExtismError::ManifestInvalid(_)));
281    }
282
283    #[test]
284    fn parses_empty_registration() {
285        let json = br#"{"entries":[]}"#;
286        let r = parse_registration_json(json).unwrap();
287        assert!(r.entries.is_empty());
288    }
289
290    #[test]
291    fn parses_scalar_registration_entry() {
292        let json = br#"{
293            "entries": [{
294                "kind": "scalar",
295                "qname": "geo.haversine",
296                "signature": {
297                    "args": [
298                        {"kind":"primitive","arrow":"float64"},
299                        {"kind":"primitive","arrow":"float64"},
300                        {"kind":"primitive","arrow":"float64"},
301                        {"kind":"primitive","arrow":"float64"}
302                    ],
303                    "returns": {"kind":"primitive","arrow":"float64"}
304                }
305            }]
306        }"#;
307        let r = parse_registration_json(json).unwrap();
308        assert_eq!(r.entries.len(), 1);
309        match &r.entries[0] {
310            RegistrationEntry::Scalar { qname, signature } => {
311                assert_eq!(qname, "geo.haversine");
312                assert_eq!(signature.args.len(), 4);
313                assert_eq!(signature.volatility, "immutable");
314                assert_eq!(signature.null_handling, "propagate");
315                assert!(matches!(
316                    signature.returns,
317                    WireArgType::Primitive { ref arrow } if arrow == "float64"
318                ));
319            }
320            other => panic!("expected Scalar, got: {other:?}"),
321        }
322    }
323
324    #[test]
325    fn parses_aggregate_registration_entry() {
326        let json = br#"{
327            "entries": [{
328                "kind": "aggregate",
329                "qname": "stats.weighted_mean",
330                "signature": {
331                    "args": [
332                        {"kind":"primitive","arrow":"float64"},
333                        {"kind":"primitive","arrow":"float64"}
334                    ],
335                    "returns": {"kind":"primitive","arrow":"float64"},
336                    "volatility": "stable"
337                },
338                "state": {"kind":"primitive","arrow":"binary"}
339            }]
340        }"#;
341        let r = parse_registration_json(json).unwrap();
342        match &r.entries[0] {
343            RegistrationEntry::Aggregate {
344                qname,
345                signature,
346                state,
347            } => {
348                assert_eq!(qname, "stats.weighted_mean");
349                assert_eq!(signature.volatility, "stable");
350                assert!(matches!(state, WireArgType::Primitive { arrow } if arrow == "binary"));
351            }
352            other => panic!("expected Aggregate, got: {other:?}"),
353        }
354    }
355
356    #[test]
357    fn parses_procedure_registration_entry() {
358        let json = br#"{
359            "entries": [{
360                "kind": "procedure",
361                "qname": "myorg.scan",
362                "args": [{"kind":"primitive","arrow":"utf8"}],
363                "yields": [
364                    {"kind":"primitive","arrow":"int64"},
365                    {"kind":"cypher_value"}
366                ],
367                "mode": "write"
368            }]
369        }"#;
370        let r = parse_registration_json(json).unwrap();
371        match &r.entries[0] {
372            RegistrationEntry::Procedure {
373                qname,
374                args,
375                yields,
376                mode,
377            } => {
378                assert_eq!(qname, "myorg.scan");
379                assert_eq!(args.len(), 1);
380                assert_eq!(yields.len(), 2);
381                assert_eq!(mode, "write");
382                assert!(matches!(yields[1], WireArgType::CypherValue));
383            }
384            other => panic!("expected Procedure, got: {other:?}"),
385        }
386    }
387
388    #[test]
389    fn procedure_mode_defaults_to_read() {
390        let json = br#"{
391            "entries": [{
392                "kind": "procedure",
393                "qname": "myorg.scan",
394                "args": [],
395                "yields": []
396            }]
397        }"#;
398        let r = parse_registration_json(json).unwrap();
399        match &r.entries[0] {
400            RegistrationEntry::Procedure { mode, .. } => assert_eq!(mode, "read"),
401            _ => unreachable!(),
402        }
403    }
404
405    #[test]
406    fn registration_entry_exposes_qname() {
407        let e = RegistrationEntry::Scalar {
408            qname: "x.y".to_owned(),
409            signature: WireFnSignature {
410                args: vec![],
411                returns: WireArgType::CypherValue,
412                volatility: "immutable".to_owned(),
413                null_handling: "propagate".to_owned(),
414            },
415        };
416        assert_eq!(e.qname(), "x.y");
417    }
418
419    #[test]
420    fn rejects_unknown_registration_kind() {
421        let json = br#"{"entries":[{"kind":"telegraphic","qname":"x"}]}"#;
422        let err = parse_registration_json(json).unwrap_err();
423        assert!(matches!(err, ExtismError::OutputDecode(_)));
424    }
425
426    #[test]
427    fn parses_vector_and_variadic_argtypes() {
428        let json = br#"{
429            "entries": [{
430                "kind": "scalar",
431                "qname": "vec.norm",
432                "signature": {
433                    "args": [
434                        {"kind":"vector","len":128,"element":"float32"},
435                        {"kind":"variadic","inner":{"kind":"primitive","arrow":"int64"}}
436                    ],
437                    "returns": {"kind":"primitive","arrow":"float32"}
438                }
439            }]
440        }"#;
441        let r = parse_registration_json(json).unwrap();
442        match &r.entries[0] {
443            RegistrationEntry::Scalar { signature, .. } => {
444                assert!(matches!(
445                    signature.args[0],
446                    WireArgType::Vector { len: 128, ref element } if element == "float32"
447                ));
448                assert!(matches!(
449                    signature.args[1],
450                    WireArgType::Variadic { ref inner } if matches!(
451                        **inner,
452                        WireArgType::Primitive { ref arrow } if arrow == "int64"
453                    )
454                ));
455            }
456            _ => unreachable!(),
457        }
458    }
459}