Skip to main content

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}