Skip to main content

lemma/
error.rs

1use crate::parsing::ast::LemmaSpec;
2use crate::parsing::source::Source;
3use crate::registry::RegistryErrorKind;
4use std::fmt;
5use std::sync::Arc;
6
7/// Detailed error information with optional source location.
8#[derive(Debug, Clone)]
9pub struct ErrorDetails {
10    pub message: String,
11    pub source: Option<Source>,
12    pub suggestion: Option<String>,
13    /// Spec we were planning when this error occurred. Used for display grouping ("In spec 'X':").
14    pub spec_context: Option<Arc<LemmaSpec>>,
15    /// When the cause involves a referenced spec, that temporal version. Displayed as "See spec 'X' (active from Y)."
16    pub related_spec: Option<Arc<LemmaSpec>>,
17}
18
19/// Error types for the Lemma system with source location tracking
20#[derive(Debug, Clone)]
21pub enum Error {
22    /// Parse error with source location
23    Parsing(Box<ErrorDetails>),
24
25    /// Inversion error (valid Lemma, but unsupported by inversion) with source location
26    Inversion(Box<ErrorDetails>),
27
28    /// Validation error (semantic/planning, including circular dependency) with source location
29    Validation(Box<ErrorDetails>),
30
31    /// Registry resolution error with source location and structured error kind.
32    ///
33    /// Produced when an `@...` reference cannot be resolved by the configured Registry
34    /// (e.g. the spec was not found, the request was unauthorized, or the network
35    /// is unreachable).
36    Registry {
37        details: Box<ErrorDetails>,
38        /// The `@...` identifier that failed to resolve (includes the leading `@`).
39        identifier: String,
40        /// The category of failure.
41        kind: RegistryErrorKind,
42    },
43
44    /// Resource limit exceeded
45    ResourceLimitExceeded {
46        details: Box<ErrorDetails>,
47        limit_name: String,
48        limit_value: String,
49        actual_value: String,
50    },
51
52    /// Request error: invalid or unsatisfiable API request (e.g. spec not found, invalid parameters).
53    /// Not a parse/planning failure; the request itself is invalid. Such errors occur *before* any evaluation and *never during* evaluation.
54    Request {
55        details: Box<ErrorDetails>,
56        kind: RequestErrorKind,
57    },
58}
59
60/// Distinguishes HTTP 404 (not found) from 400 (bad request) for request errors.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum RequestErrorKind {
63    /// Spec not found, no temporal version for effective, or plan hash pin not found — map to 404.
64    SpecNotFound,
65    /// Invalid spec id, etc. — map to 400.
66    InvalidRequest,
67}
68
69impl Error {
70    /// Create a parse error. Source is required: parsing errors always originate from source code.
71    pub fn parsing(
72        message: impl Into<String>,
73        source: Source,
74        suggestion: Option<impl Into<String>>,
75    ) -> Self {
76        Self::parsing_with_context(message, source, suggestion, None, None)
77    }
78
79    /// Parse error with optional spec context (for display).
80    pub fn parsing_with_context(
81        message: impl Into<String>,
82        source: Source,
83        suggestion: Option<impl Into<String>>,
84        spec_context: Option<Arc<LemmaSpec>>,
85        related_spec: Option<Arc<LemmaSpec>>,
86    ) -> Self {
87        Self::Parsing(Box::new(ErrorDetails {
88            message: message.into(),
89            source: Some(source),
90            suggestion: suggestion.map(Into::into),
91            spec_context,
92            related_spec,
93        }))
94    }
95
96    /// Create a parse error with suggestion. Source is required.
97    pub fn parsing_with_suggestion(
98        message: impl Into<String>,
99        source: Source,
100        suggestion: impl Into<String>,
101    ) -> Self {
102        Self::parsing_with_context(message, source, Some(suggestion), None, None)
103    }
104
105    /// Create an inversion error with source information.
106    pub fn inversion(
107        message: impl Into<String>,
108        source: Option<Source>,
109        suggestion: Option<impl Into<String>>,
110    ) -> Self {
111        Self::inversion_with_context(message, source, suggestion, None, None)
112    }
113
114    /// Inversion error with optional spec context (for display).
115    pub fn inversion_with_context(
116        message: impl Into<String>,
117        source: Option<Source>,
118        suggestion: Option<impl Into<String>>,
119        spec_context: Option<Arc<LemmaSpec>>,
120        related_spec: Option<Arc<LemmaSpec>>,
121    ) -> Self {
122        Self::Inversion(Box::new(ErrorDetails {
123            message: message.into(),
124            source,
125            suggestion: suggestion.map(Into::into),
126            spec_context,
127            related_spec,
128        }))
129    }
130
131    /// Create an inversion error with suggestion
132    pub fn inversion_with_suggestion(
133        message: impl Into<String>,
134        source: Option<Source>,
135        suggestion: impl Into<String>,
136        spec_context: Option<Arc<LemmaSpec>>,
137        related_spec: Option<Arc<LemmaSpec>>,
138    ) -> Self {
139        Self::inversion_with_context(
140            message,
141            source,
142            Some(suggestion),
143            spec_context,
144            related_spec,
145        )
146    }
147
148    /// Create a validation error with source information (semantic/planning, including circular dependency).
149    pub fn validation(
150        message: impl Into<String>,
151        source: Option<Source>,
152        suggestion: Option<impl Into<String>>,
153    ) -> Self {
154        Self::validation_with_context(message, source, suggestion, None, None)
155    }
156
157    /// Validation error with optional spec context and related spec (for display).
158    pub fn validation_with_context(
159        message: impl Into<String>,
160        source: Option<Source>,
161        suggestion: Option<impl Into<String>>,
162        spec_context: Option<Arc<LemmaSpec>>,
163        related_spec: Option<Arc<LemmaSpec>>,
164    ) -> Self {
165        Self::Validation(Box::new(ErrorDetails {
166            message: message.into(),
167            source,
168            suggestion: suggestion.map(Into::into),
169            spec_context,
170            related_spec,
171        }))
172    }
173
174    /// Create a request error (invalid API request, e.g. bad spec id).
175    /// Request errors never have source locations — they are API-level.
176    pub fn request(message: impl Into<String>, suggestion: Option<impl Into<String>>) -> Self {
177        Self::request_with_kind(message, suggestion, RequestErrorKind::InvalidRequest)
178    }
179
180    /// Create a "spec not found" request error — map to HTTP 404.
181    pub fn request_not_found(
182        message: impl Into<String>,
183        suggestion: Option<impl Into<String>>,
184    ) -> Self {
185        Self::request_with_kind(message, suggestion, RequestErrorKind::SpecNotFound)
186    }
187
188    fn request_with_kind(
189        message: impl Into<String>,
190        suggestion: Option<impl Into<String>>,
191        kind: RequestErrorKind,
192    ) -> Self {
193        Self::Request {
194            details: Box::new(ErrorDetails {
195                message: message.into(),
196                source: None,
197                suggestion: suggestion.map(Into::into),
198                spec_context: None,
199                related_spec: None,
200            }),
201            kind,
202        }
203    }
204
205    /// Create a resource-limit-exceeded error with optional source location and spec context.
206    pub fn resource_limit_exceeded(
207        limit_name: impl Into<String>,
208        limit_value: impl Into<String>,
209        actual_value: impl Into<String>,
210        suggestion: impl Into<String>,
211        source: Option<Source>,
212        spec_context: Option<Arc<LemmaSpec>>,
213        related_spec: Option<Arc<LemmaSpec>>,
214    ) -> Self {
215        let limit_name = limit_name.into();
216        let limit_value = limit_value.into();
217        let actual_value = actual_value.into();
218        let message = format!("{limit_name} (limit: {limit_value}, actual: {actual_value})");
219        Self::ResourceLimitExceeded {
220            details: Box::new(ErrorDetails {
221                message,
222                source,
223                suggestion: Some(suggestion.into()),
224                spec_context,
225                related_spec,
226            }),
227            limit_name,
228            limit_value,
229            actual_value,
230        }
231    }
232
233    /// Create a registry error. Source is required: registry errors point to `@ref` in source.
234    pub fn registry(
235        message: impl Into<String>,
236        source: Source,
237        identifier: impl Into<String>,
238        kind: RegistryErrorKind,
239        suggestion: Option<impl Into<String>>,
240        spec_context: Option<Arc<LemmaSpec>>,
241        related_spec: Option<Arc<LemmaSpec>>,
242    ) -> Self {
243        Self::Registry {
244            details: Box::new(ErrorDetails {
245                message: message.into(),
246                source: Some(source),
247                suggestion: suggestion.map(Into::into),
248                spec_context,
249                related_spec,
250            }),
251            identifier: identifier.into(),
252            kind,
253        }
254    }
255
256    /// Attach spec context for display grouping. Returns a new Error with context set.
257    pub fn with_spec_context(self, spec: Arc<LemmaSpec>) -> Self {
258        match self {
259            Error::Parsing(details) => {
260                let mut d = *details;
261                d.spec_context = Some(spec.clone());
262                Error::Parsing(Box::new(d))
263            }
264            Error::Inversion(details) => {
265                let mut d = *details;
266                d.spec_context = Some(spec.clone());
267                Error::Inversion(Box::new(d))
268            }
269            Error::Validation(details) => {
270                let mut d = *details;
271                d.spec_context = Some(spec.clone());
272                Error::Validation(Box::new(d))
273            }
274            Error::Registry {
275                details,
276                identifier,
277                kind,
278            } => {
279                let mut d = *details;
280                d.spec_context = Some(spec.clone());
281                Error::Registry {
282                    details: Box::new(d),
283                    identifier,
284                    kind,
285                }
286            }
287            Error::ResourceLimitExceeded {
288                details,
289                limit_name,
290                limit_value,
291                actual_value,
292            } => {
293                let mut d = *details;
294                d.spec_context = Some(spec.clone());
295                Error::ResourceLimitExceeded {
296                    details: Box::new(d),
297                    limit_name,
298                    limit_value,
299                    actual_value,
300                }
301            }
302            Error::Request { details, kind } => {
303                let mut d = *details;
304                d.spec_context = Some(spec);
305                Error::Request {
306                    details: Box::new(d),
307                    kind,
308                }
309            }
310        }
311    }
312}
313
314fn format_related_spec(spec: &LemmaSpec) -> String {
315    let effective_from_str = spec
316        .effective_from()
317        .map(|d| d.to_string())
318        .unwrap_or_else(|| "beginning".to_string());
319    format!(
320        "See spec '{}' (effective from {}).",
321        spec.name, effective_from_str
322    )
323}
324
325fn write_source_location(f: &mut fmt::Formatter<'_>, source: &Option<Source>) -> fmt::Result {
326    if let Some(src) = source {
327        write!(
328            f,
329            " at {}:{}:{}",
330            src.attribute, src.span.line, src.span.col
331        )
332    } else {
333        Ok(())
334    }
335}
336
337fn write_related_spec(f: &mut fmt::Formatter<'_>, details: &ErrorDetails) -> fmt::Result {
338    if let Some(ref related) = details.related_spec {
339        write!(f, " {}", format_related_spec(related))?;
340    }
341    Ok(())
342}
343
344fn write_spec_context(f: &mut fmt::Formatter<'_>, spec: &LemmaSpec) -> fmt::Result {
345    write!(f, "In spec '{}': ", spec.name)
346}
347
348impl fmt::Display for Error {
349    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350        match self {
351            Error::Parsing(details) => {
352                if let Some(ref spec) = details.spec_context {
353                    write_spec_context(f, spec)?;
354                }
355                write!(f, "Parse error: {}", details.message)?;
356                if let Some(suggestion) = &details.suggestion {
357                    write!(f, " (suggestion: {suggestion})")?;
358                }
359                write_related_spec(f, details)?;
360                write_source_location(f, &details.source)
361            }
362            Error::Inversion(details) => {
363                if let Some(ref spec) = details.spec_context {
364                    write_spec_context(f, spec)?;
365                }
366                write!(f, "Inversion error: {}", details.message)?;
367                if let Some(suggestion) = &details.suggestion {
368                    write!(f, " (suggestion: {suggestion})")?;
369                }
370                write_related_spec(f, details)?;
371                write_source_location(f, &details.source)
372            }
373            Error::Validation(details) => {
374                if let Some(ref spec) = details.spec_context {
375                    write_spec_context(f, spec)?;
376                }
377                write!(f, "Validation error: {}", details.message)?;
378                if let Some(suggestion) = &details.suggestion {
379                    write!(f, " (suggestion: {suggestion})")?;
380                }
381                write_related_spec(f, details)?;
382                write_source_location(f, &details.source)
383            }
384            Error::Registry {
385                details,
386                identifier,
387                kind,
388            } => {
389                if let Some(ref spec) = details.spec_context {
390                    write_spec_context(f, spec)?;
391                }
392                write!(
393                    f,
394                    "Registry error ({}): {}: {}",
395                    kind, identifier, details.message
396                )?;
397                if let Some(suggestion) = &details.suggestion {
398                    write!(f, " (suggestion: {suggestion})")?;
399                }
400                write_related_spec(f, details)?;
401                write_source_location(f, &details.source)
402            }
403            Error::ResourceLimitExceeded {
404                details,
405                limit_name,
406                limit_value,
407                actual_value,
408            } => {
409                if let Some(ref spec) = details.spec_context {
410                    write_spec_context(f, spec)?;
411                }
412                write!(
413                    f,
414                    "Resource limit exceeded: {limit_name} (limit: {limit_value}, actual: {actual_value})"
415                )?;
416                if let Some(suggestion) = &details.suggestion {
417                    write!(f, ". {suggestion}")?;
418                }
419                write_source_location(f, &details.source)
420            }
421            Error::Request { details, .. } => {
422                if let Some(ref spec) = details.spec_context {
423                    write_spec_context(f, spec)?;
424                }
425                write!(f, "Request error: {}", details.message)?;
426                if let Some(suggestion) = &details.suggestion {
427                    write!(f, " (suggestion: {suggestion})")?;
428                }
429                write_related_spec(f, details)?;
430                write_source_location(f, &details.source)
431            }
432        }
433    }
434}
435
436impl std::error::Error for Error {}
437
438impl From<std::fmt::Error> for Error {
439    fn from(err: std::fmt::Error) -> Self {
440        Error::validation(format!("Format error: {err}"), None, None::<String>)
441    }
442}
443
444impl Error {
445    /// Get the error message.
446    pub fn message(&self) -> &str {
447        match self {
448            Error::Parsing(details)
449            | Error::Inversion(details)
450            | Error::Validation(details)
451            | Error::Request { details, .. } => &details.message,
452            Error::Registry { details, .. } | Error::ResourceLimitExceeded { details, .. } => {
453                &details.message
454            }
455        }
456    }
457
458    /// Get the source location if available
459    pub fn location(&self) -> Option<&Source> {
460        match self {
461            Error::Parsing(details)
462            | Error::Inversion(details)
463            | Error::Validation(details)
464            | Error::Request { details, .. } => details.source.as_ref(),
465            Error::Registry { details, .. } | Error::ResourceLimitExceeded { details, .. } => {
466                details.source.as_ref()
467            }
468        }
469    }
470
471    /// Resolve source text from the sources map (for display). Source no longer stores text.
472    pub fn source_text(
473        &self,
474        sources: &std::collections::HashMap<String, String>,
475    ) -> Option<String> {
476        self.location()
477            .and_then(|s| s.text_from(sources).map(|c| c.into_owned()))
478    }
479
480    /// Get the suggestion if available
481    pub fn suggestion(&self) -> Option<&str> {
482        match self {
483            Error::Parsing(details)
484            | Error::Inversion(details)
485            | Error::Validation(details)
486            | Error::Request { details, .. } => details.suggestion.as_deref(),
487            Error::Registry { details, .. } | Error::ResourceLimitExceeded { details, .. } => {
488                details.suggestion.as_deref()
489            }
490        }
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::parsing::ast::Span;
498
499    fn test_source() -> Source {
500        Source::new(
501            "test.lemma",
502            Span {
503                start: 14,
504                end: 21,
505                line: 1,
506                col: 15,
507            },
508        )
509    }
510
511    #[test]
512    fn test_error_creation_and_display() {
513        let parse_error = Error::parsing("Invalid currency", test_source(), None::<String>);
514        let parse_error_display = format!("{parse_error}");
515        assert!(parse_error_display.contains("Parse error: Invalid currency"));
516        assert!(parse_error_display.contains("test.lemma:1:15"));
517
518        let suggestion_source = Source::new(
519            "suggestion.lemma",
520            Span {
521                start: 5,
522                end: 10,
523                line: 1,
524                col: 6,
525            },
526        );
527
528        let parse_error_with_suggestion = Error::parsing_with_suggestion(
529            "Typo in fact name",
530            suggestion_source,
531            "Did you mean 'amount'?",
532        );
533        let parse_error_with_suggestion_display = format!("{parse_error_with_suggestion}");
534        assert!(parse_error_with_suggestion_display.contains("Typo in fact name"));
535        assert!(parse_error_with_suggestion_display.contains("Did you mean 'amount'?"));
536
537        let engine_error = Error::validation("Something went wrong", None, None::<String>);
538        assert!(format!("{engine_error}").contains("Validation error: Something went wrong"));
539        assert!(!format!("{engine_error}").contains(" at "));
540
541        let validation_error =
542            Error::validation("Circular dependency: a -> b -> a", None, None::<String>);
543        assert!(format!("{validation_error}")
544            .contains("Validation error: Circular dependency: a -> b -> a"));
545    }
546}