Skip to main content

cordis/
error.rs

1//! Framework errors and configuration validation diagnostics.
2
3use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5use std::sync::Arc;
6
7/// Stable, machine-readable Cordis error codes.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9#[non_exhaustive]
10pub enum ErrorCode {
11    /// An effect was created from a disposed or unloading context.
12    InactiveEffect,
13    /// A requested service does not exist in the current scope.
14    MissingService,
15    /// A dynamically stored value had an unexpected concrete Rust type.
16    TypeMismatch,
17    /// A service or property was registered more than once in one scope.
18    DuplicateService,
19    /// A service was mutated by a fiber other than its provider.
20    AccessDenied,
21    /// Plugin configuration was rejected by its validator.
22    InvalidConfig,
23    /// Plugin startup failed.
24    Plugin,
25    /// An event listener or middleware failed.
26    Event,
27    /// A property is already declared using another reflection mode.
28    PropertyConflict,
29    /// A general framework error.
30    Other,
31}
32
33impl ErrorCode {
34    /// Return the default human-readable message for this code.
35    pub const fn message(self) -> &'static str {
36        match self {
37            Self::InactiveEffect => "cannot create effect on inactive context",
38            Self::MissingService => "required service is unavailable",
39            Self::TypeMismatch => "stored value has an unexpected type",
40            Self::DuplicateService => "service has already been registered",
41            Self::AccessDenied => "service belongs to another fiber",
42            Self::InvalidConfig => "invalid config",
43            Self::Plugin => "plugin failed",
44            Self::Event => "event listener failed",
45            Self::PropertyConflict => "property is already declared",
46            Self::Other => "cordis error",
47        }
48    }
49}
50
51/// An individual standard-schema-style validation issue.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ValidationIssue {
54    /// Human-readable problem description.
55    pub message: String,
56    /// Path segments locating the invalid value.
57    pub path: Vec<String>,
58}
59
60impl ValidationIssue {
61    /// Construct an issue without a path.
62    pub fn new(message: impl Into<String>) -> Self {
63        Self {
64            message: message.into(),
65            path: Vec::new(),
66        }
67    }
68
69    /// Attach a path to this issue.
70    pub fn at(mut self, path: impl IntoIterator<Item = impl Into<String>>) -> Self {
71        self.path = path.into_iter().map(Into::into).collect();
72        self
73    }
74}
75
76/// Aggregated plugin configuration validation error.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct ValidationError {
79    /// All issues reported by the validator.
80    pub issues: Vec<ValidationIssue>,
81}
82
83impl ValidationError {
84    /// Construct an aggregate from one or more issues.
85    pub fn new(issues: impl IntoIterator<Item = ValidationIssue>) -> Self {
86        Self {
87            issues: issues.into_iter().collect(),
88        }
89    }
90}
91
92impl Display for ValidationError {
93    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
94        writeln!(f, "invalid config:")?;
95        for (index, issue) in self.issues.iter().enumerate() {
96            write!(f, "  - {}", issue.message)?;
97            if !issue.path.is_empty() {
98                write!(f, " (at {})", issue.path.join("."))?;
99            }
100            if index + 1 < self.issues.len() {
101                writeln!(f)?;
102            }
103        }
104        Ok(())
105    }
106}
107
108impl Error for ValidationError {}
109
110/// Error type used throughout the framework.
111#[derive(Debug, Clone)]
112pub struct CordisError {
113    code: ErrorCode,
114    message: String,
115    validation: Option<ValidationError>,
116    source: Option<Arc<dyn Error + Send + Sync + 'static>>,
117}
118
119impl CordisError {
120    /// Construct an error with the code's default message.
121    pub fn new(code: ErrorCode) -> Self {
122        Self {
123            code,
124            message: code.message().to_owned(),
125            validation: None,
126            source: None,
127        }
128    }
129
130    /// Construct an error with a custom message.
131    pub fn with_message(code: ErrorCode, message: impl Into<String>) -> Self {
132        Self {
133            code,
134            message: message.into(),
135            validation: None,
136            source: None,
137        }
138    }
139
140    /// Construct an error wrapping an underlying cause.
141    pub fn with_source(
142        code: ErrorCode,
143        message: impl Into<String>,
144        source: impl Error + Send + Sync + 'static,
145    ) -> Self {
146        Self {
147            code,
148            message: message.into(),
149            validation: None,
150            source: Some(Arc::new(source)),
151        }
152    }
153
154    /// Convert validation issues to a Cordis error.
155    pub fn validation(issues: impl IntoIterator<Item = ValidationIssue>) -> Self {
156        let validation = ValidationError::new(issues);
157        Self {
158            code: ErrorCode::InvalidConfig,
159            message: validation.to_string(),
160            validation: Some(validation),
161            source: None,
162        }
163    }
164
165    /// Return the stable error code.
166    pub const fn code(&self) -> ErrorCode {
167        self.code
168    }
169
170    /// Return validation details when this is an invalid-config error.
171    pub fn validation_error(&self) -> Option<&ValidationError> {
172        self.validation.as_ref()
173    }
174
175    /// Attach context to an existing error while preserving its code.
176    pub fn context(mut self, context: impl AsRef<str>) -> Self {
177        self.message = format!("{}: {}", context.as_ref(), self.message);
178        self
179    }
180
181    /// Attach an underlying cause while preserving code and message.
182    pub fn caused_by(mut self, source: impl Error + Send + Sync + 'static) -> Self {
183        self.source = Some(Arc::new(source));
184        self
185    }
186}
187
188impl Display for CordisError {
189    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
190        f.write_str(&self.message)
191    }
192}
193
194impl Error for CordisError {
195    fn source(&self) -> Option<&(dyn Error + 'static)> {
196        self.source
197            .as_deref()
198            .map(|source| source as &(dyn Error + 'static))
199    }
200}
201
202impl From<ValidationError> for CordisError {
203    fn from(value: ValidationError) -> Self {
204        Self {
205            code: ErrorCode::InvalidConfig,
206            message: value.to_string(),
207            validation: Some(value),
208            source: None,
209        }
210    }
211}
212
213impl From<String> for CordisError {
214    fn from(value: String) -> Self {
215        Self::with_message(ErrorCode::Other, value)
216    }
217}
218
219impl From<&str> for CordisError {
220    fn from(value: &str) -> Self {
221        Self::with_message(ErrorCode::Other, value)
222    }
223}
224
225/// Framework result alias.
226pub type Result<T, E = CordisError> = std::result::Result<T, E>;
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn source_chain_survives_construction_and_clone() {
234        let cause = std::io::Error::other("disk gone");
235        let error = CordisError::with_source(ErrorCode::Plugin, "plugin failed", cause);
236        let source = Error::source(&error).expect("source recorded");
237        assert_eq!(source.to_string(), "disk gone");
238        assert_eq!(error.code(), ErrorCode::Plugin);
239        assert_eq!(error.to_string(), "plugin failed");
240
241        let cloned = error.clone();
242        assert_eq!(
243            Error::source(&cloned).map(ToString::to_string).as_deref(),
244            Some("disk gone")
245        );
246
247        let attached = CordisError::new(ErrorCode::Event).caused_by(std::io::Error::other("inner"));
248        assert_eq!(
249            Error::source(&attached).map(ToString::to_string).as_deref(),
250            Some("inner")
251        );
252    }
253}