uni_plugin/errors.rs
1//! Error types for the plugin framework.
2//!
3//! Errors are split between [`PluginError`] (framework-level failures —
4//! invalid manifest, capability denied, duplicate registration, ABI
5//! mismatch) and [`FnError`] (per-invocation failures returned by a plugin's
6//! work function and wrapped into a `UniError::Plugin` by the host adapter).
7
8use std::fmt;
9
10use thiserror::Error;
11
12use crate::capability::Capability;
13use crate::qname::QName;
14
15/// Errors surfaced by the plugin framework itself.
16///
17/// `PluginError` covers framework operations: manifest parsing, capability
18/// checks, registration validation, dependency resolution, WASM loading,
19/// signing. Per-invocation errors from plugin code are represented by
20/// [`FnError`] instead.
21#[derive(Debug, Error)]
22#[non_exhaustive]
23pub enum PluginError {
24 /// The supplied manifest could not be parsed.
25 #[error("plugin manifest parse failure: {0}")]
26 ManifestParse(String),
27
28 /// The manifest's `abi` range does not intersect any host-supported major.
29 #[error(
30 "plugin {plugin} requires uni-plugin ABI {required}; \
31 host supports majors {supported:?}"
32 )]
33 AbiUnsupported {
34 /// Plugin id reporting the mismatch.
35 plugin: String,
36 /// Required ABI range from the manifest.
37 required: String,
38 /// Host-supported major versions.
39 supported: Vec<u64>,
40 },
41
42 /// A registration was attempted without the required capability.
43 #[error("plugin attempted registration requiring capability {0:?}; not granted")]
44 CapabilityRequired(Capability),
45
46 /// A capability the plugin requested was denied by the host loader.
47 #[error("plugin requested capability {0:?}; denied by host")]
48 CapabilityDenied(Capability),
49
50 /// Two registrations attempted to claim the same qualified name.
51 #[error("duplicate registration for qualified name {0}")]
52 DuplicateRegistration(QName),
53
54 /// A `depends_on` entry referenced a missing or version-incompatible plugin.
55 #[error("plugin {dependent} depends on {dep_id} (req {req}); not satisfied")]
56 DependencyMissing {
57 /// Plugin id whose manifest declared the dependency.
58 dependent: String,
59 /// Missing dependency id.
60 dep_id: String,
61 /// Version requirement from the manifest.
62 req: String,
63 },
64
65 /// A cycle was detected in the dependency graph.
66 #[error("dependency cycle in plugin graph: {0:?}")]
67 DependencyCycle(Vec<String>),
68
69 /// The manifest's signature failed verification against the trust root.
70 #[error("plugin manifest signature invalid: {0}")]
71 SignatureInvalid(String),
72
73 /// The plugin's hash did not match the pinned blake3 digest.
74 #[error("plugin hash mismatch: expected {expected}, actual {actual}")]
75 HashMismatch {
76 /// Hash declared in the manifest.
77 expected: String,
78 /// Hash actually computed at load.
79 actual: String,
80 },
81
82 /// WASM component instantiation failed (loader-side).
83 #[error("WASM instantiate failure: {0}")]
84 WasmInstantiate(String),
85
86 /// Lua source parse / compile failed.
87 #[error("Lua plugin parse failure: {0}")]
88 LuaParse(String),
89
90 /// Rhai source parse / compile failed.
91 #[error("Rhai plugin parse failure: {0}")]
92 RhaiParse(String),
93
94 /// A qualified name failed to parse.
95 #[error("invalid qualified name: `{0}`")]
96 InvalidQName(String),
97
98 /// A logical type registration conflicted with an existing extension type.
99 #[error("logical-type conflict: extension name `{0}` already registered")]
100 LogicalTypeConflict(String),
101
102 /// Storage scheme already registered.
103 #[error("storage scheme `{0}` already registered")]
104 StorageSchemeConflict(String),
105
106 /// Catch-all for genuinely internal errors that don't map to a variant above.
107 #[error("internal plugin-framework error: {0}")]
108 Internal(String),
109}
110
111impl PluginError {
112 /// Construct an [`PluginError::Internal`] with a descriptive message.
113 #[must_use]
114 pub fn internal(message: impl Into<String>) -> Self {
115 Self::Internal(message.into())
116 }
117}
118
119/// Per-invocation error returned by a plugin's work function.
120///
121/// `FnError` is what crosses the host↔plugin boundary on every call. The
122/// host wraps it into the user-facing error chain. WASM plugins return this
123/// shape over the WIT `fn-error` record.
124#[derive(Clone, Debug)]
125pub struct FnError {
126 /// Plugin-defined error code. Reserved range `0..=0xFF` for framework
127 /// errors; plugins use `0x100..=u32::MAX`.
128 pub code: u32,
129 /// Human-readable error message.
130 pub message: String,
131 /// Whether the caller should retry the operation (e.g., transient
132 /// network failure).
133 pub retryable: bool,
134}
135
136impl FnError {
137 /// Build an `FnError` with the given code and message; not retryable.
138 #[must_use]
139 pub fn new(code: u32, message: impl Into<String>) -> Self {
140 Self {
141 code,
142 message: message.into(),
143 retryable: false,
144 }
145 }
146
147 /// Build a retryable `FnError`.
148 #[must_use]
149 pub fn retryable(code: u32, message: impl Into<String>) -> Self {
150 Self {
151 code,
152 message: message.into(),
153 retryable: true,
154 }
155 }
156
157 /// Framework-reserved code for "unknown function name at dispatch site".
158 pub const CODE_UNKNOWN_FUNCTION: u32 = 0x01;
159 /// Framework-reserved code for "type-coercion failure on input column".
160 pub const CODE_TYPE_COERCION: u32 = 0x02;
161 /// Framework-reserved code for "null encountered where forbidden".
162 pub const CODE_UNEXPECTED_NULL: u32 = 0x03;
163 /// Framework-reserved code for "resource limit exceeded".
164 pub const CODE_RESOURCE_LIMIT: u32 = 0x04;
165 /// Framework-reserved code for "plugin attempted forbidden side effect".
166 pub const CODE_FORBIDDEN: u32 = 0x05;
167
168 /// Convenience constructor for "unknown function" errors.
169 #[must_use]
170 pub fn unknown_function(name: impl AsRef<str>) -> Self {
171 Self::new(
172 Self::CODE_UNKNOWN_FUNCTION,
173 format!("unknown function: {}", name.as_ref()),
174 )
175 }
176}
177
178impl fmt::Display for FnError {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 write!(
181 f,
182 "plugin fn error (code={}, retryable={}): {}",
183 self.code, self.retryable, self.message
184 )
185 }
186}
187
188impl std::error::Error for FnError {}
189
190/// Errors produced by the hot-reload pipeline.
191///
192/// Surfaced by [`crate::reload::ReloadDispatcher::dispatch`] and by
193/// the host's `Uni::reload` / `Uni::remove_plugin` entry points. Each
194/// variant maps to a distinct failure mode of the §11.2 epoch-fenced
195/// cutover: a drain-state-machine failure, a per-kind schema-compat
196/// rejection, a persistence/round-trip failure on a stateful surface,
197/// or a generic plugin-framework error wrapped through.
198///
199/// Reload failures abort the cutover **before** the new plugin's
200/// surfaces are committed to the registry, so the registry stays
201/// consistent with the still-active old plugin.
202#[derive(Debug, Error)]
203#[non_exhaustive]
204pub enum ReloadError {
205 /// The drain state machine rejected the request.
206 #[error("drain failure during reload: {0}")]
207 Drain(String),
208
209 /// A per-kind schema-compat check rejected the new provider.
210 ///
211 /// Holds the kind name (e.g., `"crdt:lww-register"`) and a
212 /// human-readable explanation of the incompatibility.
213 #[error("schema-incompat for {kind}: {reason}")]
214 SchemaIncompat {
215 /// Per-kind discriminator with a `kind:value` prefix
216 /// (`"crdt:lww-register"`, `"logical-type:geo.point"`).
217 kind: String,
218 /// Human-readable explanation of the incompatibility.
219 reason: String,
220 },
221
222 /// A stateful surface failed to persist/round-trip during reload.
223 #[error("persist/restore failure during reload: {0}")]
224 Persist(FnError),
225
226 /// The new plugin's `register()` (or other framework op) failed.
227 #[error(transparent)]
228 Plugin(#[from] PluginError),
229
230 /// The host lookup for a plugin handle came up empty.
231 #[error("plugin {0} not found in host registry")]
232 PluginNotFound(String),
233}
234
235impl ReloadError {
236 /// Convenience constructor for a schema-incompatibility rejection.
237 #[must_use]
238 pub fn schema_incompat(kind: impl Into<String>, reason: impl Into<String>) -> Self {
239 Self::SchemaIncompat {
240 kind: kind.into(),
241 reason: reason.into(),
242 }
243 }
244}
245
246/// Outcome of a host-side hook invocation.
247///
248/// Hooks may continue normally, request a rewrite of the operation, or
249/// reject the operation outright with a reason.
250#[derive(Debug)]
251#[non_exhaustive]
252pub enum HookOutcome {
253 /// Continue normally.
254 Continue,
255 /// Reject the operation; surfaced as `UniError::HookRejected`.
256 Reject {
257 /// Human-readable rejection reason.
258 reason: String,
259 },
260}
261
262impl HookOutcome {
263 /// Build a `Reject` outcome with the given reason.
264 #[must_use]
265 pub fn reject(reason: impl Into<String>) -> Self {
266 Self::Reject {
267 reason: reason.into(),
268 }
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn fn_error_constructors() {
278 let e = FnError::new(0x100, "boom");
279 assert_eq!(e.code, 0x100);
280 assert!(!e.retryable);
281 assert_eq!(e.message, "boom");
282
283 let e = FnError::retryable(0x101, "transient");
284 assert!(e.retryable);
285
286 let e = FnError::unknown_function("nope");
287 assert_eq!(e.code, FnError::CODE_UNKNOWN_FUNCTION);
288 assert!(e.message.contains("nope"));
289 }
290
291 #[test]
292 fn plugin_error_internal_constructor() {
293 let e = PluginError::internal("oops");
294 match e {
295 PluginError::Internal(message) => assert_eq!(message, "oops"),
296 other => panic!("expected Internal, got {other:?}"),
297 }
298 }
299
300 #[test]
301 fn plugin_error_display_contains_context() {
302 let e = PluginError::HashMismatch {
303 expected: "abc".to_owned(),
304 actual: "def".to_owned(),
305 };
306 let s = e.to_string();
307 assert!(s.contains("abc"));
308 assert!(s.contains("def"));
309 }
310}