Skip to main content

lemma/
error.rs

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