Skip to main content

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}