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    /// 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}