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 // A very-high minor/patch ensures we hit any minor-tightened range
66 // (`^1.2`, `>=1.5.3`). Using u64::MAX / 2 leaves headroom for
67 // arithmetic in callers without overflow.
68 let probe = Version::new(host_major, u64::MAX / 2, u64::MAX / 2);
69 req.matches(&probe)
70 }
71
72 /// Returns the underlying range string.
73 #[must_use]
74 pub fn as_str(&self) -> &str {
75 &self.0
76 }
77}
78
79/// A dependency on another plugin.
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81pub struct PluginDep {
82 /// Required dependency plugin id.
83 pub id: PluginId,
84 /// Version requirement (semver range).
85 pub version_req: String,
86 /// If `true`, the dependency is best-effort (init still runs even if missing).
87 #[serde(default)]
88 pub optional: bool,
89}
90
91impl PluginDep {
92 /// Construct a required dependency.
93 #[must_use]
94 pub fn new(id: PluginId, version_req: impl Into<String>) -> Self {
95 Self {
96 id,
97 version_req: version_req.into(),
98 optional: false,
99 }
100 }
101
102 /// Check whether the supplied `version` satisfies this dependency.
103 #[must_use]
104 pub fn satisfied_by(&self, version: &Version) -> bool {
105 VersionReq::parse(&self.version_req)
106 .map(|r| r.matches(version))
107 .unwrap_or(false)
108 }
109}
110
111/// Declarative summary of what surfaces a plugin's `register()` will add.
112///
113/// Built by the plugin author and serialized into the manifest. The host can
114/// use this to validate registrations against the manifest and to build a
115/// fast pre-registration routing table.
116#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(default)]
118pub struct ProvidedSurfaces {
119 /// Scalar function locals (un-namespaced, joined with manifest id at
120 /// registration).
121 pub scalar_fns: Vec<SmolStr>,
122 /// Aggregate function locals.
123 pub aggregate_fns: Vec<SmolStr>,
124 /// Window function locals.
125 pub window_fns: Vec<SmolStr>,
126 /// Procedure locals.
127 pub procedures: Vec<SmolStr>,
128 /// Locy aggregate locals.
129 pub locy_aggregates: Vec<SmolStr>,
130 /// Locy predicate locals.
131 pub locy_predicates: Vec<SmolStr>,
132 /// Algorithm locals.
133 pub algorithms: Vec<SmolStr>,
134 /// Storage backends declared (by URI scheme).
135 pub storage_backends: Vec<SmolStr>,
136 /// Index kinds declared.
137 pub index_kinds: Vec<SmolStr>,
138 /// CRDT kinds declared.
139 pub crdt_kinds: Vec<SmolStr>,
140 /// Logical (Arrow extension) types declared.
141 pub logical_types: Vec<SmolStr>,
142 /// Whether the plugin contributes phased hooks.
143 pub hooks: bool,
144 /// Whether the plugin contributes triggers.
145 pub triggers: bool,
146 /// Whether the plugin contributes background jobs.
147 pub background_jobs: bool,
148 /// Wire-protocol connectors declared.
149 pub connectors: Vec<SmolStr>,
150}
151
152/// Top-level plugin manifest.
153///
154/// Authored as TOML, exchanged as JSON (the WASM `manifest-json` export
155/// returns this serialized to JSON). Round-trips through `serde` in either
156/// format.
157#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158pub struct PluginManifest {
159 /// Reverse-DNS plugin id.
160 pub id: PluginId,
161 /// Plugin semantic version.
162 pub version: Version,
163 /// ABI range this plugin supports.
164 pub abi: AbiRange,
165 /// Plugins this depends on. May be empty.
166 #[serde(default)]
167 pub depends_on: Vec<PluginDep>,
168 /// Capability requests; granted set is intersection with host grants.
169 #[serde(default)]
170 pub capabilities: CapabilitySet,
171 /// Determinism characterization.
172 #[serde(default)]
173 pub determinism: Determinism,
174 /// Side-effect characterization.
175 #[serde(default)]
176 pub side_effects: SideEffects,
177 /// Lifetime scope.
178 #[serde(default)]
179 pub scope: Scope,
180 /// Optional hash pin (blake3 hex string of the plugin payload).
181 #[serde(default)]
182 pub hash: Option<String>,
183 /// Optional Ed25519 signature over canonical-JSON manifest + payload hash.
184 #[serde(default)]
185 pub signature: Option<ManifestSignature>,
186 /// Declarative surface summary.
187 #[serde(default)]
188 pub provides: ProvidedSurfaces,
189 /// Markdown docs surfaced via `uni plugin help <qname>` and
190 /// `CALL uni.plugin.help('qname')`.
191 #[serde(default)]
192 pub docs: String,
193 /// Free-form metadata (author, license, repo, etc.).
194 #[serde(default)]
195 pub metadata: BTreeMap<String, String>,
196}
197
198impl PluginManifest {
199 /// Parse a manifest from a TOML string.
200 ///
201 /// # Errors
202 ///
203 /// Returns [`PluginError::ManifestParse`] if the input fails to parse.
204 pub fn from_toml(s: impl AsRef<str>) -> Result<Self, PluginError> {
205 toml::from_str(s.as_ref()).map_err(|e| PluginError::ManifestParse(format!("toml: {e}")))
206 }
207
208 /// Parse a manifest from a JSON string.
209 ///
210 /// # Errors
211 ///
212 /// Returns [`PluginError::ManifestParse`] if the input fails to parse.
213 pub fn from_json(s: impl AsRef<str>) -> Result<Self, PluginError> {
214 serde_json::from_str(s.as_ref())
215 .map_err(|e| PluginError::ManifestParse(format!("json: {e}")))
216 }
217
218 /// Serialize this manifest to TOML.
219 ///
220 /// # Errors
221 ///
222 /// Returns [`PluginError::ManifestParse`] if serialization fails
223 /// (unusual — only happens with non-stringifiable map keys, which the
224 /// manifest doesn't produce).
225 pub fn to_toml(&self) -> Result<String, PluginError> {
226 toml::to_string_pretty(self)
227 .map_err(|e| PluginError::ManifestParse(format!("toml serialize: {e}")))
228 }
229
230 /// Serialize this manifest to JSON (compact).
231 ///
232 /// # Errors
233 ///
234 /// Returns [`PluginError::ManifestParse`] if serialization fails.
235 pub fn to_json(&self) -> Result<String, PluginError> {
236 serde_json::to_string(self)
237 .map_err(|e| PluginError::ManifestParse(format!("json serialize: {e}")))
238 }
239}
240
241/// Manifest signature material.
242#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
243pub struct ManifestSignature {
244 /// Algorithm identifier — `"ed25519"` for v1.
245 pub algorithm: String,
246 /// Key identifier (key fingerprint or human-readable name).
247 pub key_id: String,
248 /// Base64-encoded signature bytes.
249 pub value: String,
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn sample_manifest() -> PluginManifest {
257 PluginManifest {
258 id: PluginId::new("ai.dragonscale.geo"),
259 version: Version::parse("0.3.1").unwrap(),
260 abi: AbiRange::parse("^1").unwrap(),
261 depends_on: vec![],
262 capabilities: CapabilitySet::new(),
263 determinism: Determinism::Pure,
264 side_effects: SideEffects::ReadOnly,
265 scope: Scope::Instance,
266 hash: None,
267 signature: None,
268 provides: ProvidedSurfaces::default(),
269 docs: String::new(),
270 metadata: BTreeMap::new(),
271 }
272 }
273
274 #[test]
275 fn abi_range_parse_and_match() {
276 let r = AbiRange::parse("^1.2").unwrap();
277 assert!(r.matches(1));
278 assert!(!r.matches(2));
279 }
280
281 #[test]
282 fn abi_range_rejects_garbage() {
283 assert!(AbiRange::parse("not-semver").is_err());
284 }
285
286 #[test]
287 fn manifest_round_trip_json() {
288 let m = sample_manifest();
289 let s = m.to_json().unwrap();
290 let parsed = PluginManifest::from_json(&s).unwrap();
291 assert_eq!(parsed, m);
292 }
293
294 #[test]
295 fn manifest_round_trip_toml() {
296 let m = sample_manifest();
297 let s = m.to_toml().unwrap();
298 let parsed = PluginManifest::from_toml(&s).unwrap();
299 assert_eq!(parsed, m);
300 }
301
302 #[test]
303 fn plugin_dep_version_satisfaction() {
304 let dep = PluginDep::new(PluginId::new("units"), "^0.4".to_owned());
305 assert!(dep.satisfied_by(&Version::parse("0.4.2").unwrap()));
306 assert!(!dep.satisfied_by(&Version::parse("0.3.0").unwrap()));
307 }
308}