Skip to main content

uni_plugin/
manifest.rs

1//! Plugin manifest — TOML and JSON (de)serialization.
2//!
3//! The manifest is the *typed contract* between a plugin and the host. It
4//! declares the plugin's identity, the ABI range it targets, capabilities it
5//! requests, declarative dependencies on other plugins, the determinism /
6//! side-effect / scope characterizations, and a summary of what surfaces it
7//! plans to register.
8//!
9//! Manifests are persisted in TOML for human authoring and on-the-wire JSON
10//! for programmatic exchange (WASM plugins return JSON from their
11//! `manifest-json` export). Both forms round-trip through `serde`.
12
13use std::collections::BTreeMap;
14
15use semver::{Version, VersionReq};
16use serde::{Deserialize, Serialize};
17use smol_str::SmolStr;
18
19use crate::capability::{CapabilitySet, Determinism, Scope, SideEffects};
20use crate::errors::PluginError;
21use crate::plugin::PluginId;
22
23/// A semver range expressing which ABI majors this plugin supports.
24///
25/// Stored as the original requirement string so manifests round-trip
26/// losslessly through serialization. Use [`AbiRange::matches`] to test
27/// against a host major.
28///
29/// # Examples
30///
31/// ```
32/// use uni_plugin::AbiRange;
33/// let r = AbiRange::parse("^1.2").unwrap();
34/// assert!(r.matches(1));
35/// assert!(!r.matches(2));
36/// ```
37#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(transparent)]
39pub struct AbiRange(String);
40
41impl AbiRange {
42    /// Parse an ABI range from a semver requirement string.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`PluginError::ManifestParse`] if the input is not a valid
47    /// semver `VersionReq`.
48    pub fn parse(s: impl AsRef<str>) -> Result<Self, PluginError> {
49        let s = s.as_ref();
50        VersionReq::parse(s)
51            .map_err(|e| PluginError::ManifestParse(format!("invalid abi range `{s}`: {e}")))?;
52        Ok(Self(s.to_owned()))
53    }
54
55    /// Check whether a host ABI major satisfies this range.
56    ///
57    /// The check passes if any version with major == `host_major` falls
58    /// within the requirement. We probe with a high minor / patch so that
59    /// ranges like `^1.2` (which excludes `1.0.0`) still recognize the
60    /// host's major as compatible.
61    #[must_use]
62    pub fn matches(&self, host_major: u64) -> bool {
63        // `unwrap_or(STAR)` is defensive — the range was validated at parse.
64        let req = VersionReq::parse(&self.0).unwrap_or(VersionReq::STAR);
65        // The question is "does ANY version with major == host_major satisfy the
66        // requirement?". A single high `(major, MAX, MAX)` probe answers that only
67        // for caret / lower-bounded ranges — it wrongly fails an upper-bounded
68        // minor/patch range such as `~1.2` (`>=1.2.0, <1.3.0`) or `=1.2.3`, whose
69        // in-range points sit at the comparators' own coordinates. So probe those
70        // coordinates (and just above each, for `>x.y.z` lower bounds) plus the
71        // extremes, and accept if any candidate at this major satisfies the req.
72        let mut candidates: Vec<(u64, u64)> = vec![(0, 0), (u64::MAX / 2, u64::MAX / 2)];
73        for c in &req.comparators {
74            let minor = c.minor.unwrap_or(0);
75            let patch = c.patch.unwrap_or(0);
76            candidates.push((minor, patch));
77            candidates.push((minor, patch.saturating_add(1)));
78        }
79        candidates
80            .into_iter()
81            .any(|(minor, patch)| req.matches(&Version::new(host_major, minor, patch)))
82    }
83
84    /// Returns the underlying range string.
85    #[must_use]
86    pub fn as_str(&self) -> &str {
87        &self.0
88    }
89}
90
91/// A dependency on another plugin.
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
93pub struct PluginDep {
94    /// Required dependency plugin id.
95    pub id: PluginId,
96    /// Version requirement (semver range).
97    pub version_req: String,
98    /// If `true`, the dependency is best-effort (init still runs even if missing).
99    #[serde(default)]
100    pub optional: bool,
101}
102
103impl PluginDep {
104    /// Construct a required dependency.
105    #[must_use]
106    pub fn new(id: PluginId, version_req: impl Into<String>) -> Self {
107        Self {
108            id,
109            version_req: version_req.into(),
110            optional: false,
111        }
112    }
113
114    /// Check whether the supplied `version` satisfies this dependency.
115    #[must_use]
116    pub fn satisfied_by(&self, version: &Version) -> bool {
117        VersionReq::parse(&self.version_req).is_ok_and(|r| r.matches(version))
118    }
119}
120
121/// Declarative summary of what surfaces a plugin's `register()` will add.
122///
123/// Built by the plugin author and serialized into the manifest. The host can
124/// use this to validate registrations against the manifest and to build a
125/// fast pre-registration routing table.
126#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(default)]
128pub struct ProvidedSurfaces {
129    /// Scalar function locals (un-namespaced, joined with manifest id at
130    /// registration).
131    pub scalar_fns: Vec<SmolStr>,
132    /// Aggregate function locals.
133    pub aggregate_fns: Vec<SmolStr>,
134    /// Window function locals.
135    pub window_fns: Vec<SmolStr>,
136    /// Procedure locals.
137    pub procedures: Vec<SmolStr>,
138    /// Locy aggregate locals.
139    pub locy_aggregates: Vec<SmolStr>,
140    /// Locy predicate locals.
141    pub locy_predicates: Vec<SmolStr>,
142    /// Algorithm locals.
143    pub algorithms: Vec<SmolStr>,
144    /// Storage backends declared (by URI scheme).
145    pub storage_backends: Vec<SmolStr>,
146    /// Index kinds declared.
147    pub index_kinds: Vec<SmolStr>,
148    /// CRDT kinds declared.
149    pub crdt_kinds: Vec<SmolStr>,
150    /// Logical (Arrow extension) types declared.
151    pub logical_types: Vec<SmolStr>,
152    /// Whether the plugin contributes phased hooks.
153    pub hooks: bool,
154    /// Whether the plugin contributes triggers.
155    pub triggers: bool,
156    /// Whether the plugin contributes background jobs.
157    pub background_jobs: bool,
158    /// Wire-protocol connectors declared.
159    pub connectors: Vec<SmolStr>,
160}
161
162/// Top-level plugin manifest.
163///
164/// Authored as TOML, exchanged as JSON (the WASM `manifest-json` export
165/// returns this serialized to JSON). Round-trips through `serde` in either
166/// format.
167#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
168pub struct PluginManifest {
169    /// Reverse-DNS plugin id.
170    pub id: PluginId,
171    /// Plugin semantic version.
172    pub version: Version,
173    /// ABI range this plugin supports.
174    pub abi: AbiRange,
175    /// Plugins this depends on. May be empty.
176    #[serde(default)]
177    pub depends_on: Vec<PluginDep>,
178    /// Capability requests; granted set is intersection with host grants.
179    #[serde(default)]
180    pub capabilities: CapabilitySet,
181    /// Determinism characterization.
182    #[serde(default)]
183    pub determinism: Determinism,
184    /// Side-effect characterization.
185    #[serde(default)]
186    pub side_effects: SideEffects,
187    /// Lifetime scope.
188    #[serde(default)]
189    pub scope: Scope,
190    /// Optional hash pin (blake3 hex string of the plugin payload).
191    #[serde(default)]
192    pub hash: Option<String>,
193    /// Optional Ed25519 signature over canonical-JSON manifest + payload hash.
194    #[serde(default)]
195    pub signature: Option<ManifestSignature>,
196    /// Declarative surface summary.
197    #[serde(default)]
198    pub provides: ProvidedSurfaces,
199    /// Markdown docs surfaced via `uni plugin help <qname>` and
200    /// `CALL uni.plugin.help('qname')`.
201    #[serde(default)]
202    pub docs: String,
203    /// Free-form metadata (author, license, repo, etc.).
204    #[serde(default)]
205    pub metadata: BTreeMap<String, String>,
206}
207
208impl PluginManifest {
209    /// Parse a manifest from a TOML string.
210    ///
211    /// # Errors
212    ///
213    /// Returns [`PluginError::ManifestParse`] if the input fails to parse.
214    pub fn from_toml(s: impl AsRef<str>) -> Result<Self, PluginError> {
215        toml::from_str(s.as_ref()).map_err(|e| PluginError::ManifestParse(format!("toml: {e}")))
216    }
217
218    /// Parse a manifest from a JSON string.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`PluginError::ManifestParse`] if the input fails to parse.
223    pub fn from_json(s: impl AsRef<str>) -> Result<Self, PluginError> {
224        serde_json::from_str(s.as_ref())
225            .map_err(|e| PluginError::ManifestParse(format!("json: {e}")))
226    }
227
228    /// Serialize this manifest to TOML.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`PluginError::ManifestParse`] if serialization fails
233    /// (unusual — only happens with non-stringifiable map keys, which the
234    /// manifest doesn't produce).
235    pub fn to_toml(&self) -> Result<String, PluginError> {
236        toml::to_string_pretty(self)
237            .map_err(|e| PluginError::ManifestParse(format!("toml serialize: {e}")))
238    }
239
240    /// Serialize this manifest to JSON (compact).
241    ///
242    /// # Errors
243    ///
244    /// Returns [`PluginError::ManifestParse`] if serialization fails.
245    pub fn to_json(&self) -> Result<String, PluginError> {
246        serde_json::to_string(self)
247            .map_err(|e| PluginError::ManifestParse(format!("json serialize: {e}")))
248    }
249}
250
251/// Manifest signature material.
252#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
253pub struct ManifestSignature {
254    /// Algorithm identifier — `"ed25519"` for v1.
255    pub algorithm: String,
256    /// Key identifier (key fingerprint or human-readable name).
257    pub key_id: String,
258    /// Base64-encoded signature bytes.
259    pub value: String,
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    fn sample_manifest() -> PluginManifest {
267        PluginManifest {
268            id: PluginId::new("ai.dragonscale.geo"),
269            version: Version::parse("0.3.1").unwrap(),
270            abi: AbiRange::parse("^1").unwrap(),
271            depends_on: vec![],
272            capabilities: CapabilitySet::new(),
273            determinism: Determinism::Pure,
274            side_effects: SideEffects::ReadOnly,
275            scope: Scope::Instance,
276            hash: None,
277            signature: None,
278            provides: ProvidedSurfaces::default(),
279            docs: String::new(),
280            metadata: BTreeMap::new(),
281        }
282    }
283
284    #[test]
285    fn abi_range_parse_and_match() {
286        let r = AbiRange::parse("^1.2").unwrap();
287        assert!(r.matches(1));
288        assert!(!r.matches(2));
289    }
290
291    #[test]
292    fn abi_range_rejects_garbage() {
293        assert!(AbiRange::parse("not-semver").is_err());
294    }
295
296    #[test]
297    fn manifest_round_trip_json() {
298        let m = sample_manifest();
299        let s = m.to_json().unwrap();
300        let parsed = PluginManifest::from_json(&s).unwrap();
301        assert_eq!(parsed, m);
302    }
303
304    #[test]
305    fn manifest_round_trip_toml() {
306        let m = sample_manifest();
307        let s = m.to_toml().unwrap();
308        let parsed = PluginManifest::from_toml(&s).unwrap();
309        assert_eq!(parsed, m);
310    }
311
312    #[test]
313    fn plugin_dep_version_satisfaction() {
314        let dep = PluginDep::new(PluginId::new("units"), "^0.4".to_owned());
315        assert!(dep.satisfied_by(&Version::parse("0.4.2").unwrap()));
316        assert!(!dep.satisfied_by(&Version::parse("0.3.0").unwrap()));
317    }
318}