Skip to main content

lemma/
error.rs

1use crate::parsing::ast::{EffectiveDate, LemmaSpec};
2use crate::parsing::source::Source;
3use crate::registry::RegistryErrorKind;
4use std::fmt;
5
6/// Detailed error information with optional source location.
7#[derive(Debug, Clone)]
8pub struct ErrorDetails {
9    pub message: String,
10    pub source: Option<Source>,
11    pub suggestion: Option<String>,
12    /// Spec we were planning when this error occurred. Used for display grouping ("In spec 'X':").
13    pub spec_context_name: Option<String>,
14    pub spec_context_effective_from: Option<EffectiveDate>,
15    /// When the cause involves a referenced spec, that temporal version. Displayed as "See spec 'X' (active from Y)."
16    pub related_spec_name: Option<String>,
17    pub related_spec_effective_from: Option<EffectiveDate>,
18    /// Data name this error is about. Populated by the data-binding site so consumers can attribute
19    /// the error to a specific input field without string parsing. Displayed as "Failed to parse data 'X':".
20    pub related_data: Option<String>,
21}
22
23fn attribution_fields(spec: Option<&LemmaSpec>) -> (Option<String>, Option<EffectiveDate>) {
24    match spec {
25        Some(s) => (Some(s.name.clone()), Some(s.effective_from.clone())),
26        None => (None, None),
27    }
28}
29
30/// Classification of an [`Error`]. Serialized as the `kind` field on the flat object returned to JavaScript from WASM (`engine/src/wasm.rs`, `JsError`).
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ErrorKind {
34    Parsing,
35    Validation,
36    Inversion,
37    Registry,
38    MissingRepository,
39    Request,
40    ResourceLimit,
41}
42
43/// Error types for the Lemma system with source location tracking
44#[derive(Debug, Clone)]
45pub enum Error {
46    /// Parse error with source location
47    Parsing(Box<ErrorDetails>),
48
49    /// Inversion error (valid Lemma, but unsupported by inversion) with source location
50    Inversion(Box<ErrorDetails>),
51
52    /// Validation error (semantic/planning, including circular dependency) with source location
53    Validation(Box<ErrorDetails>),
54
55    /// Registry resolution error with source location and structured error kind.
56    ///
57    /// Produced when an `@...` reference cannot be resolved by the configured Registry
58    /// (e.g. the spec was not found, the request was unauthorized, or the network
59    /// is unreachable).
60    Registry {
61        details: Box<ErrorDetails>,
62        /// The `@...` identifier that failed to resolve (includes the leading `@`).
63        identifier: String,
64        /// The category of failure.
65        kind: RegistryErrorKind,
66    },
67
68    /// A referenced repository is not present in the context (not loaded / not fetched).
69    ///
70    /// Produced during planning when a `uses @repository ...` reference names a repository
71    /// qualifier that has not been added to the workspace.
72    MissingRepository {
73        details: Box<ErrorDetails>,
74        /// Full repository qualifier as written (e.g. `"@iso/countries"`).
75        repository: String,
76    },
77
78    /// Resource limit exceeded
79    ResourceLimitExceeded {
80        details: Box<ErrorDetails>,
81        limit_name: String,
82        limit_value: String,
83        actual_value: String,
84    },
85
86    /// Request error: invalid or unsatisfiable API request (e.g. spec not found, invalid parameters).
87    /// Not a parse/planning failure; the request itself is invalid. Such errors occur *before* any evaluation and *never during* evaluation.
88    Request {
89        details: Box<ErrorDetails>,
90        kind: RequestErrorKind,
91    },
92}
93
94/// Distinguishes HTTP 404 (not found) from 400 (bad request) for request errors.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
96#[serde(rename_all = "snake_case")]
97pub enum RequestErrorKind {
98    /// Spec not found or no temporal version for effective — map to 404.
99    SpecNotFound,
100    /// Rule not found
101    RuleNotFound,
102    /// Invalid spec id, etc. — map to 400.
103    InvalidRequest,
104}
105
106impl Error {
107    /// Create a parse error. Source is required: parsing errors always originate from source code.
108    pub fn parsing(
109        message: impl Into<String>,
110        source: Source,
111        suggestion: Option<impl Into<String>>,
112    ) -> Self {
113        Self::parsing_with_context(message, source, suggestion, None, None)
114    }
115
116    /// Parse error with optional spec context (for display).
117    pub fn parsing_with_context(
118        message: impl Into<String>,
119        source: Source,
120        suggestion: Option<impl Into<String>>,
121        spec_context: Option<&LemmaSpec>,
122        related_spec: Option<&LemmaSpec>,
123    ) -> Self {
124        let (spec_context_name, spec_context_effective_from) = attribution_fields(spec_context);
125        let (related_spec_name, related_spec_effective_from) = attribution_fields(related_spec);
126        Self::Parsing(Box::new(ErrorDetails {
127            message: message.into(),
128            source: Some(source),
129            suggestion: suggestion.map(Into::into),
130            spec_context_name,
131            spec_context_effective_from,
132            related_spec_name,
133            related_spec_effective_from,
134            related_data: None,
135        }))
136    }
137
138    /// Create a parse error with suggestion. Source is required.
139    pub fn parsing_with_suggestion(
140        message: impl Into<String>,
141        source: Source,
142        suggestion: impl Into<String>,
143    ) -> Self {
144        Self::parsing_with_context(message, source, Some(suggestion), None, None)
145    }
146
147    /// Create an inversion error with source information.
148    pub fn inversion(
149        message: impl Into<String>,
150        source: Option<Source>,
151        suggestion: Option<impl Into<String>>,
152    ) -> Self {
153        Self::inversion_with_context(message, source, suggestion, None, None)
154    }
155
156    /// Inversion error with optional spec context (for display).
157    pub fn inversion_with_context(
158        message: impl Into<String>,
159        source: Option<Source>,
160        suggestion: Option<impl Into<String>>,
161        spec_context: Option<&LemmaSpec>,
162        related_spec: Option<&LemmaSpec>,
163    ) -> Self {
164        let (spec_context_name, spec_context_effective_from) = attribution_fields(spec_context);
165        let (related_spec_name, related_spec_effective_from) = attribution_fields(related_spec);
166        Self::Inversion(Box::new(ErrorDetails {
167            message: message.into(),
168            source,
169            suggestion: suggestion.map(Into::into),
170            spec_context_name,
171            spec_context_effective_from,
172            related_spec_name,
173            related_spec_effective_from,
174            related_data: None,
175        }))
176    }
177
178    /// Create an inversion error with suggestion
179    pub fn inversion_with_suggestion(
180        message: impl Into<String>,
181        source: Option<Source>,
182        suggestion: impl Into<String>,
183        spec_context: Option<&LemmaSpec>,
184        related_spec: Option<&LemmaSpec>,
185    ) -> Self {
186        Self::inversion_with_context(
187            message,
188            source,
189            Some(suggestion),
190            spec_context,
191            related_spec,
192        )
193    }
194
195    /// Create a validation error with source information (semantic/planning, including circular dependency).
196    pub fn validation(
197        message: impl Into<String>,
198        source: Option<Source>,
199        suggestion: Option<impl Into<String>>,
200    ) -> Self {
201        Self::validation_with_context(message, source, suggestion, None, None)
202    }
203
204    /// Validation error with optional spec context and related spec (for display).
205    pub fn validation_with_context(
206        message: impl Into<String>,
207        source: Option<Source>,
208        suggestion: Option<impl Into<String>>,
209        spec_context: Option<&LemmaSpec>,
210        related_spec: Option<&LemmaSpec>,
211    ) -> Self {
212        let (spec_context_name, spec_context_effective_from) = attribution_fields(spec_context);
213        let (related_spec_name, related_spec_effective_from) = attribution_fields(related_spec);
214        Self::Validation(Box::new(ErrorDetails {
215            message: message.into(),
216            source,
217            suggestion: suggestion.map(Into::into),
218            spec_context_name,
219            spec_context_effective_from,
220            related_spec_name,
221            related_spec_effective_from,
222            related_data: None,
223        }))
224    }
225
226    /// Create a request error (invalid API request, e.g. bad spec id).
227    /// Request errors never have source locations — they are API-level.
228    pub fn request(message: impl Into<String>, suggestion: Option<impl Into<String>>) -> Self {
229        Self::request_with_kind(message, suggestion, RequestErrorKind::InvalidRequest)
230    }
231
232    /// Create a "spec not found" request error — map to HTTP 404.
233    pub fn request_not_found(
234        message: impl Into<String>,
235        suggestion: Option<impl Into<String>>,
236    ) -> Self {
237        Self::request_with_kind(message, suggestion, RequestErrorKind::SpecNotFound)
238    }
239
240    /// Create a rule not found error
241    pub fn rule_not_found(rule_name: &str, suggestion: Option<impl Into<String>>) -> Self {
242        Self::request_with_kind(
243            format!("Rule '{}' not found", rule_name),
244            suggestion,
245            RequestErrorKind::RuleNotFound,
246        )
247    }
248
249    fn request_with_kind(
250        message: impl Into<String>,
251        suggestion: Option<impl Into<String>>,
252        kind: RequestErrorKind,
253    ) -> Self {
254        Self::Request {
255            details: Box::new(ErrorDetails {
256                message: message.into(),
257                source: None,
258                suggestion: suggestion.map(Into::into),
259                spec_context_name: None,
260                spec_context_effective_from: None,
261                related_spec_name: None,
262                related_spec_effective_from: None,
263                related_data: None,
264            }),
265            kind,
266        }
267    }
268
269    /// Create a resource-limit-exceeded error with optional source location and spec context.
270    pub fn resource_limit_exceeded(
271        limit_name: impl Into<String>,
272        limit_value: impl Into<String>,
273        actual_value: impl Into<String>,
274        suggestion: impl Into<String>,
275        source: Option<Source>,
276        spec_context: Option<&LemmaSpec>,
277        related_spec: Option<&LemmaSpec>,
278    ) -> Self {
279        let limit_name = limit_name.into();
280        let limit_value = limit_value.into();
281        let actual_value = actual_value.into();
282        let message = format!("{limit_name} (limit: {limit_value}, actual: {actual_value})");
283        let (spec_context_name, spec_context_effective_from) = attribution_fields(spec_context);
284        let (related_spec_name, related_spec_effective_from) = attribution_fields(related_spec);
285        Self::ResourceLimitExceeded {
286            details: Box::new(ErrorDetails {
287                message,
288                source,
289                suggestion: Some(suggestion.into()),
290                spec_context_name,
291                spec_context_effective_from,
292                related_spec_name,
293                related_spec_effective_from,
294                related_data: None,
295            }),
296            limit_name,
297            limit_value,
298            actual_value,
299        }
300    }
301
302    /// Create a registry error. Source is required: registry errors point to `@ref` in source.
303    pub fn registry(
304        message: impl Into<String>,
305        source: Source,
306        identifier: impl Into<String>,
307        kind: RegistryErrorKind,
308        suggestion: Option<impl Into<String>>,
309        spec_context: Option<&LemmaSpec>,
310        related_spec: Option<&LemmaSpec>,
311    ) -> Self {
312        let (spec_context_name, spec_context_effective_from) = attribution_fields(spec_context);
313        let (related_spec_name, related_spec_effective_from) = attribution_fields(related_spec);
314        Self::Registry {
315            details: Box::new(ErrorDetails {
316                message: message.into(),
317                source: Some(source),
318                suggestion: suggestion.map(Into::into),
319                spec_context_name,
320                spec_context_effective_from,
321                related_spec_name,
322                related_spec_effective_from,
323                related_data: None,
324            }),
325            identifier: identifier.into(),
326            kind,
327        }
328    }
329
330    /// Repository referenced in source is not loaded in the context.
331    pub fn missing_repository(
332        message: impl Into<String>,
333        source: Option<Source>,
334        repository: impl Into<String>,
335        suggestion: Option<impl Into<String>>,
336        spec_context: Option<&LemmaSpec>,
337    ) -> Self {
338        let (spec_context_name, spec_context_effective_from) = attribution_fields(spec_context);
339        Self::MissingRepository {
340            details: Box::new(ErrorDetails {
341                message: message.into(),
342                source,
343                suggestion: suggestion.map(Into::into),
344                spec_context_name,
345                spec_context_effective_from,
346                related_spec_name: None,
347                related_spec_effective_from: None,
348                related_data: None,
349            }),
350            repository: repository.into(),
351        }
352    }
353
354    /// Attach spec context for display grouping. Returns a new Error with context set.
355    pub fn with_spec_context(self, spec: &LemmaSpec) -> Self {
356        self.map_details(|d| {
357            d.spec_context_name = Some(spec.name.clone());
358            d.spec_context_effective_from = Some(spec.effective_from.clone());
359        })
360    }
361
362    /// Attach a data-binding attribution. Returns a new Error carrying the data name.
363    /// Consumers (WASM `JsError`, LSP, HTTP) can read this via [`Error::related_data`] to attribute
364    /// the failure to a specific input field without parsing strings.
365    pub fn with_related_data(self, name: impl Into<String>) -> Self {
366        let name = name.into();
367        self.map_details(|d| d.related_data = Some(name))
368    }
369
370    /// Apply a mutator to the inner [`ErrorDetails`] regardless of variant.
371    fn map_details(self, f: impl FnOnce(&mut ErrorDetails)) -> Self {
372        match self {
373            Error::Parsing(details) => {
374                let mut d = *details;
375                f(&mut d);
376                Error::Parsing(Box::new(d))
377            }
378            Error::Inversion(details) => {
379                let mut d = *details;
380                f(&mut d);
381                Error::Inversion(Box::new(d))
382            }
383            Error::Validation(details) => {
384                let mut d = *details;
385                f(&mut d);
386                Error::Validation(Box::new(d))
387            }
388            Error::Registry {
389                details,
390                identifier,
391                kind,
392            } => {
393                let mut d = *details;
394                f(&mut d);
395                Error::Registry {
396                    details: Box::new(d),
397                    identifier,
398                    kind,
399                }
400            }
401            Error::MissingRepository {
402                details,
403                repository,
404            } => {
405                let mut d = *details;
406                f(&mut d);
407                Error::MissingRepository {
408                    details: Box::new(d),
409                    repository,
410                }
411            }
412            Error::ResourceLimitExceeded {
413                details,
414                limit_name,
415                limit_value,
416                actual_value,
417            } => {
418                let mut d = *details;
419                f(&mut d);
420                Error::ResourceLimitExceeded {
421                    details: Box::new(d),
422                    limit_name,
423                    limit_value,
424                    actual_value,
425                }
426            }
427            Error::Request { details, kind } => {
428                let mut d = *details;
429                f(&mut d);
430                Error::Request {
431                    details: Box::new(d),
432                    kind,
433                }
434            }
435        }
436    }
437}
438
439fn format_related_spec(name: &str, effective_from: &EffectiveDate) -> String {
440    let effective_from_str = effective_from
441        .as_ref()
442        .map(|d| d.to_string())
443        .unwrap_or_else(|| "beginning".to_string());
444    format!(
445        "See spec '{}' (effective from {}).",
446        name, effective_from_str
447    )
448}
449
450fn write_source_location(f: &mut fmt::Formatter<'_>, source: &Option<Source>) -> fmt::Result {
451    if let Some(src) = source {
452        write!(
453            f,
454            " at {}:{}:{}",
455            src.source_type, src.span.line, src.span.col
456        )
457    } else {
458        Ok(())
459    }
460}
461
462fn write_related_spec(f: &mut fmt::Formatter<'_>, details: &ErrorDetails) -> fmt::Result {
463    if let Some(ref name) = details.related_spec_name {
464        let effective = details
465            .related_spec_effective_from
466            .as_ref()
467            .expect("BUG: related_spec_name set without related_spec_effective_from");
468        write!(f, " {}", format_related_spec(name, effective))?;
469    }
470    Ok(())
471}
472
473fn write_spec_context(f: &mut fmt::Formatter<'_>, name: &str) -> fmt::Result {
474    write!(f, "In spec '{}': ", name)
475}
476
477impl fmt::Display for Error {
478    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
479        match self {
480            Error::Parsing(details) => {
481                if let Some(ref name) = details.spec_context_name {
482                    write_spec_context(f, name)?;
483                }
484                write!(f, "Parse error: {}", details.message)?;
485                if let Some(suggestion) = &details.suggestion {
486                    write!(f, " (suggestion: {suggestion})")?;
487                }
488                write_related_spec(f, details)?;
489                write_source_location(f, &details.source)
490            }
491            Error::Inversion(details) => {
492                if let Some(ref name) = details.spec_context_name {
493                    write_spec_context(f, name)?;
494                }
495                write!(f, "Inversion error: {}", details.message)?;
496                if let Some(suggestion) = &details.suggestion {
497                    write!(f, " (suggestion: {suggestion})")?;
498                }
499                write_related_spec(f, details)?;
500                write_source_location(f, &details.source)
501            }
502            Error::Validation(details) => {
503                if let Some(ref name) = details.spec_context_name {
504                    write_spec_context(f, name)?;
505                }
506                write!(f, "Validation error: ")?;
507                if let Some(ref name) = details.related_data {
508                    write!(f, "Failed to parse data '{}': ", name)?;
509                }
510                write!(f, "{}", details.message)?;
511                if let Some(suggestion) = &details.suggestion {
512                    write!(f, " (suggestion: {suggestion})")?;
513                }
514                write_related_spec(f, details)?;
515                write_source_location(f, &details.source)
516            }
517            Error::Registry {
518                details,
519                identifier,
520                kind,
521            } => {
522                if let Some(ref name) = details.spec_context_name {
523                    write_spec_context(f, name)?;
524                }
525                write!(
526                    f,
527                    "Registry error ({}): {}: {}",
528                    kind, identifier, details.message
529                )?;
530                if let Some(suggestion) = &details.suggestion {
531                    write!(f, " (suggestion: {suggestion})")?;
532                }
533                write_related_spec(f, details)?;
534                write_source_location(f, &details.source)
535            }
536            Error::MissingRepository {
537                details,
538                repository,
539            } => {
540                if let Some(ref name) = details.spec_context_name {
541                    write_spec_context(f, name)?;
542                }
543                write!(f, "Missing repository: {}: {}", repository, details.message)?;
544                if let Some(suggestion) = &details.suggestion {
545                    write!(f, " (suggestion: {suggestion})")?;
546                }
547                write_related_spec(f, details)?;
548                write_source_location(f, &details.source)
549            }
550            Error::ResourceLimitExceeded {
551                details,
552                limit_name,
553                limit_value,
554                actual_value,
555            } => {
556                if let Some(ref name) = details.spec_context_name {
557                    write_spec_context(f, name)?;
558                }
559                write!(
560                    f,
561                    "Resource limit exceeded: {limit_name} (limit: {limit_value}, actual: {actual_value})"
562                )?;
563                if let Some(suggestion) = &details.suggestion {
564                    write!(f, ". {suggestion}")?;
565                }
566                write_source_location(f, &details.source)
567            }
568            Error::Request { details, .. } => {
569                if let Some(ref name) = details.spec_context_name {
570                    write_spec_context(f, name)?;
571                }
572                write!(f, "Request error: {}", details.message)?;
573                if let Some(suggestion) = &details.suggestion {
574                    write!(f, " (suggestion: {suggestion})")?;
575                }
576                write_related_spec(f, details)?;
577                write_source_location(f, &details.source)
578            }
579        }
580    }
581}
582
583impl std::error::Error for Error {}
584
585impl From<std::fmt::Error> for Error {
586    fn from(err: std::fmt::Error) -> Self {
587        Error::validation(format!("Format error: {err}"), None, None::<String>)
588    }
589}
590
591impl Error {
592    /// Classify this error. Used by FFI/WASM consumers that need to branch on error category
593    /// without depending on internal variant shapes.
594    pub fn kind(&self) -> ErrorKind {
595        match self {
596            Error::Parsing(_) => ErrorKind::Parsing,
597            Error::Validation(_) => ErrorKind::Validation,
598            Error::Inversion(_) => ErrorKind::Inversion,
599            Error::Registry { .. } => ErrorKind::Registry,
600            Error::MissingRepository { .. } => ErrorKind::MissingRepository,
601            Error::Request { .. } => ErrorKind::Request,
602            Error::ResourceLimitExceeded { .. } => ErrorKind::ResourceLimit,
603        }
604    }
605
606    /// Shared access to the inner [`ErrorDetails`] regardless of variant.
607    pub(crate) fn details(&self) -> &ErrorDetails {
608        match self {
609            Error::Parsing(d) | Error::Inversion(d) | Error::Validation(d) => d,
610            Error::Registry { details, .. }
611            | Error::MissingRepository { details, .. }
612            | Error::ResourceLimitExceeded { details, .. }
613            | Error::Request { details, .. } => details,
614        }
615    }
616
617    /// Repository identifier when the error is about a missing repository or a registry fetch target.
618    ///
619    /// Populated for [`Error::MissingRepository`] and [`Error::Registry`] (`identifier`).
620    #[must_use]
621    pub fn repository(&self) -> Option<&str> {
622        match self {
623            Error::MissingRepository { repository, .. } => Some(repository.as_str()),
624            Error::Registry { identifier, .. } => Some(identifier.as_str()),
625            _ => None,
626        }
627    }
628
629    /// Get the error message.
630    pub fn message(&self) -> &str {
631        &self.details().message
632    }
633
634    /// Get the source location if available.
635    pub fn location(&self) -> Option<&Source> {
636        self.details().source.as_ref()
637    }
638
639    /// Alias for [`Error::location`]. Preferred name when building the WASM/JS error payload.
640    pub fn source_location(&self) -> Option<&Source> {
641        self.location()
642    }
643
644    /// Resolve source text from the sources map (for display). Source no longer stores text.
645    pub fn source_text(
646        &self,
647        sources: &std::collections::HashMap<crate::parsing::source::SourceType, String>,
648    ) -> Option<String> {
649        self.location()
650            .and_then(|s| s.text_from(sources).map(|c| c.into_owned()))
651    }
652
653    /// Get the suggestion if available.
654    pub fn suggestion(&self) -> Option<&str> {
655        self.details().suggestion.as_deref()
656    }
657
658    /// Data name this error is attributed to (set at the data-binding call site).
659    pub fn related_data(&self) -> Option<&str> {
660        self.details().related_data.as_deref()
661    }
662
663    /// Spec name when the error is attributed to a planning/eval context.
664    pub fn spec_context_name(&self) -> Option<&str> {
665        self.details().spec_context_name.as_deref()
666    }
667
668    /// Name of a related spec referenced by this error (e.g. a transitive dependency).
669    pub fn related_spec(&self) -> Option<&str> {
670        self.details().related_spec_name.as_deref()
671    }
672
673    /// Registry failure sub-kind, populated only for [`Error::Registry`].
674    #[must_use]
675    pub fn registry_kind(&self) -> Option<RegistryErrorKind> {
676        match self {
677            Error::Registry { kind, .. } => Some(*kind),
678            _ => None,
679        }
680    }
681
682    /// Request failure sub-kind, populated only for [`Error::Request`].
683    #[must_use]
684    pub fn request_kind(&self) -> Option<RequestErrorKind> {
685        match self {
686            Error::Request { kind, .. } => Some(*kind),
687            _ => None,
688        }
689    }
690
691    /// Name of the exceeded resource limit, populated only for [`Error::ResourceLimitExceeded`].
692    #[must_use]
693    pub fn limit_name(&self) -> Option<&str> {
694        match self {
695            Error::ResourceLimitExceeded { limit_name, .. } => Some(limit_name.as_str()),
696            _ => None,
697        }
698    }
699
700    /// Configured value of the exceeded resource limit, populated only for
701    /// [`Error::ResourceLimitExceeded`].
702    #[must_use]
703    pub fn limit_value(&self) -> Option<&str> {
704        match self {
705            Error::ResourceLimitExceeded { limit_value, .. } => Some(limit_value.as_str()),
706            _ => None,
707        }
708    }
709
710    /// Actual value that exceeded the resource limit, populated only for
711    /// [`Error::ResourceLimitExceeded`].
712    #[must_use]
713    pub fn actual_value(&self) -> Option<&str> {
714        match self {
715            Error::ResourceLimitExceeded { actual_value, .. } => Some(actual_value.as_str()),
716            _ => None,
717        }
718    }
719}
720
721/// Source location attached to an [`EngineError`]. Line and column are 1-based;
722/// `length` is the UTF-8 byte length of the offending span.
723#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
724pub struct EngineErrorSource {
725    pub attribute: String,
726    pub line: usize,
727    pub column: usize,
728    pub length: usize,
729}
730
731impl From<&Source> for EngineErrorSource {
732    fn from(source: &Source) -> Self {
733        Self {
734            attribute: source.source_type.to_string(),
735            line: source.span.line,
736            column: source.span.col,
737            length: source.span.end.saturating_sub(source.span.start),
738        }
739    }
740}
741
742/// Flat wire view of [`Error`] matching `EngineError` in `engine/schemas/api.v1.json`
743/// and `engine/packages/npm/lemma.d.ts`. Missing optionals serialize as JSON `null`.
744#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
745pub struct EngineError {
746    pub kind: ErrorKind,
747    pub message: String,
748    pub related_data: Option<String>,
749    pub spec: Option<String>,
750    pub related_spec: Option<String>,
751    pub source: Option<EngineErrorSource>,
752    pub suggestion: Option<String>,
753    /// Set for [`Error::MissingRepository`] and [`Error::Registry`] (registry `@` id).
754    pub repository: Option<String>,
755    /// Set only for [`Error::Registry`].
756    pub registry_kind: Option<RegistryErrorKind>,
757    /// Set only for [`Error::Request`].
758    pub request_kind: Option<RequestErrorKind>,
759    /// Set only for [`Error::ResourceLimitExceeded`].
760    pub limit_name: Option<String>,
761    pub limit_value: Option<String>,
762    pub actual_value: Option<String>,
763}
764
765impl From<&Error> for EngineError {
766    fn from(error: &Error) -> Self {
767        Self {
768            kind: error.kind(),
769            message: error.message().to_string(),
770            related_data: error.related_data().map(str::to_string),
771            spec: error.spec_context_name().map(str::to_string),
772            related_spec: error.related_spec().map(str::to_string),
773            source: error.source_location().map(EngineErrorSource::from),
774            suggestion: error.suggestion().map(str::to_string),
775            repository: error.repository().map(str::to_string),
776            registry_kind: error.registry_kind(),
777            request_kind: error.request_kind(),
778            limit_name: error.limit_name().map(str::to_string),
779            limit_value: error.limit_value().map(str::to_string),
780            actual_value: error.actual_value().map(str::to_string),
781        }
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788    use crate::parsing::ast::Span;
789
790    fn test_source() -> Source {
791        Source::new(
792            crate::parsing::source::SourceType::Path(std::sync::Arc::new(
793                std::path::PathBuf::from("test.lemma"),
794            )),
795            Span {
796                start: 14,
797                end: 21,
798                line: 1,
799                col: 15,
800            },
801        )
802    }
803
804    #[test]
805    fn test_error_creation_and_display() {
806        let parse_error = Error::parsing("Invalid currency", test_source(), None::<String>);
807        let parse_error_display = format!("{parse_error}");
808        assert!(parse_error_display.contains("Parse error: Invalid currency"));
809        assert!(parse_error_display.contains("test.lemma:1:15"));
810
811        let suggestion_source = Source::new(
812            crate::parsing::source::SourceType::Volatile,
813            Span {
814                start: 5,
815                end: 10,
816                line: 2,
817                col: 3,
818            },
819        );
820        let suggestion_error =
821            Error::parsing_with_suggestion("typo", suggestion_source, "did you mean X?");
822        assert!(format!("{suggestion_error}").contains("suggestion: did you mean X?"));
823    }
824
825    #[test]
826    fn test_request_error_accessors() {
827        let err = Error::request("bad id", Some("use a valid id"));
828        assert_eq!(err.kind(), ErrorKind::Request);
829        assert_eq!(err.message(), "bad id");
830        assert!(err.location().is_none());
831        assert_eq!(err.suggestion(), Some("use a valid id"));
832        assert!(err.spec_context_name().is_none());
833        assert!(err.related_spec().is_none());
834    }
835
836    #[test]
837    fn test_missing_repository_display() {
838        let err = Error::missing_repository(
839            "not loaded",
840            None,
841            "@iso/countries",
842            Some("load the dependency first"),
843            None,
844        );
845        let display = format!("{err}");
846        assert!(display.contains("Missing repository"));
847        assert!(display.contains("@iso/countries"));
848        assert!(display.contains("not loaded"));
849    }
850
851    #[test]
852    fn test_with_spec_context_copies_name() {
853        let spec = LemmaSpec::new("pricing".to_string());
854        let err = Error::validation("bad", None, None::<String>).with_spec_context(&spec);
855        assert_eq!(err.spec_context_name(), Some("pricing"));
856        let display = format!("{err}");
857        assert!(display.contains("In spec 'pricing':"));
858    }
859}