Skip to main content

lemma/
error.rs

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