Skip to main content

uni_plugin_rhai/
manifest.rs

1//! Rhai-side manifest reader.
2//!
3//! Rhai plugins declare their identity, version, capabilities, and
4//! provided functions by exporting a `uni_manifest()` function that
5//! returns a Rhai map. This module compiles the script, calls the
6//! function, and walks the returned `rhai::Map` into a structured
7//! manifest the loader can use.
8//!
9//! Expected shape (Rhai source):
10//!
11//! ```rhai
12//! fn uni_manifest() {
13//!   #{
14//!     id:          "ai.example.score",
15//!     version:     "0.1.0",
16//!     determinism: "pure",
17//!     scalar_fns: [
18//!       #{ name: "score", args: ["float","float"], returns: "float" },
19//!     ],
20//!     aggregate_fns: [
21//!       #{ name: "stats", args: ["float"], returns: "map", state: "map" },
22//!     ],
23//!     procedures: [
24//!       #{ name: "rows", args: [], yields: ["int","string"] },
25//!     ],
26//!   }
27//! }
28//! ```
29
30#![cfg(feature = "rhai-runtime")]
31
32use rhai::{AST, Dynamic, Engine, Map, Scope};
33
34use crate::error::RhaiError;
35
36/// Result of parsing a `uni_manifest()` return value.
37#[derive(Debug, Clone, Default)]
38pub struct RhaiManifest {
39    /// Plugin id (`"ai.example.score"`).
40    pub id: String,
41    /// Plugin version (semver string).
42    pub version: String,
43    /// Determinism: `"pure"`, `"session"`, or `"nondeterministic"`.
44    pub determinism: String,
45    /// Declared scalar functions.
46    pub scalar_fns: Vec<ScalarEntry>,
47    /// Declared aggregate functions.
48    pub aggregate_fns: Vec<AggregateEntry>,
49    /// Declared procedures.
50    pub procedures: Vec<ProcedureEntry>,
51}
52
53/// One scalar fn entry from the Rhai manifest.
54#[derive(Debug, Clone)]
55pub struct ScalarEntry {
56    /// Function name as declared in the script (also the Rhai callable).
57    pub name: String,
58    /// Argument type names (`"float"`, `"int"`, …).
59    pub args: Vec<String>,
60    /// Return type name.
61    pub returns: String,
62    /// Opt-in vectorised mode — the function takes column userdata.
63    /// Defaults to `false` (row mode).
64    pub vectorized: bool,
65}
66
67/// One aggregate fn entry.
68#[derive(Debug, Clone)]
69pub struct AggregateEntry {
70    /// Aggregate name; must also be the name of a `const` map in the
71    /// script carrying `init` / `accumulate` / `merge` / `finalize`
72    /// closures.
73    pub name: String,
74    /// Input type names.
75    pub args: Vec<String>,
76    /// Final return type name.
77    pub returns: String,
78    /// State type — informational; v1 always wraps as JSON-blob
79    /// `LargeBinary` regardless.
80    pub state: String,
81}
82
83/// One procedure entry.
84#[derive(Debug, Clone)]
85pub struct ProcedureEntry {
86    /// Procedure name.
87    pub name: String,
88    /// Argument type names.
89    pub args: Vec<String>,
90    /// Yielded column type names (in declaration order).
91    pub yields: Vec<String>,
92    /// Mode: `"read"`, `"write"`, `"schema"`, or `"dbms"`. Default
93    /// `"read"`.
94    pub mode: String,
95}
96
97/// Compile a Rhai script into an AST.
98///
99/// Returns [`RhaiError::ParseFailed`] on syntax errors, with the Rhai
100/// position information preserved in the error message.
101pub fn compile(engine: &Engine, script: &str) -> Result<AST, RhaiError> {
102    engine
103        .compile(script)
104        .map_err(|e| RhaiError::ParseFailed(format!("{e}")))
105}
106
107/// Call the script's `uni_manifest()` function and parse the returned
108/// map into a [`RhaiManifest`].
109pub fn parse_manifest(engine: &Engine, ast: &AST) -> Result<RhaiManifest, RhaiError> {
110    let mut scope = Scope::new();
111    let dynamic: Dynamic = engine
112        .call_fn(&mut scope, ast, "uni_manifest", ())
113        .map_err(|e| RhaiError::ManifestInvalid(format!("calling uni_manifest: {e}")))?;
114
115    let map: Map = dynamic
116        .try_cast::<Map>()
117        .ok_or_else(|| RhaiError::ManifestInvalid("uni_manifest() must return a map".into()))?;
118
119    let id = required_string(&map, "id")?;
120    let version = required_string(&map, "version")?;
121    let determinism = optional_string(&map, "determinism").unwrap_or_else(|| "pure".into());
122
123    let scalar_fns = parse_scalar_entries(&map)?;
124    let aggregate_fns = parse_aggregate_entries(&map)?;
125    let procedures = parse_procedure_entries(&map)?;
126
127    Ok(RhaiManifest {
128        id,
129        version,
130        determinism,
131        scalar_fns,
132        aggregate_fns,
133        procedures,
134    })
135}
136
137/// Parse the array under `key` into a `Vec<T>`, building each entry from
138/// its map via `build`.
139///
140/// A missing `key` yields an empty vec (the field is optional). A present
141/// but non-array value, or a non-map element, is a `ManifestInvalid` error
142/// labelled with `key`.
143fn parse_entry_array<T>(
144    map: &Map,
145    key: &str,
146    build: impl Fn(&Map) -> Result<T, RhaiError>,
147) -> Result<Vec<T>, RhaiError> {
148    let Some(arr) = map.get(key) else {
149        return Ok(vec![]);
150    };
151    let arr = arr
152        .clone()
153        .try_cast::<rhai::Array>()
154        .ok_or_else(|| RhaiError::ManifestInvalid(format!("{key} must be an array of maps")))?;
155    let mut entries = Vec::with_capacity(arr.len());
156    for d in arr {
157        let m = d
158            .try_cast::<Map>()
159            .ok_or_else(|| RhaiError::ManifestInvalid(format!("{key} entry must be a map")))?;
160        entries.push(build(&m)?);
161    }
162    Ok(entries)
163}
164
165fn parse_scalar_entries(map: &Map) -> Result<Vec<ScalarEntry>, RhaiError> {
166    parse_entry_array(map, "scalar_fns", |m| {
167        Ok(ScalarEntry {
168            name: required_string(m, "name")?,
169            args: required_string_array(m, "args")?,
170            returns: required_string(m, "returns")?,
171            vectorized: optional_bool(m, "vectorized").unwrap_or(false),
172        })
173    })
174}
175
176fn parse_aggregate_entries(map: &Map) -> Result<Vec<AggregateEntry>, RhaiError> {
177    parse_entry_array(map, "aggregate_fns", |m| {
178        Ok(AggregateEntry {
179            name: required_string(m, "name")?,
180            args: required_string_array(m, "args")?,
181            returns: required_string(m, "returns")?,
182            state: optional_string(m, "state").unwrap_or_else(|| "map".into()),
183        })
184    })
185}
186
187fn parse_procedure_entries(map: &Map) -> Result<Vec<ProcedureEntry>, RhaiError> {
188    parse_entry_array(map, "procedures", |m| {
189        Ok(ProcedureEntry {
190            name: required_string(m, "name")?,
191            args: required_string_array(m, "args")?,
192            yields: required_string_array(m, "yields")?,
193            mode: optional_string(m, "mode").unwrap_or_else(|| "read".into()),
194        })
195    })
196}
197
198fn required_string(map: &Map, key: &str) -> Result<String, RhaiError> {
199    let dyn_val = map
200        .get(key)
201        .ok_or_else(|| RhaiError::ManifestInvalid(format!("missing required field `{key}`")))?;
202    dyn_val
203        .clone()
204        .into_string()
205        .map_err(|t| RhaiError::ManifestInvalid(format!("`{key}` must be a string (got {t})")))
206}
207
208fn optional_string(map: &Map, key: &str) -> Option<String> {
209    map.get(key).and_then(|d| d.clone().into_string().ok())
210}
211
212fn optional_bool(map: &Map, key: &str) -> Option<bool> {
213    map.get(key).and_then(|d| d.as_bool().ok())
214}
215
216fn required_string_array(map: &Map, key: &str) -> Result<Vec<String>, RhaiError> {
217    let dyn_val = map
218        .get(key)
219        .ok_or_else(|| RhaiError::ManifestInvalid(format!("missing required field `{key}`")))?;
220    let arr = dyn_val
221        .clone()
222        .try_cast::<rhai::Array>()
223        .ok_or_else(|| RhaiError::ManifestInvalid(format!("`{key}` must be an array")))?;
224    let mut out = Vec::with_capacity(arr.len());
225    for (i, d) in arr.into_iter().enumerate() {
226        let s = d.into_string().map_err(|t| {
227            RhaiError::ManifestInvalid(format!("`{key}`[{i}] must be a string (got {t})"))
228        })?;
229        out.push(s);
230    }
231    Ok(out)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::engine::build_engine;
238    use crate::host_fns::RhaiHostFnRegistry;
239    use uni_plugin::CapabilitySet;
240
241    fn engine() -> Engine {
242        build_engine(&CapabilitySet::new(), &RhaiHostFnRegistry::new())
243    }
244
245    #[test]
246    fn parses_minimal_manifest() {
247        let script = r#"
248            fn uni_manifest() {
249                #{
250                    id: "ai.test.min",
251                    version: "0.1.0",
252                    scalar_fns: [
253                        #{ name: "score", args: ["float","float"], returns: "float" },
254                    ],
255                }
256            }
257            fn score(x, y) { x + y }
258        "#;
259        let eng = engine();
260        let ast = compile(&eng, script).expect("compiles");
261        let m = parse_manifest(&eng, &ast).expect("parses");
262        assert_eq!(m.id, "ai.test.min");
263        assert_eq!(m.version, "0.1.0");
264        assert_eq!(m.determinism, "pure");
265        assert_eq!(m.scalar_fns.len(), 1);
266        assert_eq!(m.scalar_fns[0].name, "score");
267        assert_eq!(m.scalar_fns[0].args, vec!["float", "float"]);
268        assert_eq!(m.scalar_fns[0].returns, "float");
269        assert!(!m.scalar_fns[0].vectorized);
270    }
271
272    #[test]
273    fn missing_id_rejected() {
274        let script = r#"
275            fn uni_manifest() { #{ version: "0.1.0" } }
276        "#;
277        let eng = engine();
278        let ast = compile(&eng, script).unwrap();
279        let err = parse_manifest(&eng, &ast).unwrap_err();
280        assert!(matches!(err, RhaiError::ManifestInvalid(_)));
281    }
282
283    #[test]
284    fn parses_aggregate_and_procedure_entries() {
285        let script = r#"
286            fn uni_manifest() {
287                #{
288                    id: "ai.test.agg",
289                    version: "0.1.0",
290                    aggregate_fns: [
291                        #{ name: "stats", args: ["float"], returns: "map", state: "map" },
292                    ],
293                    procedures: [
294                        #{ name: "rows", args: [], yields: ["int","string"], mode: "read" },
295                    ],
296                }
297            }
298        "#;
299        let eng = engine();
300        let ast = compile(&eng, script).unwrap();
301        let m = parse_manifest(&eng, &ast).unwrap();
302        assert_eq!(m.aggregate_fns.len(), 1);
303        assert_eq!(m.aggregate_fns[0].name, "stats");
304        assert_eq!(m.procedures.len(), 1);
305        assert_eq!(m.procedures[0].yields, vec!["int", "string"]);
306    }
307}