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