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 added to the workspace.
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. Returns a new Error with context set.
386    pub fn with_spec_context(self, spec: &LemmaSpec) -> Self {
387        self.map_details(|d| {
388            d.spec_context_name = Some(spec.name.clone());
389            d.spec_context_effective_from = Some(spec.effective_from.clone());
390        })
391    }
392
393    /// Attach a data-binding attribution. Returns a new Error carrying the data name.
394    /// Consumers (WASM `JsError`, LSP, HTTP) can read this via [`Error::related_data`] to attribute
395    /// the failure to a specific input field without parsing strings.
396    pub fn with_related_data(self, name: impl Into<String>) -> Self {
397        let name = name.into();
398        self.map_details(|d| d.related_data = Some(name))
399    }
400
401    /// Apply a mutator to the inner [`ErrorDetails`] regardless of variant.
402    fn map_details(self, f: impl FnOnce(&mut ErrorDetails)) -> Self {
403        match self {
404            Error::Parsing(details) => {
405                let mut d = *details;
406                f(&mut d);
407                Error::Parsing(Box::new(d))
408            }
409            Error::Inversion(details) => {
410                let mut d = *details;
411                f(&mut d);
412                Error::Inversion(Box::new(d))
413            }
414            Error::Validation(details) => {
415                let mut d = *details;
416                f(&mut d);
417                Error::Validation(Box::new(d))
418            }
419            Error::Registry {
420                details,
421                identifier,
422                kind,
423            } => {
424                let mut d = *details;
425                f(&mut d);
426                Error::Registry {
427                    details: Box::new(d),
428                    identifier,
429                    kind,
430                }
431            }
432            Error::MissingRepository {
433                details,
434                repository,
435            } => {
436                let mut d = *details;
437                f(&mut d);
438                Error::MissingRepository {
439                    details: Box::new(d),
440                    repository,
441                }
442            }
443            Error::ResourceLimitExceeded {
444                details,
445                limit_name,
446                limit_value,
447                actual_value,
448            } => {
449                let mut d = *details;
450                f(&mut d);
451                Error::ResourceLimitExceeded {
452                    details: Box::new(d),
453                    limit_name,
454                    limit_value,
455                    actual_value,
456                }
457            }
458            Error::Request { details, kind } => {
459                let mut d = *details;
460                f(&mut d);
461                Error::Request {
462                    details: Box::new(d),
463                    kind,
464                }
465            }
466        }
467    }
468}
469
470fn format_related_spec(name: &str, effective_from: &EffectiveDate) -> String {
471    let effective_from_str = effective_from
472        .as_ref()
473        .map(|d| d.to_string())
474        .unwrap_or_else(|| "beginning".to_string());
475    format!(
476        "See spec '{}' (effective from {}).",
477        name, effective_from_str
478    )
479}
480
481fn write_source_location(f: &mut fmt::Formatter<'_>, source: &Option<Source>) -> fmt::Result {
482    if let Some(src) = source {
483        write!(
484            f,
485            " at {}:{}:{}",
486            src.source_type, src.span.line, src.span.col
487        )
488    } else {
489        Ok(())
490    }
491}
492
493fn write_related_spec(f: &mut fmt::Formatter<'_>, details: &ErrorDetails) -> fmt::Result {
494    if let Some(ref name) = details.related_spec_name {
495        let effective = details
496            .related_spec_effective_from
497            .as_ref()
498            .expect("BUG: related_spec_name set without related_spec_effective_from");
499        write!(f, " {}", format_related_spec(name, effective))?;
500    }
501    Ok(())
502}
503
504fn write_spec_context(f: &mut fmt::Formatter<'_>, name: &str) -> fmt::Result {
505    write!(f, "In spec '{}': ", name)
506}
507
508impl fmt::Display for Error {
509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510        match self {
511            Error::Parsing(details) => {
512                if let Some(ref name) = details.spec_context_name {
513                    write_spec_context(f, name)?;
514                }
515                write!(f, "Parse error: {}", details.message)?;
516                if let Some(suggestion) = &details.suggestion {
517                    write!(f, " (suggestion: {suggestion})")?;
518                }
519                write_related_spec(f, details)?;
520                write_source_location(f, &details.source)
521            }
522            Error::Inversion(details) => {
523                if let Some(ref name) = details.spec_context_name {
524                    write_spec_context(f, name)?;
525                }
526                write!(f, "Inversion error: {}", details.message)?;
527                if let Some(suggestion) = &details.suggestion {
528                    write!(f, " (suggestion: {suggestion})")?;
529                }
530                write_related_spec(f, details)?;
531                write_source_location(f, &details.source)
532            }
533            Error::Validation(details) => {
534                if let Some(ref name) = details.spec_context_name {
535                    write_spec_context(f, name)?;
536                }
537                write!(f, "Validation error: ")?;
538                if let Some(ref name) = details.related_data {
539                    write!(f, "Failed to parse data '{}': ", name)?;
540                }
541                write!(f, "{}", details.message)?;
542                if let Some(suggestion) = &details.suggestion {
543                    write!(f, " (suggestion: {suggestion})")?;
544                }
545                write_related_spec(f, details)?;
546                write_source_location(f, &details.source)
547            }
548            Error::Registry {
549                details,
550                identifier,
551                kind,
552            } => {
553                if let Some(ref name) = details.spec_context_name {
554                    write_spec_context(f, name)?;
555                }
556                write!(
557                    f,
558                    "Registry error ({}): {}: {}",
559                    kind, identifier, details.message
560                )?;
561                if let Some(suggestion) = &details.suggestion {
562                    write!(f, " (suggestion: {suggestion})")?;
563                }
564                write_related_spec(f, details)?;
565                write_source_location(f, &details.source)
566            }
567            Error::MissingRepository {
568                details,
569                repository,
570            } => {
571                if let Some(ref name) = details.spec_context_name {
572                    write_spec_context(f, name)?;
573                }
574                write!(f, "Missing repository: {}: {}", repository, details.message)?;
575                if let Some(suggestion) = &details.suggestion {
576                    write!(f, " (suggestion: {suggestion})")?;
577                }
578                write_related_spec(f, details)?;
579                write_source_location(f, &details.source)
580            }
581            Error::ResourceLimitExceeded {
582                details,
583                limit_name,
584                limit_value,
585                actual_value,
586            } => {
587                if let Some(ref name) = details.spec_context_name {
588                    write_spec_context(f, name)?;
589                }
590                write!(
591                    f,
592                    "Resource limit exceeded: {limit_name} (limit: {limit_value}, actual: {actual_value})"
593                )?;
594                if let Some(suggestion) = &details.suggestion {
595                    write!(f, ". {suggestion}")?;
596                }
597                write_source_location(f, &details.source)
598            }
599            Error::Request { details, .. } => {
600                if let Some(ref name) = details.spec_context_name {
601                    write_spec_context(f, name)?;
602                }
603                write!(f, "Request error: {}", details.message)?;
604                if let Some(suggestion) = &details.suggestion {
605                    write!(f, " (suggestion: {suggestion})")?;
606                }
607                write_related_spec(f, details)?;
608                write_source_location(f, &details.source)
609            }
610        }
611    }
612}
613
614impl std::error::Error for Error {}
615
616impl From<std::fmt::Error> for Error {
617    fn from(err: std::fmt::Error) -> Self {
618        Error::validation(format!("Format error: {err}"), None, None::<String>)
619    }
620}
621
622impl Error {
623    /// Classify this error. Used by FFI/WASM consumers that need to branch on error category
624    /// without depending on internal variant shapes.
625    pub fn kind(&self) -> ErrorKind {
626        match self {
627            Error::Parsing(_) => ErrorKind::Parsing,
628            Error::Validation(_) => ErrorKind::Validation,
629            Error::Inversion(_) => ErrorKind::Inversion,
630            Error::Registry { .. } => ErrorKind::Registry,
631            Error::MissingRepository { .. } => ErrorKind::MissingRepository,
632            Error::Request { .. } => ErrorKind::Request,
633            Error::ResourceLimitExceeded { .. } => ErrorKind::ResourceLimit,
634        }
635    }
636
637    /// Shared access to the inner [`ErrorDetails`] regardless of variant.
638    pub(crate) fn details(&self) -> &ErrorDetails {
639        match self {
640            Error::Parsing(d) | Error::Inversion(d) | Error::Validation(d) => d,
641            Error::Registry { details, .. }
642            | Error::MissingRepository { details, .. }
643            | Error::ResourceLimitExceeded { details, .. }
644            | Error::Request { details, .. } => details,
645        }
646    }
647
648    /// Repository identifier when the error is about a missing repository or a registry fetch target.
649    ///
650    /// Populated for [`Error::MissingRepository`] and [`Error::Registry`] (`identifier`).
651    #[must_use]
652    pub fn repository(&self) -> Option<&str> {
653        match self {
654            Error::MissingRepository { repository, .. } => Some(repository.as_str()),
655            Error::Registry { identifier, .. } => Some(identifier.as_str()),
656            _ => None,
657        }
658    }
659
660    /// Get the error message.
661    pub fn message(&self) -> &str {
662        &self.details().message
663    }
664
665    /// Get the source location if available.
666    pub fn location(&self) -> Option<&Source> {
667        self.details().source.as_ref()
668    }
669
670    /// Alias for [`Error::location`]. Preferred name when building the WASM/JS error payload.
671    pub fn source_location(&self) -> Option<&Source> {
672        self.location()
673    }
674
675    /// Resolve source text from the sources map (for display). Source no longer stores text.
676    pub fn source_text(
677        &self,
678        sources: &std::collections::HashMap<crate::parsing::source::SourceType, String>,
679    ) -> Option<String> {
680        self.location()
681            .and_then(|s| s.text_from(sources).map(|c| c.into_owned()))
682    }
683
684    /// Get the suggestion if available.
685    pub fn suggestion(&self) -> Option<&str> {
686        self.details().suggestion.as_deref()
687    }
688
689    /// Data name this error is attributed to (set at the data-binding call site).
690    pub fn related_data(&self) -> Option<&str> {
691        self.details().related_data.as_deref()
692    }
693
694    /// Spec name when the error is attributed to a planning/eval context.
695    pub fn spec_context_name(&self) -> Option<&str> {
696        self.details().spec_context_name.as_deref()
697    }
698
699    /// Name of a related spec referenced by this error (e.g. a transitive dependency).
700    pub fn related_spec(&self) -> Option<&str> {
701        self.details().related_spec_name.as_deref()
702    }
703
704    /// Registry failure sub-kind, populated only for [`Error::Registry`].
705    #[must_use]
706    pub fn registry_kind(&self) -> Option<RegistryErrorKind> {
707        match self {
708            Error::Registry { kind, .. } => Some(*kind),
709            _ => None,
710        }
711    }
712
713    /// Request failure sub-kind, populated only for [`Error::Request`].
714    #[must_use]
715    pub fn request_kind(&self) -> Option<RequestErrorKind> {
716        match self {
717            Error::Request { kind, .. } => Some(*kind),
718            _ => None,
719        }
720    }
721
722    /// Name of the exceeded resource limit, populated only for [`Error::ResourceLimitExceeded`].
723    #[must_use]
724    pub fn limit_name(&self) -> Option<&str> {
725        match self {
726            Error::ResourceLimitExceeded { limit_name, .. } => Some(limit_name.as_str()),
727            _ => None,
728        }
729    }
730
731    /// Configured value of the exceeded resource limit, populated only for
732    /// [`Error::ResourceLimitExceeded`].
733    #[must_use]
734    pub fn limit_value(&self) -> Option<&str> {
735        match self {
736            Error::ResourceLimitExceeded { limit_value, .. } => Some(limit_value.as_str()),
737            _ => None,
738        }
739    }
740
741    /// Actual value that exceeded the resource limit, populated only for
742    /// [`Error::ResourceLimitExceeded`].
743    #[must_use]
744    pub fn actual_value(&self) -> Option<&str> {
745        match self {
746            Error::ResourceLimitExceeded { actual_value, .. } => Some(actual_value.as_str()),
747            _ => None,
748        }
749    }
750}
751
752/// Source location attached to an [`EngineError`]. Line and column are 1-based;
753/// `length` is the UTF-8 byte length of the offending span.
754#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
755pub struct EngineErrorSource {
756    pub attribute: String,
757    pub line: usize,
758    pub column: usize,
759    pub length: usize,
760}
761
762impl From<&Source> for EngineErrorSource {
763    fn from(source: &Source) -> Self {
764        Self {
765            attribute: source.source_type.to_string(),
766            line: source.span.line,
767            column: source.span.col,
768            length: source.span.end.saturating_sub(source.span.start),
769        }
770    }
771}
772
773/// Flat wire view of [`Error`] matching `EngineError` in `engine/schemas/api.v1.json`
774/// and `engine/packages/npm/lemma.d.ts`. Missing optionals serialize as JSON `null`.
775#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
776pub struct EngineError {
777    pub kind: ErrorKind,
778    pub message: String,
779    pub related_data: Option<String>,
780    pub spec: Option<String>,
781    pub related_spec: Option<String>,
782    pub source: Option<EngineErrorSource>,
783    pub suggestion: Option<String>,
784    /// Set for [`Error::MissingRepository`] and [`Error::Registry`] (registry `@` id).
785    pub repository: Option<String>,
786    /// Set only for [`Error::Registry`].
787    pub registry_kind: Option<RegistryErrorKind>,
788    /// Set only for [`Error::Request`].
789    pub request_kind: Option<RequestErrorKind>,
790    /// Set only for [`Error::ResourceLimitExceeded`].
791    pub limit_name: Option<String>,
792    pub limit_value: Option<String>,
793    pub actual_value: Option<String>,
794}
795
796impl From<&Error> for EngineError {
797    fn from(error: &Error) -> Self {
798        Self {
799            kind: error.kind(),
800            message: error.message().to_string(),
801            related_data: error.related_data().map(str::to_string),
802            spec: error.spec_context_name().map(str::to_string),
803            related_spec: error.related_spec().map(str::to_string),
804            source: error.source_location().map(EngineErrorSource::from),
805            suggestion: error.suggestion().map(str::to_string),
806            repository: error.repository().map(str::to_string),
807            registry_kind: error.registry_kind(),
808            request_kind: error.request_kind(),
809            limit_name: error.limit_name().map(str::to_string),
810            limit_value: error.limit_value().map(str::to_string),
811            actual_value: error.actual_value().map(str::to_string),
812        }
813    }
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use crate::parsing::ast::Span;
820
821    fn test_source() -> Source {
822        Source::new(
823            crate::parsing::source::SourceType::Path(std::sync::Arc::new(
824                std::path::PathBuf::from("test.lemma"),
825            )),
826            Span {
827                start: 14,
828                end: 21,
829                line: 1,
830                col: 15,
831            },
832        )
833    }
834
835    #[test]
836    fn test_error_creation_and_display() {
837        let parse_error = Error::parsing("Invalid currency", test_source(), None::<String>);
838        let parse_error_display = format!("{parse_error}");
839        assert!(parse_error_display.contains("Parse error: Invalid currency"));
840        assert!(parse_error_display.contains("test.lemma:1:15"));
841
842        let suggestion_source = Source::new(
843            crate::parsing::source::SourceType::Volatile,
844            Span {
845                start: 5,
846                end: 10,
847                line: 2,
848                col: 3,
849            },
850        );
851        let suggestion_error =
852            Error::parsing_with_suggestion("typo", suggestion_source, "did you mean X?");
853        assert!(format!("{suggestion_error}").contains("suggestion: did you mean X?"));
854    }
855
856    #[test]
857    fn test_request_error_accessors() {
858        let err = Error::request("bad id", Some("use a valid id"));
859        assert_eq!(err.kind(), ErrorKind::Request);
860        assert_eq!(err.message(), "bad id");
861        assert!(err.location().is_none());
862        assert_eq!(err.suggestion(), Some("use a valid id"));
863        assert!(err.spec_context_name().is_none());
864        assert!(err.related_spec().is_none());
865    }
866
867    #[test]
868    fn test_missing_repository_display() {
869        let err = Error::missing_repository(
870            "not loaded",
871            None,
872            "@iso/countries",
873            Some("load the dependency first"),
874            None,
875        );
876        let display = format!("{err}");
877        assert!(display.contains("Missing repository"));
878        assert!(display.contains("@iso/countries"));
879        assert!(display.contains("not loaded"));
880    }
881
882    #[test]
883    fn test_with_spec_context_copies_name() {
884        let spec = LemmaSpec::new("pricing".to_string());
885        let err = Error::validation("bad", None, None::<String>).with_spec_context(&spec);
886        assert_eq!(err.spec_context_name(), Some("pricing"));
887        let display = format!("{err}");
888        assert!(display.contains("In spec 'pricing':"));
889    }
890}