Skip to main content

graphforge_core/
error_conversion.rs

1//! Recover GraphForge's structured errors at foreign error boundaries.
2
3use std::error::Error;
4
5use crate::GfError;
6
7impl GfError {
8    /// Preserve a GraphForge source when a planner wraps it in another error.
9    ///
10    /// Foreign failures without a GraphForge source retain the existing
11    /// `GF_PLAN` classification and diagnostic. Source traversal uses Rust's
12    /// error identity, never diagnostic text.
13    #[must_use]
14    pub fn from_plan_error(error: impl Error + 'static) -> Self {
15        Self::recover_source(&error, false).unwrap_or_else(|| Self::Plan(error.to_string()))
16    }
17
18    /// Preserve a GraphForge source when execution wraps it in another error.
19    ///
20    /// This also handles shared foreign errors: cloning the GraphForge value
21    /// retains its typed code, variant, message, and source span without needing
22    /// exclusive ownership of an upstream error. Foreign failures without a
23    /// GraphForge source retain `GF_EXECUTION` and their existing diagnostic.
24    #[must_use]
25    pub fn from_execution_error(error: impl Error + 'static) -> Self {
26        Self::recover_source(&error, true).unwrap_or_else(|| Self::Execution(error.to_string()))
27    }
28
29    fn recover_source(error: &(dyn Error + 'static), execution: bool) -> Option<Self> {
30        let mut source = Some(error);
31        while let Some(error) = source {
32            if let Some(original) = error.downcast_ref::<Self>() {
33                return Some(original.clone());
34            }
35            if let Some(original) = error.downcast_ref::<crate::LoweringError>() {
36                return Some(if execution {
37                    Self::LoweringExecution(original.clone())
38                } else {
39                    Self::Lowering(original.clone())
40                });
41            }
42            if let Some(original) = error.downcast_ref::<crate::AlgorithmError>() {
43                return Some(Self::Algorithm(original.clone()));
44            }
45            source = error.source();
46        }
47        None
48    }
49}
50
51impl From<crate::ParseError> for GfError {
52    fn from(error: crate::ParseError) -> Self {
53        if let crate::ParseErrorKind::UnsupportedFeature(feature) = error.kind {
54            return Self::NotImplemented(feature.description());
55        }
56        Self::Parse {
57            msg: error.message.clone(),
58            span: error.span,
59            diagnostic: Some(Box::new(error)),
60        }
61    }
62}
63
64impl From<crate::LoweringError> for GfError {
65    fn from(error: crate::LoweringError) -> Self {
66        Self::Lowering(error)
67    }
68}
69
70impl From<crate::AlgorithmError> for GfError {
71    fn from(error: crate::AlgorithmError) -> Self {
72        Self::Algorithm(error)
73    }
74}
75
76impl GfError {
77    /// Preserve the legacy EXPLAIN display while retaining the full parser diagnostic.
78    #[must_use]
79    pub fn from_parse_display(error: crate::ParseError) -> Self {
80        if let crate::ParseErrorKind::UnsupportedFeature(feature) = error.kind {
81            return Self::NotImplemented(feature.description());
82        }
83        Self::Parse {
84            msg: error.to_string(),
85            span: error.span,
86            diagnostic: Some(Box::new(error)),
87        }
88    }
89
90    /// Preserve legacy cypher EXPLAIN's planning domain and message.
91    #[must_use]
92    pub fn from_bind_plan_errors(errors: &[crate::BindError]) -> Self {
93        if errors
94            .iter()
95            .all(|error| matches!(error.kind, crate::BindErrorKind::UnsupportedFeature(_)))
96            && let Some(crate::BindError {
97                kind: crate::BindErrorKind::UnsupportedFeature(feature),
98                ..
99            }) = errors.first()
100        {
101            return Self::NotImplemented(feature.description());
102        }
103        Self::BindPlan {
104            msg: format!(
105                "bind errors: {}",
106                errors
107                    .iter()
108                    .map(|e| e.message.as_str())
109                    .collect::<Vec<_>>()
110                    .join("; ")
111            ),
112            diagnostics: errors.to_vec(),
113        }
114    }
115
116    /// Preserve every binder diagnostic and the established primary span/message.
117    #[must_use]
118    pub fn from_bind_errors(errors: &[crate::BindError]) -> Self {
119        if errors
120            .iter()
121            .all(|error| matches!(error.kind, crate::BindErrorKind::UnsupportedFeature(_)))
122            && let Some(crate::BindError {
123                kind: crate::BindErrorKind::UnsupportedFeature(feature),
124                ..
125            }) = errors.first()
126        {
127            return Self::NotImplemented(feature.description());
128        }
129        // This existing UUID validation policy is shared with parser clients.
130        if let Some(error) = errors
131            .iter()
132            .filter(|error| {
133                error.kind == crate::BindErrorKind::InvalidArgument
134                    && error.message.starts_with("typed UUID parameter `$")
135            })
136            .min_by_key(|error| (error.span.start, error.message.as_str()))
137        {
138            return Self::BindValidation {
139                msg: error.message.clone(),
140                diagnostics: errors.to_vec(),
141            };
142        }
143        Self::Bind {
144            msg: errors
145                .iter()
146                .map(|e| e.message.as_str())
147                .collect::<Vec<_>>()
148                .join("; "),
149            span: errors.first().map_or(crate::Span::default(), |e| e.span),
150            diagnostics: errors.to_vec(),
151        }
152    }
153}
154
155#[cfg(test)]
156mod stage_tests {
157    use super::*;
158    use crate::{BindError, BindErrorKind, LoweringError, ParseError, ParseErrorKind, Span};
159
160    #[test]
161    fn parser_kinds_payloads_and_messages_survive_both_presentations() {
162        let kinds = [
163            ParseErrorKind::UnexpectedChar,
164            ParseErrorKind::UnexpectedToken {
165                found: "token".into(),
166                expected: vec!["expression".into()],
167            },
168            ParseErrorKind::UnterminatedString,
169            ParseErrorKind::UnterminatedBlockComment,
170            ParseErrorKind::InvalidNumericLiteral,
171            ParseErrorKind::InvalidParameter,
172            ParseErrorKind::UnexpectedEof {
173                expected: vec!["expression".into()],
174            },
175        ];
176        for kind in kinds {
177            let original = ParseError::new(kind, Span::new(7, 13), "detailed source diagnostic");
178            for display in [false, true] {
179                let error = if display {
180                    GfError::from_parse_display(original.clone())
181                } else {
182                    original.clone().into()
183                };
184                assert_eq!(error.code(), "GF_PARSE");
185                let GfError::Parse {
186                    diagnostic,
187                    msg,
188                    span,
189                } = error
190                else {
191                    panic!("parser variant lost")
192                };
193                assert_eq!(*diagnostic.expect("typed parser diagnostic"), original);
194                assert_eq!(span, original.span);
195                assert_eq!(
196                    msg,
197                    if display {
198                        original.to_string()
199                    } else {
200                        original.message.clone()
201                    }
202                );
203            }
204        }
205    }
206
207    #[test]
208    fn every_binder_kind_and_span_survives_in_reported_order() {
209        use BindErrorKind as K;
210        let kinds = [
211            K::UnknownLabel,
212            K::UnknownRelationType,
213            K::UnknownProperty,
214            K::UndeclaredVariable,
215            K::DuplicateVariable,
216            K::AmbiguousProperty,
217            K::UnsupportedClause,
218            K::InvalidDeleteTarget,
219            K::VariableKindConflict,
220            K::VariableAlreadyBound,
221            K::InvalidArgument,
222            K::AmbiguousComposedSymbol,
223            K::CompositionConflict,
224        ];
225        let original: Vec<_> = kinds
226            .into_iter()
227            .enumerate()
228            .map(|(i, kind)| {
229                BindError::new(kind, Span::new(i * 2, i * 2 + 1), format!("diagnostic {i}"))
230            })
231            .collect();
232        let error = GfError::from_bind_errors(&original);
233        assert_eq!(error.code(), "GF_PARSE");
234        let GfError::Bind {
235            diagnostics,
236            msg,
237            span,
238        } = error
239        else {
240            panic!("binder variant lost")
241        };
242        assert_eq!(diagnostics, original);
243        assert_eq!(span, original[0].span);
244        assert_eq!(
245            msg,
246            original
247                .iter()
248                .map(|e| e.message.as_str())
249                .collect::<Vec<_>>()
250                .join("; ")
251        );
252    }
253
254    #[test]
255    fn uuid_validation_keeps_all_binder_diagnostics() {
256        let original = vec![
257            BindError::new(
258                BindErrorKind::UnknownLabel,
259                Span::new(0, 2),
260                "unknown label",
261            ),
262            BindError::new(
263                BindErrorKind::InvalidArgument,
264                Span::new(4, 9),
265                "typed UUID parameter `$id` is only supported as a direct node_uuid or edge_uuid identity equality predicate",
266            ),
267        ];
268        let error = GfError::from_bind_errors(&original);
269        assert_eq!(error.code(), "GF_VALIDATION");
270        let GfError::BindValidation { diagnostics, msg } = error else {
271            panic!("validation classification lost")
272        };
273        assert_eq!(diagnostics, original);
274        assert_eq!(msg, original[1].message);
275    }
276
277    #[test]
278    fn lowering_kind_and_runtime_domain_are_preserved() {
279        for original in [
280            LoweringError::UnknownFunction("fn".into()),
281            LoweringError::UnsupportedExpr("shape".into()),
282            LoweringError::UnboundVar(42),
283            LoweringError::InvalidType("predicate".into()),
284        ] {
285            let expected = if matches!(original, LoweringError::InvalidType(_)) {
286                "GF_VALIDATION"
287            } else {
288                "GF_PLAN"
289            };
290            let planning = GfError::from_plan_error(original.clone());
291            assert_eq!(planning.code(), expected);
292            assert!(matches!(planning, GfError::Lowering(ref e) if e == &original));
293            let execution = GfError::from_execution_error(original.clone());
294            assert_eq!(execution.code(), "GF_EXECUTION");
295            assert!(matches!(execution, GfError::LoweringExecution(ref e) if e == &original));
296        }
297    }
298}