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    /// Data name this error is about. Populated by the data-binding site so consumers can attribute
18    /// the error to a specific input field without string parsing. Displayed as "Failed to parse data 'X':".
19    pub related_data: Option<String>,
20}
21
22/// Classification of an [`Error`]. Serialized as the `kind` field on the flat object returned to JavaScript from WASM (`engine/src/wasm.rs`, `JsError`).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ErrorKind {
26    Parsing,
27    Validation,
28    Inversion,
29    Registry,
30    Request,
31    ResourceLimit,
32}
33
34/// Error types for the Lemma system with source location tracking
35#[derive(Debug, Clone)]
36pub enum Error {
37    /// Parse error with source location
38    Parsing(Box<ErrorDetails>),
39
40    /// Inversion error (valid Lemma, but unsupported by inversion) with source location
41    Inversion(Box<ErrorDetails>),
42
43    /// Validation error (semantic/planning, including circular dependency) with source location
44    Validation(Box<ErrorDetails>),
45
46    /// Registry resolution error with source location and structured error kind.
47    ///
48    /// Produced when an `@...` reference cannot be resolved by the configured Registry
49    /// (e.g. the spec was not found, the request was unauthorized, or the network
50    /// is unreachable).
51    Registry {
52        details: Box<ErrorDetails>,
53        /// The `@...` identifier that failed to resolve (includes the leading `@`).
54        identifier: String,
55        /// The category of failure.
56        kind: RegistryErrorKind,
57    },
58
59    /// Resource limit exceeded
60    ResourceLimitExceeded {
61        details: Box<ErrorDetails>,
62        limit_name: String,
63        limit_value: String,
64        actual_value: String,
65    },
66
67    /// Request error: invalid or unsatisfiable API request (e.g. spec not found, invalid parameters).
68    /// Not a parse/planning failure; the request itself is invalid. Such errors occur *before* any evaluation and *never during* evaluation.
69    Request {
70        details: Box<ErrorDetails>,
71        kind: RequestErrorKind,
72    },
73}
74
75/// Distinguishes HTTP 404 (not found) from 400 (bad request) for request errors.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum RequestErrorKind {
78    /// Spec not found or no temporal version for effective — map to 404.
79    SpecNotFound,
80    /// Rule not found
81    RuleNotFound,
82    /// Invalid spec id, etc. — map to 400.
83    InvalidRequest,
84}
85
86impl Error {
87    /// Create a parse error. Source is required: parsing errors always originate from source code.
88    pub fn parsing(
89        message: impl Into<String>,
90        source: Source,
91        suggestion: Option<impl Into<String>>,
92    ) -> Self {
93        Self::parsing_with_context(message, source, suggestion, None, None)
94    }
95
96    /// Parse error with optional spec context (for display).
97    pub fn parsing_with_context(
98        message: impl Into<String>,
99        source: Source,
100        suggestion: Option<impl Into<String>>,
101        spec_context: Option<Arc<LemmaSpec>>,
102        related_spec: Option<Arc<LemmaSpec>>,
103    ) -> Self {
104        Self::Parsing(Box::new(ErrorDetails {
105            message: message.into(),
106            source: Some(source),
107            suggestion: suggestion.map(Into::into),
108            spec_context,
109            related_spec,
110            related_data: None,
111        }))
112    }
113
114    /// Create a parse error with suggestion. Source is required.
115    pub fn parsing_with_suggestion(
116        message: impl Into<String>,
117        source: Source,
118        suggestion: impl Into<String>,
119    ) -> Self {
120        Self::parsing_with_context(message, source, Some(suggestion), None, None)
121    }
122
123    /// Create an inversion error with source information.
124    pub fn inversion(
125        message: impl Into<String>,
126        source: Option<Source>,
127        suggestion: Option<impl Into<String>>,
128    ) -> Self {
129        Self::inversion_with_context(message, source, suggestion, None, None)
130    }
131
132    /// Inversion error with optional spec context (for display).
133    pub fn inversion_with_context(
134        message: impl Into<String>,
135        source: Option<Source>,
136        suggestion: Option<impl Into<String>>,
137        spec_context: Option<Arc<LemmaSpec>>,
138        related_spec: Option<Arc<LemmaSpec>>,
139    ) -> Self {
140        Self::Inversion(Box::new(ErrorDetails {
141            message: message.into(),
142            source,
143            suggestion: suggestion.map(Into::into),
144            spec_context,
145            related_spec,
146            related_data: None,
147        }))
148    }
149
150    /// Create an inversion error with suggestion
151    pub fn inversion_with_suggestion(
152        message: impl Into<String>,
153        source: Option<Source>,
154        suggestion: impl Into<String>,
155        spec_context: Option<Arc<LemmaSpec>>,
156        related_spec: Option<Arc<LemmaSpec>>,
157    ) -> Self {
158        Self::inversion_with_context(
159            message,
160            source,
161            Some(suggestion),
162            spec_context,
163            related_spec,
164        )
165    }
166
167    /// Create a validation error with source information (semantic/planning, including circular dependency).
168    pub fn validation(
169        message: impl Into<String>,
170        source: Option<Source>,
171        suggestion: Option<impl Into<String>>,
172    ) -> Self {
173        Self::validation_with_context(message, source, suggestion, None, None)
174    }
175
176    /// Validation error with optional spec context and related spec (for display).
177    pub fn validation_with_context(
178        message: impl Into<String>,
179        source: Option<Source>,
180        suggestion: Option<impl Into<String>>,
181        spec_context: Option<Arc<LemmaSpec>>,
182        related_spec: Option<Arc<LemmaSpec>>,
183    ) -> Self {
184        Self::Validation(Box::new(ErrorDetails {
185            message: message.into(),
186            source,
187            suggestion: suggestion.map(Into::into),
188            spec_context,
189            related_spec,
190            related_data: None,
191        }))
192    }
193
194    /// Create a request error (invalid API request, e.g. bad spec id).
195    /// Request errors never have source locations — they are API-level.
196    pub fn request(message: impl Into<String>, suggestion: Option<impl Into<String>>) -> Self {
197        Self::request_with_kind(message, suggestion, RequestErrorKind::InvalidRequest)
198    }
199
200    /// Create a "spec not found" request error — map to HTTP 404.
201    pub fn request_not_found(
202        message: impl Into<String>,
203        suggestion: Option<impl Into<String>>,
204    ) -> Self {
205        Self::request_with_kind(message, suggestion, RequestErrorKind::SpecNotFound)
206    }
207
208    /// Create a rule not found error
209    pub fn rule_not_found(rule_name: &str, suggestion: Option<impl Into<String>>) -> Self {
210        Self::request_with_kind(
211            format!("Rule '{}' not found", rule_name),
212            suggestion,
213            RequestErrorKind::RuleNotFound,
214        )
215    }
216
217    fn request_with_kind(
218        message: impl Into<String>,
219        suggestion: Option<impl Into<String>>,
220        kind: RequestErrorKind,
221    ) -> Self {
222        Self::Request {
223            details: Box::new(ErrorDetails {
224                message: message.into(),
225                source: None,
226                suggestion: suggestion.map(Into::into),
227                spec_context: None,
228                related_spec: None,
229                related_data: None,
230            }),
231            kind,
232        }
233    }
234
235    /// Create a resource-limit-exceeded error with optional source location and spec context.
236    pub fn resource_limit_exceeded(
237        limit_name: impl Into<String>,
238        limit_value: impl Into<String>,
239        actual_value: impl Into<String>,
240        suggestion: impl Into<String>,
241        source: Option<Source>,
242        spec_context: Option<Arc<LemmaSpec>>,
243        related_spec: Option<Arc<LemmaSpec>>,
244    ) -> Self {
245        let limit_name = limit_name.into();
246        let limit_value = limit_value.into();
247        let actual_value = actual_value.into();
248        let message = format!("{limit_name} (limit: {limit_value}, actual: {actual_value})");
249        Self::ResourceLimitExceeded {
250            details: Box::new(ErrorDetails {
251                message,
252                source,
253                suggestion: Some(suggestion.into()),
254                spec_context,
255                related_spec,
256                related_data: None,
257            }),
258            limit_name,
259            limit_value,
260            actual_value,
261        }
262    }
263
264    /// Create a registry error. Source is required: registry errors point to `@ref` in source.
265    pub fn registry(
266        message: impl Into<String>,
267        source: Source,
268        identifier: impl Into<String>,
269        kind: RegistryErrorKind,
270        suggestion: Option<impl Into<String>>,
271        spec_context: Option<Arc<LemmaSpec>>,
272        related_spec: Option<Arc<LemmaSpec>>,
273    ) -> Self {
274        Self::Registry {
275            details: Box::new(ErrorDetails {
276                message: message.into(),
277                source: Some(source),
278                suggestion: suggestion.map(Into::into),
279                spec_context,
280                related_spec,
281                related_data: None,
282            }),
283            identifier: identifier.into(),
284            kind,
285        }
286    }
287
288    /// Attach spec context for display grouping. Returns a new Error with context set.
289    pub fn with_spec_context(self, spec: Arc<LemmaSpec>) -> Self {
290        self.map_details(|d| d.spec_context = Some(spec))
291    }
292
293    /// Attach a data-binding attribution. Returns a new Error carrying the data name.
294    /// Consumers (WASM `JsError`, LSP, HTTP) can read this via [`Error::related_data`] to attribute
295    /// the failure to a specific input field without parsing strings.
296    pub fn with_related_data(self, name: impl Into<String>) -> Self {
297        let name = name.into();
298        self.map_details(|d| d.related_data = Some(name))
299    }
300
301    /// Apply a mutator to the inner [`ErrorDetails`] regardless of variant.
302    fn map_details(self, f: impl FnOnce(&mut ErrorDetails)) -> Self {
303        match self {
304            Error::Parsing(details) => {
305                let mut d = *details;
306                f(&mut d);
307                Error::Parsing(Box::new(d))
308            }
309            Error::Inversion(details) => {
310                let mut d = *details;
311                f(&mut d);
312                Error::Inversion(Box::new(d))
313            }
314            Error::Validation(details) => {
315                let mut d = *details;
316                f(&mut d);
317                Error::Validation(Box::new(d))
318            }
319            Error::Registry {
320                details,
321                identifier,
322                kind,
323            } => {
324                let mut d = *details;
325                f(&mut d);
326                Error::Registry {
327                    details: Box::new(d),
328                    identifier,
329                    kind,
330                }
331            }
332            Error::ResourceLimitExceeded {
333                details,
334                limit_name,
335                limit_value,
336                actual_value,
337            } => {
338                let mut d = *details;
339                f(&mut d);
340                Error::ResourceLimitExceeded {
341                    details: Box::new(d),
342                    limit_name,
343                    limit_value,
344                    actual_value,
345                }
346            }
347            Error::Request { details, kind } => {
348                let mut d = *details;
349                f(&mut d);
350                Error::Request {
351                    details: Box::new(d),
352                    kind,
353                }
354            }
355        }
356    }
357}
358
359fn format_related_spec(spec: &LemmaSpec) -> String {
360    let effective_from_str = spec
361        .effective_from()
362        .map(|d| d.to_string())
363        .unwrap_or_else(|| "beginning".to_string());
364    format!(
365        "See spec '{}' (effective from {}).",
366        spec.name, effective_from_str
367    )
368}
369
370fn write_source_location(f: &mut fmt::Formatter<'_>, source: &Option<Source>) -> fmt::Result {
371    if let Some(src) = source {
372        write!(
373            f,
374            " at {}:{}:{}",
375            src.attribute, src.span.line, src.span.col
376        )
377    } else {
378        Ok(())
379    }
380}
381
382fn write_related_spec(f: &mut fmt::Formatter<'_>, details: &ErrorDetails) -> fmt::Result {
383    if let Some(ref related) = details.related_spec {
384        write!(f, " {}", format_related_spec(related))?;
385    }
386    Ok(())
387}
388
389fn write_spec_context(f: &mut fmt::Formatter<'_>, spec: &LemmaSpec) -> fmt::Result {
390    write!(f, "In spec '{}': ", spec.name)
391}
392
393impl fmt::Display for Error {
394    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
395        match self {
396            Error::Parsing(details) => {
397                if let Some(ref spec) = details.spec_context {
398                    write_spec_context(f, spec)?;
399                }
400                write!(f, "Parse error: {}", details.message)?;
401                if let Some(suggestion) = &details.suggestion {
402                    write!(f, " (suggestion: {suggestion})")?;
403                }
404                write_related_spec(f, details)?;
405                write_source_location(f, &details.source)
406            }
407            Error::Inversion(details) => {
408                if let Some(ref spec) = details.spec_context {
409                    write_spec_context(f, spec)?;
410                }
411                write!(f, "Inversion error: {}", details.message)?;
412                if let Some(suggestion) = &details.suggestion {
413                    write!(f, " (suggestion: {suggestion})")?;
414                }
415                write_related_spec(f, details)?;
416                write_source_location(f, &details.source)
417            }
418            Error::Validation(details) => {
419                if let Some(ref spec) = details.spec_context {
420                    write_spec_context(f, spec)?;
421                }
422                write!(f, "Validation error: ")?;
423                if let Some(ref name) = details.related_data {
424                    write!(f, "Failed to parse data '{}': ", name)?;
425                }
426                write!(f, "{}", details.message)?;
427                if let Some(suggestion) = &details.suggestion {
428                    write!(f, " (suggestion: {suggestion})")?;
429                }
430                write_related_spec(f, details)?;
431                write_source_location(f, &details.source)
432            }
433            Error::Registry {
434                details,
435                identifier,
436                kind,
437            } => {
438                if let Some(ref spec) = details.spec_context {
439                    write_spec_context(f, spec)?;
440                }
441                write!(
442                    f,
443                    "Registry error ({}): {}: {}",
444                    kind, identifier, details.message
445                )?;
446                if let Some(suggestion) = &details.suggestion {
447                    write!(f, " (suggestion: {suggestion})")?;
448                }
449                write_related_spec(f, details)?;
450                write_source_location(f, &details.source)
451            }
452            Error::ResourceLimitExceeded {
453                details,
454                limit_name,
455                limit_value,
456                actual_value,
457            } => {
458                if let Some(ref spec) = details.spec_context {
459                    write_spec_context(f, spec)?;
460                }
461                write!(
462                    f,
463                    "Resource limit exceeded: {limit_name} (limit: {limit_value}, actual: {actual_value})"
464                )?;
465                if let Some(suggestion) = &details.suggestion {
466                    write!(f, ". {suggestion}")?;
467                }
468                write_source_location(f, &details.source)
469            }
470            Error::Request { details, .. } => {
471                if let Some(ref spec) = details.spec_context {
472                    write_spec_context(f, spec)?;
473                }
474                write!(f, "Request error: {}", details.message)?;
475                if let Some(suggestion) = &details.suggestion {
476                    write!(f, " (suggestion: {suggestion})")?;
477                }
478                write_related_spec(f, details)?;
479                write_source_location(f, &details.source)
480            }
481        }
482    }
483}
484
485impl std::error::Error for Error {}
486
487impl From<std::fmt::Error> for Error {
488    fn from(err: std::fmt::Error) -> Self {
489        Error::validation(format!("Format error: {err}"), None, None::<String>)
490    }
491}
492
493impl Error {
494    /// Classify this error. Used by FFI/WASM consumers that need to branch on error category
495    /// without depending on internal variant shapes.
496    pub fn kind(&self) -> ErrorKind {
497        match self {
498            Error::Parsing(_) => ErrorKind::Parsing,
499            Error::Validation(_) => ErrorKind::Validation,
500            Error::Inversion(_) => ErrorKind::Inversion,
501            Error::Registry { .. } => ErrorKind::Registry,
502            Error::Request { .. } => ErrorKind::Request,
503            Error::ResourceLimitExceeded { .. } => ErrorKind::ResourceLimit,
504        }
505    }
506
507    /// Shared access to the inner [`ErrorDetails`] regardless of variant.
508    fn details(&self) -> &ErrorDetails {
509        match self {
510            Error::Parsing(d) | Error::Inversion(d) | Error::Validation(d) => d,
511            Error::Registry { details, .. }
512            | Error::ResourceLimitExceeded { details, .. }
513            | Error::Request { details, .. } => details,
514        }
515    }
516
517    /// Get the error message.
518    pub fn message(&self) -> &str {
519        &self.details().message
520    }
521
522    /// Get the source location if available.
523    pub fn location(&self) -> Option<&Source> {
524        self.details().source.as_ref()
525    }
526
527    /// Alias for [`Error::location`]. Preferred name when building the WASM/JS error payload.
528    pub fn source_location(&self) -> Option<&Source> {
529        self.location()
530    }
531
532    /// Resolve source text from the sources map (for display). Source no longer stores text.
533    pub fn source_text(
534        &self,
535        sources: &std::collections::HashMap<String, String>,
536    ) -> Option<String> {
537        self.location()
538            .and_then(|s| s.text_from(sources).map(|c| c.into_owned()))
539    }
540
541    /// Get the suggestion if available.
542    pub fn suggestion(&self) -> Option<&str> {
543        self.details().suggestion.as_deref()
544    }
545
546    /// Data name this error is attributed to (set at the data-binding call site).
547    pub fn related_data(&self) -> Option<&str> {
548        self.details().related_data.as_deref()
549    }
550
551    /// Name of the spec being planned when the error occurred.
552    pub fn spec(&self) -> Option<&str> {
553        self.details()
554            .spec_context
555            .as_ref()
556            .map(|s| s.name.as_str())
557    }
558
559    /// Name of a related spec referenced by this error (e.g. a transitive dependency).
560    pub fn related_spec(&self) -> Option<&str> {
561        self.details()
562            .related_spec
563            .as_ref()
564            .map(|s| s.name.as_str())
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::parsing::ast::Span;
572
573    fn test_source() -> Source {
574        Source::new(
575            "test.lemma",
576            Span {
577                start: 14,
578                end: 21,
579                line: 1,
580                col: 15,
581            },
582        )
583    }
584
585    #[test]
586    fn test_error_creation_and_display() {
587        let parse_error = Error::parsing("Invalid currency", test_source(), None::<String>);
588        let parse_error_display = format!("{parse_error}");
589        assert!(parse_error_display.contains("Parse error: Invalid currency"));
590        assert!(parse_error_display.contains("test.lemma:1:15"));
591
592        let suggestion_source = Source::new(
593            "suggestion.lemma",
594            Span {
595                start: 5,
596                end: 10,
597                line: 1,
598                col: 6,
599            },
600        );
601
602        let parse_error_with_suggestion = Error::parsing_with_suggestion(
603            "Typo in data name",
604            suggestion_source,
605            "Did you mean 'amount'?",
606        );
607        let parse_error_with_suggestion_display = format!("{parse_error_with_suggestion}");
608        assert!(parse_error_with_suggestion_display.contains("Typo in data name"));
609        assert!(parse_error_with_suggestion_display.contains("Did you mean 'amount'?"));
610
611        let engine_error = Error::validation("Something went wrong", None, None::<String>);
612        assert!(format!("{engine_error}").contains("Validation error: Something went wrong"));
613        assert!(!format!("{engine_error}").contains(" at "));
614
615        let validation_error =
616            Error::validation("Circular dependency: a -> b -> a", None, None::<String>);
617        assert!(format!("{validation_error}")
618            .contains("Validation error: Circular dependency: a -> b -> a"));
619    }
620
621    #[test]
622    fn test_error_kind_accessor() {
623        assert_eq!(
624            Error::parsing("x", test_source(), None::<String>).kind(),
625            ErrorKind::Parsing
626        );
627        assert_eq!(
628            Error::validation("x", None, None::<String>).kind(),
629            ErrorKind::Validation
630        );
631        assert_eq!(
632            Error::inversion("x", None, None::<String>).kind(),
633            ErrorKind::Inversion
634        );
635        assert_eq!(
636            Error::request("x", None::<String>).kind(),
637            ErrorKind::Request
638        );
639        assert_eq!(
640            Error::resource_limit_exceeded("cap", "1", "2", "try less", None, None, None).kind(),
641            ErrorKind::ResourceLimit
642        );
643    }
644
645    #[test]
646    fn test_related_data_attribution_and_display() {
647        let err = Error::validation(
648            "Unknown unit 'mete' for this scale type",
649            Some(test_source()),
650            None::<String>,
651        )
652        .with_related_data("bridge_height");
653
654        assert_eq!(err.related_data(), Some("bridge_height"));
655        assert_eq!(err.kind(), ErrorKind::Validation);
656        assert_eq!(err.message(), "Unknown unit 'mete' for this scale type");
657
658        let display = format!("{err}");
659        assert!(
660            display.contains(
661                "Validation error: Failed to parse data 'bridge_height': Unknown unit 'mete'"
662            ),
663            "unexpected display: {display}"
664        );
665
666        let at_occurrences = display.matches(" at ").count();
667        assert_eq!(
668            at_occurrences, 1,
669            "expected exactly one ` at ` in display, got {at_occurrences}: {display}"
670        );
671    }
672
673    #[test]
674    fn test_related_data_none_by_default() {
675        let err = Error::validation("x", None, None::<String>);
676        assert!(err.related_data().is_none());
677        assert!(err.spec().is_none());
678        assert!(err.related_spec().is_none());
679    }
680
681    #[test]
682    fn test_related_data_builder_preserves_other_variants() {
683        let err = Error::resource_limit_exceeded(
684            "max_data_value_bytes",
685            "100",
686            "200",
687            "reduce size",
688            Some(test_source()),
689            None,
690            None,
691        )
692        .with_related_data("big_blob");
693
694        assert_eq!(err.kind(), ErrorKind::ResourceLimit);
695        assert_eq!(err.related_data(), Some("big_blob"));
696    }
697}