cpex_core/error.rs
1// Location: ./crates/cpex-core/src/error.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Error types for the CPEX plugin framework.
7//
8// Provides structured error types for plugin execution failures,
9// policy violations, timeouts, and configuration errors. Mirrors
10// the Python framework's PluginError, PluginViolation, and
11// PluginViolationError types.
12
13use std::collections::HashMap;
14
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18// ---------------------------------------------------------------------------
19// Plugin Errors
20// ---------------------------------------------------------------------------
21
22/// Top-level error type for the CPEX framework.
23///
24/// Covers plugin execution failures, policy violations, timeouts,
25/// and configuration issues. Each variant carries enough context
26/// for the caller to log, report, or recover.
27///
28/// Mirrors the Python framework's `PluginErrorModel` with:
29/// - `code` — business-logic error code (e.g., `"rate_limit_exceeded"`)
30/// - `details` — structured diagnostic data for logging
31/// - `proto_error_code` — protocol-level error code for the host to
32/// map back to the wire format (MCP JSON-RPC, HTTP status, etc.)
33#[derive(Debug, Error)]
34pub enum PluginError {
35 /// A plugin raised an execution error.
36 #[error("plugin '{plugin_name}' failed: {message}")]
37 Execution {
38 plugin_name: String,
39 message: String,
40 /// Business-logic error code (e.g., `"invalid_token"`).
41 #[source]
42 source: Option<Box<dyn std::error::Error + Send + Sync>>,
43 /// Business-logic error code set by the plugin.
44 code: Option<String>,
45 /// Structured diagnostic data for logging or debugging.
46 details: HashMap<String, serde_json::Value>,
47 /// Protocol-level error code for the host to map to the wire
48 /// format. MCP: JSON-RPC codes (e.g., -32603). HTTP: status
49 /// codes. The host interprets this; CPEX just carries it.
50 proto_error_code: Option<i64>,
51 },
52
53 /// A plugin exceeded its execution timeout.
54 #[error("plugin '{plugin_name}' timed out after {timeout_ms}ms")]
55 Timeout {
56 plugin_name: String,
57 timeout_ms: u64,
58 /// Protocol-level error code for the host.
59 proto_error_code: Option<i64>,
60 },
61
62 /// A plugin returned a policy violation (deny).
63 #[error("plugin '{plugin_name}' denied: {}", violation.reason)]
64 Violation {
65 plugin_name: String,
66 violation: PluginViolation,
67 },
68
69 /// Configuration parsing or validation failed.
70 #[error("configuration error: {message}")]
71 Config { message: String },
72
73 /// A hook type was not found in the registry.
74 #[error("unknown hook type: {hook_type}")]
75 UnknownHook { hook_type: String },
76}
77
78impl PluginError {
79 /// Box this error for use in `Result<T, Box<PluginError>>`.
80 ///
81 /// Public APIs return `Result<T, Box<PluginError>>` rather than
82 /// `Result<T, Box<PluginError>>` because the enum is large (~184 bytes
83 /// — `details: HashMap` and the `source: Box<dyn Error>` push it
84 /// well past clippy's `result_large_err` threshold). Boxing keeps
85 /// `Result<T, _>` pointer-sized on the success path; the
86 /// allocation only happens on the error path.
87 ///
88 /// `.boxed()` is sugar for `Box::new(...)` that reads better at
89 /// construction sites: `PluginError::Config { ... }.boxed()`.
90 /// `?` already calls `From::from`, and `From<T> for Box<T>` is
91 /// built into std, so existing `?` chains keep working.
92 pub fn boxed(self) -> Box<Self> {
93 Box::new(self)
94 }
95}
96
97// ---------------------------------------------------------------------------
98// Plugin Error Record
99// ---------------------------------------------------------------------------
100
101/// A `Clone`-able, serialization-friendly snapshot of a `PluginError`.
102///
103/// Used in `PipelineResult.errors` to surface execution failures from
104/// `on_error: ignore` / `on_error: disable` plugins to the caller —
105/// previously those errors were only logged via `tracing::warn!` and
106/// were invisible to programmatic consumers (agents, dashboards,
107/// retry logic).
108///
109/// `PluginError` itself can't be `Clone` because of its
110/// `Box<dyn std::error::Error + Send + Sync>` source field, and that
111/// field doesn't survive serialization anyway. `PluginErrorRecord`
112/// flattens the five enum variants into a single shape — the
113/// `From<&PluginError>` impl handles the variant-to-fields mapping.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct PluginErrorRecord {
116 pub plugin_name: String,
117 pub message: String,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub code: Option<String>,
120 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
121 pub details: HashMap<String, serde_json::Value>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub proto_error_code: Option<i64>,
124}
125
126/// Forward `&Box<PluginError>` to the `&PluginError` impl.
127///
128/// Public APIs return `Result<T, Box<PluginError>>` (see
129/// `PluginError::boxed`), which means error-handling code in the
130/// pipeline (e.g., `Ok(Err(e))` inside `executor::run_*_phase`) holds
131/// `e: Box<PluginError>`. This blanket forward keeps existing
132/// `(&e).into()` call sites working without forcing every caller to
133/// write `(&*e).into()` after the boxing migration.
134impl From<&Box<PluginError>> for PluginErrorRecord {
135 fn from(e: &Box<PluginError>) -> Self {
136 PluginErrorRecord::from(e.as_ref())
137 }
138}
139
140impl From<&PluginError> for PluginErrorRecord {
141 fn from(e: &PluginError) -> Self {
142 match e {
143 PluginError::Execution {
144 plugin_name,
145 message,
146 code,
147 details,
148 proto_error_code,
149 ..
150 } => Self {
151 plugin_name: plugin_name.clone(),
152 message: message.clone(),
153 code: code.clone(),
154 details: details.clone(),
155 proto_error_code: *proto_error_code,
156 },
157 PluginError::Timeout {
158 plugin_name,
159 timeout_ms,
160 proto_error_code,
161 } => Self {
162 plugin_name: plugin_name.clone(),
163 message: format!("plugin timed out after {}ms", timeout_ms),
164 code: Some("timeout".into()),
165 details: HashMap::new(),
166 proto_error_code: *proto_error_code,
167 },
168 PluginError::Violation {
169 plugin_name,
170 violation,
171 } => Self {
172 plugin_name: plugin_name.clone(),
173 message: format!("plugin denied: {}", violation.reason),
174 code: Some(violation.code.clone()),
175 details: violation.details.clone(),
176 proto_error_code: violation.proto_error_code,
177 },
178 PluginError::Config { message } => Self {
179 plugin_name: String::new(),
180 message: message.clone(),
181 code: Some("config".into()),
182 details: HashMap::new(),
183 proto_error_code: None,
184 },
185 PluginError::UnknownHook { hook_type } => Self {
186 plugin_name: String::new(),
187 message: format!("unknown hook type: {}", hook_type),
188 code: Some("unknown_hook".into()),
189 details: HashMap::new(),
190 proto_error_code: None,
191 },
192 }
193 }
194}
195
196// ---------------------------------------------------------------------------
197// Plugin Violations
198// ---------------------------------------------------------------------------
199
200/// Structured policy violation returned by a plugin that denies execution.
201///
202/// Carries a machine-readable code, human-readable reason, and optional
203/// diagnostic details. Corresponds to the Python `PluginViolation` type.
204///
205/// # Examples
206///
207/// ```
208/// use cpex_core::error::PluginViolation;
209///
210/// let v = PluginViolation::new("missing_permission", "User lacks pii_access");
211/// assert_eq!(v.code, "missing_permission");
212/// assert_eq!(v.reason, "User lacks pii_access");
213/// ```
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct PluginViolation {
216 /// Machine-readable violation identifier (e.g., `"missing_permission"`).
217 pub code: String,
218
219 /// Short human-readable reason for the denial.
220 pub reason: String,
221
222 /// Optional detailed explanation.
223 pub description: Option<String>,
224
225 /// Structured diagnostic data for logging or debugging.
226 pub details: HashMap<String, serde_json::Value>,
227
228 /// Name of the plugin that produced the violation.
229 /// Set by the framework after the plugin returns, not by the plugin itself.
230 pub plugin_name: Option<String>,
231
232 /// Protocol-level error code for the host to map to the wire format.
233 /// MCP: JSON-RPC codes (e.g., -32603). HTTP: status codes (e.g., 403).
234 /// Set by the plugin; the host interprets it.
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub proto_error_code: Option<i64>,
237}
238
239impl PluginViolation {
240 /// Create a new violation with a code and reason.
241 pub fn new(code: impl Into<String>, reason: impl Into<String>) -> Self {
242 Self {
243 code: code.into(),
244 reason: reason.into(),
245 description: None,
246 details: HashMap::new(),
247 plugin_name: None,
248 proto_error_code: None,
249 }
250 }
251
252 /// Attach a detailed description.
253 pub fn with_description(mut self, description: impl Into<String>) -> Self {
254 self.description = Some(description.into());
255 self
256 }
257
258 /// Attach structured diagnostic details.
259 pub fn with_details(mut self, details: HashMap<String, serde_json::Value>) -> Self {
260 self.details = details;
261 self
262 }
263
264 /// Attach a protocol-level error code.
265 pub fn with_proto_error_code(mut self, code: i64) -> Self {
266 self.proto_error_code = Some(code);
267 self
268 }
269}
270
271impl std::fmt::Display for PluginViolation {
272 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273 write!(f, "[{}] {}", self.code, self.reason)
274 }
275}