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}
120
121fn default_proc_mode() -> String {
122    "read".to_owned()
123}
124
125impl RegistrationEntry {
126    /// Fully-qualified name of this entry.
127    #[must_use]
128    pub fn qname(&self) -> &str {
129        match self {
130            Self::Scalar { qname, .. }
131            | Self::Aggregate { qname, .. }
132            | Self::Procedure { qname, .. } => qname,
133        }
134    }
135}
136
137/// Top-level `register` export payload.
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(deny_unknown_fields)]
140pub struct RegistrationManifest {
141    /// One entry per qname provided by the plugin.
142    pub entries: Vec<RegistrationEntry>,
143}
144
145/// Parse the bytes returned by a plugin's `manifest` export into an
146/// [`ExtismPluginManifest`].
147///
148/// # Errors
149///
150/// - [`ExtismError::ManifestInvalid`] if the JSON doesn't parse or
151///   doesn't match the expected shape.
152pub fn parse_manifest_json(bytes: &[u8]) -> Result<ExtismPluginManifest, ExtismError> {
153    serde_json::from_slice(bytes)
154        .map_err(|e| ExtismError::ManifestInvalid(format!("json parse: {e}")))
155}
156
157/// Parse the bytes returned by a plugin's `register` export into a
158/// [`RegistrationManifest`].
159///
160/// # Errors
161///
162/// - [`ExtismError::OutputDecode`] if the JSON doesn't parse or doesn't
163///   match the expected shape.
164pub fn parse_registration_json(bytes: &[u8]) -> Result<RegistrationManifest, ExtismError> {
165    serde_json::from_slice(bytes)
166        .map_err(|e| ExtismError::OutputDecode(format!("register json parse: {e}")))
167}
168
169/// Call a live plugin's `manifest` export and parse the response.
170///
171/// The `manifest` export takes no input and returns canonical-JSON
172/// matching [`ExtismPluginManifest`]. The plugin produces this once and
173/// caches internally; the host reads it once at load and never again.
174///
175/// # Errors
176///
177/// - [`ExtismError::InvalidPlugin`] if the export doesn't exist or the
178///   underlying Extism call fails.
179/// - [`ExtismError::ManifestInvalid`] if the returned JSON is malformed.
180pub fn read_manifest_export(
181    plugin: &mut extism::Plugin,
182) -> Result<ExtismPluginManifest, ExtismError> {
183    let bytes = read_required_export(plugin, "manifest")?;
184    parse_manifest_json(&bytes)
185}
186
187/// Call a required no-input plugin export and return its raw output bytes.
188///
189/// Shared by [`read_manifest_export`] and [`read_register_export`]: both
190/// require the export to exist, both pass an empty input, and both surface
191/// a missing export or a failed call as [`ExtismError::InvalidPlugin`].
192///
193/// # Errors
194///
195/// - [`ExtismError::InvalidPlugin`] if the export is absent or the call fails.
196fn read_required_export(plugin: &mut extism::Plugin, export: &str) -> Result<Vec<u8>, ExtismError> {
197    if !plugin.function_exists(export) {
198        return Err(ExtismError::InvalidPlugin(format!(
199            "plugin does not export required `{export}` function"
200        )));
201    }
202    plugin
203        .call::<&str, &[u8]>(export, "")
204        .map(<[u8]>::to_vec)
205        .map_err(|e| ExtismError::InvalidPlugin(format!("call {export}: {e}")))
206}
207
208/// Call a live plugin's `register` export and parse the response.
209///
210/// The `register` export takes no input and returns canonical-JSON
211/// matching [`RegistrationManifest`]. The host reads this after
212/// capability negotiation and converts each entry into an adapter
213/// implementing the corresponding capability trait.
214///
215/// # Errors
216///
217/// - [`ExtismError::InvalidPlugin`] if the export doesn't exist or the
218///   underlying Extism call fails.
219/// - [`ExtismError::OutputDecode`] if the returned JSON is malformed.
220pub fn read_register_export(
221    plugin: &mut extism::Plugin,
222) -> Result<RegistrationManifest, ExtismError> {
223    let bytes = read_required_export(plugin, "register")?;
224    parse_registration_json(&bytes)
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn parses_minimal_manifest() {
233        let json = br#"{"id":"a.b","version":"0.0.1"}"#;
234        let m = parse_manifest_json(json).unwrap();
235        assert_eq!(m.id, "a.b");
236        assert_eq!(m.version, "0.0.1");
237        assert!(m.capabilities.is_empty());
238        assert!(m.fuel_per_call.is_none());
239    }
240
241    #[test]
242    fn parses_manifest_with_resource_limits() {
243        let json = br#"{
244            "id": "a.b",
245            "version": "0.0.1",
246            "capabilities": ["filesystem"],
247            "fuel_per_call": 1000,
248            "memory_max_pages": 4,
249            "timeout_ms": 500
250        }"#;
251        let m = parse_manifest_json(json).unwrap();
252        assert_eq!(m.fuel_per_call, Some(1000));
253        assert_eq!(m.memory_max_pages, Some(4));
254        assert_eq!(m.timeout_ms, Some(500));
255        assert!(m.declared_capability_set().contains_variant(
256            &uni_plugin::Capability::Filesystem {
257                read: vec![],
258                write: vec![],
259            }
260        ));
261    }
262
263    #[test]
264    fn rejects_unknown_manifest_field() {
265        let json = br#"{"id":"a.b","version":"0.0.1","mystery":"surprise"}"#;
266        let err = parse_manifest_json(json).unwrap_err();
267        assert!(matches!(err, ExtismError::ManifestInvalid(_)));
268    }
269
270    #[test]
271    fn parses_empty_registration() {
272        let json = br#"{"entries":[]}"#;
273        let r = parse_registration_json(json).unwrap();
274        assert!(r.entries.is_empty());
275    }
276
277    #[test]
278    fn parses_scalar_registration_entry() {
279        let json = br#"{
280            "entries": [{
281                "kind": "scalar",
282                "qname": "geo.haversine",
283                "signature": {
284                    "args": [
285                        {"kind":"primitive","arrow":"float64"},
286                        {"kind":"primitive","arrow":"float64"},
287                        {"kind":"primitive","arrow":"float64"},
288                        {"kind":"primitive","arrow":"float64"}
289                    ],
290                    "returns": {"kind":"primitive","arrow":"float64"}
291                }
292            }]
293        }"#;
294        let r = parse_registration_json(json).unwrap();
295        assert_eq!(r.entries.len(), 1);
296        match &r.entries[0] {
297            RegistrationEntry::Scalar { qname, signature } => {
298                assert_eq!(qname, "geo.haversine");
299                assert_eq!(signature.args.len(), 4);
300                assert_eq!(signature.volatility, "immutable");
301                assert_eq!(signature.null_handling, "propagate");
302                assert!(matches!(
303                    signature.returns,
304                    WireArgType::Primitive { ref arrow } if arrow == "float64"
305                ));
306            }
307            other => panic!("expected Scalar, got: {other:?}"),
308        }
309    }
310
311    #[test]
312    fn parses_aggregate_registration_entry() {
313        let json = br#"{
314            "entries": [{
315                "kind": "aggregate",
316                "qname": "stats.weighted_mean",
317                "signature": {
318                    "args": [
319                        {"kind":"primitive","arrow":"float64"},
320                        {"kind":"primitive","arrow":"float64"}
321                    ],
322                    "returns": {"kind":"primitive","arrow":"float64"},
323                    "volatility": "stable"
324                },
325                "state": {"kind":"primitive","arrow":"binary"}
326            }]
327        }"#;
328        let r = parse_registration_json(json).unwrap();
329        match &r.entries[0] {
330            RegistrationEntry::Aggregate {
331                qname,
332                signature,
333                state,
334            } => {
335                assert_eq!(qname, "stats.weighted_mean");
336                assert_eq!(signature.volatility, "stable");
337                assert!(matches!(state, WireArgType::Primitive { arrow } if arrow == "binary"));
338            }
339            other => panic!("expected Aggregate, got: {other:?}"),
340        }
341    }
342
343    #[test]
344    fn parses_procedure_registration_entry() {
345        let json = br#"{
346            "entries": [{
347                "kind": "procedure",
348                "qname": "myorg.scan",
349                "args": [{"kind":"primitive","arrow":"utf8"}],
350                "yields": [
351                    {"kind":"primitive","arrow":"int64"},
352                    {"kind":"cypher_value"}
353                ],
354                "mode": "write"
355            }]
356        }"#;
357        let r = parse_registration_json(json).unwrap();
358        match &r.entries[0] {
359            RegistrationEntry::Procedure {
360                qname,
361                args,
362                yields,
363                mode,
364            } => {
365                assert_eq!(qname, "myorg.scan");
366                assert_eq!(args.len(), 1);
367                assert_eq!(yields.len(), 2);
368                assert_eq!(mode, "write");
369                assert!(matches!(yields[1], WireArgType::CypherValue));
370            }
371            other => panic!("expected Procedure, got: {other:?}"),
372        }
373    }
374
375    #[test]
376    fn procedure_mode_defaults_to_read() {
377        let json = br#"{
378            "entries": [{
379                "kind": "procedure",
380                "qname": "myorg.scan",
381                "args": [],
382                "yields": []
383            }]
384        }"#;
385        let r = parse_registration_json(json).unwrap();
386        match &r.entries[0] {
387            RegistrationEntry::Procedure { mode, .. } => assert_eq!(mode, "read"),
388            _ => unreachable!(),
389        }
390    }
391
392    #[test]
393    fn registration_entry_exposes_qname() {
394        let e = RegistrationEntry::Scalar {
395            qname: "x.y".to_owned(),
396            signature: WireFnSignature {
397                args: vec![],
398                returns: WireArgType::CypherValue,
399                volatility: "immutable".to_owned(),
400                null_handling: "propagate".to_owned(),
401            },
402        };
403        assert_eq!(e.qname(), "x.y");
404    }
405
406    #[test]
407    fn rejects_unknown_registration_kind() {
408        let json = br#"{"entries":[{"kind":"telegraphic","qname":"x"}]}"#;
409        let err = parse_registration_json(json).unwrap_err();
410        assert!(matches!(err, ExtismError::OutputDecode(_)));
411    }
412
413    #[test]
414    fn parses_vector_and_variadic_argtypes() {
415        let json = br#"{
416            "entries": [{
417                "kind": "scalar",
418                "qname": "vec.norm",
419                "signature": {
420                    "args": [
421                        {"kind":"vector","len":128,"element":"float32"},
422                        {"kind":"variadic","inner":{"kind":"primitive","arrow":"int64"}}
423                    ],
424                    "returns": {"kind":"primitive","arrow":"float32"}
425                }
426            }]
427        }"#;
428        let r = parse_registration_json(json).unwrap();
429        match &r.entries[0] {
430            RegistrationEntry::Scalar { signature, .. } => {
431                assert!(matches!(
432                    signature.args[0],
433                    WireArgType::Vector { len: 128, ref element } if element == "float32"
434                ));
435                assert!(matches!(
436                    signature.args[1],
437                    WireArgType::Variadic { ref inner } if matches!(
438                        **inner,
439                        WireArgType::Primitive { ref arrow } if arrow == "int64"
440                    )
441                ));
442            }
443            _ => unreachable!(),
444        }
445    }
446}