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