Skip to main content

powerio_core/
error.rs

1use std::fmt;
2
3use crate::{
4    Diagnostic, DiagnosticInfo, DiagnosticSeverity, ErrorCategory, Source, SourceSpan,
5    render_diagnostic,
6};
7
8type BoxedCause = Box<dyn std::error::Error + Send + Sync + 'static>;
9
10/// Failure to produce an operation's requested output.
11///
12/// Every value contains a registered error diagnostic. An underlying I/O or
13/// library error remains available through [`std::error::Error::source`].
14pub struct Error {
15    diagnostics: Vec<Diagnostic>,
16    cause: Option<BoxedCause>,
17    retained_source: Option<Source>,
18}
19
20impl Error {
21    /// Construct a failure from a registered code carrying a category.
22    ///
23    /// A code that ends an operation must declare a category, because the
24    /// category is what a binding and an exit status project the failure onto.
25    /// A code that does not is a registry defect: the finding keeps its own
26    /// code so its identity is not lost, and a `REQUEST.DIAGNOSTIC.MISSING_CATEGORY`
27    /// note records the defect. A debug build asserts instead, so the defect
28    /// surfaces in tests rather than in a released binding.
29    #[must_use]
30    pub fn new(info: &'static DiagnosticInfo, message: impl Into<String>) -> Self {
31        debug_assert!(
32            info.category.is_some(),
33            "{} ends an operation but declares no error category",
34            info.code
35        );
36        let mut diagnostics =
37            vec![Diagnostic::of(info, message).with_severity(DiagnosticSeverity::Error)];
38        if info.category.is_none() {
39            diagnostics.push(
40                Diagnostic::of(
41                    &crate::codes::REQUEST_DIAGNOSTIC_MISSING_CATEGORY,
42                    format!("{} declares no error category", info.code),
43                )
44                .with_severity(DiagnosticSeverity::Note),
45            );
46        }
47        Self {
48            diagnostics,
49            cause: None,
50            retained_source: None,
51        }
52    }
53
54    /// Add a diagnostic emitted before or while the operation failed.
55    #[must_use]
56    pub fn with_diagnostic(mut self, diagnostic: Diagnostic) -> Self {
57        self.diagnostics.push(diagnostic);
58        self
59    }
60
61    /// Add diagnostics without changing their order.
62    #[must_use]
63    pub fn with_diagnostics(mut self, diagnostics: impl IntoIterator<Item = Diagnostic>) -> Self {
64        self.diagnostics.extend(diagnostics);
65        self
66    }
67
68    /// Retain the implementation error that caused this operation failure.
69    #[must_use]
70    pub fn with_cause(mut self, cause: impl std::error::Error + Send + Sync + 'static) -> Self {
71        self.cause = Some(Box::new(cause));
72        self
73    }
74
75    /// Retain the shared input owner needed to interpret diagnostic spans.
76    #[must_use]
77    pub fn with_source(mut self, source: Source) -> Self {
78        self.retained_source = Some(source);
79        self
80    }
81
82    /// Attach the byte range of the record that ended the operation to the
83    /// failure's error diagnostic.
84    ///
85    /// The diagnostic keeps at most `limits::MAX_DIAGNOSTIC_SPANS` spans. A
86    /// span past that limit is not attached; the refusal is recorded as a
87    /// note so the omission stays visible.
88    #[must_use]
89    pub fn with_span(mut self, span: SourceSpan) -> Self {
90        let Some(position) = self
91            .diagnostics
92            .iter()
93            .position(|diagnostic| diagnostic.severity() == DiagnosticSeverity::Error)
94        else {
95            return self;
96        };
97        if let Err(refused) = self.diagnostics[position].add_span(span) {
98            self.diagnostics.extend(
99                refused
100                    .into_diagnostics()
101                    .into_iter()
102                    .map(|diagnostic| diagnostic.with_severity(DiagnosticSeverity::Note)),
103            );
104        }
105        self
106    }
107
108    #[must_use]
109    pub fn diagnostics(&self) -> &[Diagnostic] {
110        &self.diagnostics
111    }
112
113    /// The registered entry of the diagnostic that ended the operation, when
114    /// the failure was built from one.
115    #[must_use]
116    pub fn info(&self) -> Option<&'static crate::DiagnosticInfo> {
117        self.diagnostics
118            .first()
119            .and_then(Diagnostic::registered_info)
120    }
121
122    /// Coarse projection from the first registered error diagnostic.
123    #[must_use]
124    pub fn category(&self) -> ErrorCategory {
125        self.diagnostics
126            .iter()
127            .find(|diagnostic| diagnostic.severity() == DiagnosticSeverity::Error)
128            .and_then(Diagnostic::registered_info)
129            .and_then(|info| info.category)
130            .unwrap_or(ErrorCategory::Data)
131    }
132
133    #[must_use]
134    pub const fn retained_source(&self) -> Option<&Source> {
135        self.retained_source.as_ref()
136    }
137
138    #[must_use]
139    pub fn into_diagnostics(self) -> Vec<Diagnostic> {
140        self.diagnostics
141    }
142}
143
144impl fmt::Display for Error {
145    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
146        let diagnostic = self
147            .diagnostics
148            .iter()
149            .find(|diagnostic| diagnostic.severity() == DiagnosticSeverity::Error);
150        match diagnostic {
151            Some(diagnostic) => formatter.write_str(&render_diagnostic(diagnostic)),
152            None => formatter.write_str("PowerIO operation failed without a diagnostic"),
153        }
154    }
155}
156
157impl fmt::Debug for Error {
158    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
159        formatter
160            .debug_struct("Error")
161            .field("category", &self.category())
162            .field("diagnostics", &self.diagnostics)
163            .field("cause", &self.cause.as_ref().map(ToString::to_string))
164            .field("retained_source", &self.retained_source)
165            .finish()
166    }
167}
168
169impl std::error::Error for Error {
170    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
171        self.cause
172            .as_deref()
173            .map(|cause| cause as &(dyn std::error::Error + 'static))
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use std::error::Error as _;
180
181    use super::*;
182
183    #[test]
184    fn an_error_has_one_error_diagnostic_and_a_registered_category() {
185        let error = Error::new(
186            &crate::codes::VALIDATE_TIME_SERIES_SHAPE,
187            "two values for one point",
188        );
189        assert_eq!(error.category(), ErrorCategory::Data);
190        assert_eq!(error.diagnostics().len(), 1);
191        assert_eq!(error.diagnostics()[0].severity(), DiagnosticSeverity::Error);
192        assert!(
193            error
194                .to_string()
195                .starts_with("VALIDATE.TIME_SERIES.SHAPE: ")
196        );
197    }
198
199    #[test]
200    fn a_span_attaches_to_the_diagnostic_that_ended_the_operation() {
201        let source = crate::SourceId::new("/input").unwrap();
202        let error = Error::new(&crate::codes::READ_IO_READ, "read failed")
203            .with_diagnostic(
204                Diagnostic::of(&crate::codes::READ_IO_READ, "context")
205                    .with_severity(DiagnosticSeverity::Note),
206            )
207            .with_span(SourceSpan::new(source.clone(), 4, 9).unwrap());
208        let spans = error.diagnostics()[0].spans();
209        assert_eq!(spans.len(), 1);
210        assert_eq!(spans[0].source(), &source);
211        assert_eq!((spans[0].byte_start(), spans[0].byte_end()), (4, 9));
212        assert!(error.diagnostics()[1].spans().is_empty());
213
214        // Past the span limit the range is not attached and a note records
215        // the refusal, so nothing is dropped silently.
216        let mut crowded = Error::new(&crate::codes::READ_IO_READ, "read failed");
217        for _ in 0..crate::validation::MAX_DIAGNOSTIC_SPANS {
218            crowded = crowded.with_span(SourceSpan::new(source.clone(), 0, 1).unwrap());
219        }
220        let overflow = crowded.with_span(SourceSpan::new(source, 0, 1).unwrap());
221        assert_eq!(
222            overflow.diagnostics()[0].spans().len(),
223            crate::validation::MAX_DIAGNOSTIC_SPANS
224        );
225        assert_eq!(
226            overflow.diagnostics().last().unwrap().severity(),
227            DiagnosticSeverity::Note
228        );
229    }
230
231    #[test]
232    fn cause_and_shared_source_are_retained() {
233        let source = Source::from_memory("input.bin", vec![0, 255]).unwrap();
234        let byte_pointer = source.primary_buffer().unwrap().bytes().as_ptr();
235        let error = Error::new(&crate::codes::READ_IO_READ, "read failed")
236            .with_cause(std::io::Error::other("cause"))
237            .with_source(source);
238        assert_eq!(error.source().unwrap().to_string(), "cause");
239        assert_eq!(
240            error
241                .retained_source()
242                .unwrap()
243                .primary_buffer()
244                .unwrap()
245                .bytes()
246                .as_ptr(),
247            byte_pointer
248        );
249    }
250}