Skip to main content

lemma/
error.rs

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