Skip to main content

serde_saphyr/
de_error.rs

1use crate::budget::BudgetBreach;
2use crate::Location;
3use crate::localizer::{ExternalMessageSource, Localizer, DEFAULT_ENGLISH_LOCALIZER};
4use crate::location::Locations;
5use crate::parse_scalars::{
6    parse_int_signed, parse_yaml11_bool, parse_yaml12_float, scalar_is_nullish,
7};
8#[cfg(feature = "garde")]
9use crate::path_map::path_key_from_garde;
10#[cfg(any(feature = "garde", feature = "validator"))]
11use crate::path_map::{PathKey, PathMap, format_path_with_resolved_leaf};
12use crate::tags::SfTag;
13use saphyr_parser::{ScalarStyle, ScanError};
14use serde::de::{self};
15use annotate_snippets::Level;
16use std::borrow::Cow;
17use std::cell::Cell;
18use std::fmt;
19#[cfg(feature = "validator")]
20use validator::{ValidationErrors, ValidationErrorsKind};
21use crate::de_snipped::fmt_snippet_window_offset_or_fallback;
22
23#[cfg(any(feature = "garde", feature = "validator"))]
24use crate::localizer::ExternalMessage;
25
26/// Formats error *messages* (not including locations/snippets).
27///
28/// This is the core customization hook for deferred rendering. The error value remains
29/// structured data; the formatter decides what message text to show (developer-oriented,
30/// user-oriented, localized, etc.).
31///
32/// Important: implementations must NOT call `err.to_string()` / `Display` for `Error` to
33/// avoid recursion once `Display` delegates to `Error::render()`.
34///
35/// # Example
36///
37/// Override a couple of messages, returning `Cow::Borrowed` for a fixed string and
38/// `Cow::Owned` for a formatted message, while delegating all other cases to
39/// `UserMessageFormatter`.
40///
41/// ```rust
42/// use serde_saphyr::{Error, Location, MessageFormatter, UserMessageFormatter};
43/// use std::borrow::Cow;
44///
45/// struct PoliteFormatter;
46///
47/// impl MessageFormatter for PoliteFormatter {
48///     fn format_message<'a>(&self, err: &'a Error) -> Cow<'a, str> {
49///         // `UserMessageFormatter` is a zero-sized type, so it is cheap to instantiate.
50///         let fallback = UserMessageFormatter;
51///
52///         match err {
53///             // Fixed string => `Cow::Borrowed`
54///             Error::Eof { .. } => Cow::Borrowed("could you please provide a YAML document?"),
55///
56///             // Formatted string => `Cow::Owned`
57///             Error::UnknownAnchor { .. } => {
58///                 Cow::Borrowed("sorry but unknown reference")
59///             }
60///
61///             // Everything else => delegate
62///             _ => fallback.format_message(err),
63///         }
64///     }
65/// }
66///
67/// let err = serde_saphyr::from_str::<String>("").unwrap_err();
68/// assert!(err.render_with_formatter(&PoliteFormatter).contains("please provide"));
69///
70/// let err = Error::UnknownAnchor {
71///     location: Location::UNKNOWN,
72/// };
73/// assert!(err
74///     .render_with_formatter(&PoliteFormatter)
75///     .contains("unknown reference"));
76/// ```
77pub trait MessageFormatter {
78    /// Return the [`Localizer`] used by the renderer.
79    ///
80    /// This controls wording that is produced outside of [`MessageFormatter::format_message`],
81    /// such as location suffixes and snippet/validation labels.
82    fn localizer(&self) -> &dyn Localizer {
83        &DEFAULT_ENGLISH_LOCALIZER
84    }
85
86    /// Return the message text for `err`.
87    ///
88    /// The returned string should NOT include location suffixes like
89    /// `"at line X, column Y"`; those are added by the renderer.
90    fn format_message<'a>(&self, err: &'a Error) -> Cow<'a, str>;
91}
92
93/// User-facing message formatter.
94///
95/// This formatter simplifies technical errors and removes internal details.
96/// ```
97/// use serde_saphyr::UserMessageFormatter;
98///
99/// let err = serde_saphyr::from_str::<String>("").unwrap_err();
100/// let msg = err.render_with_formatter(&UserMessageFormatter);
101///
102/// assert_eq!(msg, "unexpected end of file at line 1, column 1");
103/// ```
104#[derive(Debug, Default, Clone, Copy)]
105pub struct UserMessageFormatter;
106
107/// Controls whether snippet output is included when available.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum SnippetMode {
110    /// Render snippets when the error is wrapped in `Error::WithSnippet`.
111    Auto,
112    /// Never render snippets; render a plain (location-suffixed) message instead.
113    Off,
114}
115
116/// Options for deferred error rendering.
117///
118/// Prefer constructing this via the [`render_options!`](crate::render_options!) macro
119/// instead of a struct literal. This keeps call sites stable even if new fields are added
120/// in the future (this type is `#[non_exhaustive]`).
121///
122/// # Example (using the `render_options!` macro)
123///
124/// ```rust
125/// use serde_saphyr::{DefaultMessageFormatter, SnippetMode};
126///
127/// let dev = DefaultMessageFormatter;
128/// // Customize how an error is rendered later (formatter + snippet mode).
129/// let render_opts = serde_saphyr::render_options! {
130///     formatter: &dev,
131///     snippets: SnippetMode::Off,
132/// };
133///
134/// let err = serde_saphyr::from_str::<String>("").unwrap_err();
135/// let rendered = err.render_with_options(render_opts);
136/// assert!(rendered.contains("unexpected"));
137/// ```
138#[non_exhaustive]
139#[derive(Clone, Copy)]
140pub struct RenderOptions<'a> {
141    /// Message formatter used to produce the core error message text.
142    pub formatter: &'a dyn MessageFormatter,
143    /// Snippet rendering mode.
144    pub snippets: SnippetMode,
145}
146
147impl<'a> Default for RenderOptions<'a> {
148    #[inline]
149    fn default() -> Self {
150        // Keep the default formatter reference valid even if `RenderOptions` is stored.
151        static DEFAULT_FMT: crate::message_formatters::DefaultMessageFormatter =
152            crate::message_formatters::DefaultMessageFormatter;
153
154        Self::new(&DEFAULT_FMT)
155    }
156}
157
158impl<'a> RenderOptions<'a> {
159    /// Construct render options with the given message `formatter` and default values
160    /// for all other fields.
161    ///
162    /// Defaults:
163    /// - `snippets`: [`SnippetMode::Auto`]
164    #[inline]
165    pub fn new(formatter: &'a dyn MessageFormatter) -> Self {
166        Self {
167            formatter,
168            snippets: SnippetMode::Auto,
169        }
170    }
171}
172
173/// Cropped YAML source window stored inside [`Error::WithSnippet`].
174///
175/// The window is described in terms of the original (absolute) 1-based line numbers.
176/// This allows selecting the best-matching region for a particular error location.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct CroppedRegion {
179    /// Cropped source text used for snippet rendering.
180    pub text: String,
181    /// The 1-based line number in the *original* input where `text` starts.
182    pub start_line: usize,
183    /// The 1-based line number in the *original* input where `text` ends (inclusive).
184    pub end_line: usize,
185}
186
187impl CroppedRegion {
188    fn covers(&self, location: &Location) -> bool {
189        if location == &Location::UNKNOWN {
190            return false;
191        }
192        let line = location.line as usize;
193        self.start_line <= line && line <= self.end_line
194    }
195}
196
197fn line_count_including_trailing_empty_line(text: &str) -> usize {
198    let mut lines = text.split_terminator('\n').count().max(1);
199    if text.ends_with('\n') {
200        lines = lines.saturating_add(1);
201    }
202    lines
203}
204
205#[cfg(any(feature = "garde", feature = "validator"))]
206#[derive(Debug, Clone)]
207pub(crate) struct ValidationIssue {
208    pub(crate) path: PathKey,
209    pub(crate) code: String,
210    pub(crate) message: Option<String>,
211    pub(crate) params: Vec<(String, String)>,
212}
213
214#[cfg(any(feature = "garde", feature = "validator"))]
215impl ValidationIssue {
216    pub(crate) fn display_entry(&self) -> String {
217        if let Some(msg) = &self.message {
218            return msg.clone();
219        }
220
221        if self.params.is_empty() {
222            return self.code.clone();
223        }
224
225        let mut params = String::new();
226        for (i, (k, v)) in self.params.iter().enumerate() {
227            if i > 0 {
228                params.push_str(", ");
229            }
230            params.push_str(k);
231            params.push('=');
232            params.push_str(v);
233        }
234        format!("{} ({params})", self.code)
235    }
236
237    pub(crate) fn display_entry_overridden(
238        &self,
239        l10n: &dyn Localizer,
240        source: ExternalMessageSource,
241    ) -> String {
242        let raw = self.display_entry();
243        let overridden = l10n
244            .override_external_message(ExternalMessage {
245                source,
246                original: raw.as_str(),
247                code: Some(self.code.as_str()),
248                params: &self.params,
249            })
250            .unwrap_or(Cow::Borrowed(raw.as_str()));
251        overridden.into_owned()
252    }
253}
254
255// Fallback location for Serde's static error constructors (`unknown_field`, `missing_field`,
256// etc.) which have no `&self` and cannot access deserializer state. Thread-local because
257// that is the only side-channel available. `Cell` suffices since `Location` is `Copy`.
258//
259// Set to the current key's location before each key deserialization via
260// [`MissingFieldLocationGuard`]; read by [`maybe_attach_fallback_location`].
261// The guard saves/restores the previous value on drop for correct nesting.
262thread_local! {
263    static MISSING_FIELD_FALLBACK: Cell<Option<Location>> = const { Cell::new(None) };
264}
265
266/// RAII guard for [`MISSING_FIELD_FALLBACK`]. Saves the previous value on creation,
267/// restores it on drop.
268pub(crate) struct MissingFieldLocationGuard {
269    prev: Option<Location>,
270}
271
272impl MissingFieldLocationGuard {
273    pub(crate) fn new(location: Location) -> Self {
274        let prev = MISSING_FIELD_FALLBACK.with(|c| c.replace(Some(location)));
275        Self { prev }
276    }
277
278    /// Update the fallback location in place, reusing the existing guard's restore point.
279    pub(crate) fn replace_location(&mut self, location: Location) {
280        MISSING_FIELD_FALLBACK.with(|c| c.set(Some(location)));
281    }
282}
283
284impl Drop for MissingFieldLocationGuard {
285    fn drop(&mut self) {
286        MISSING_FIELD_FALLBACK.with(|c| c.set(self.prev));
287    }
288}
289
290/// The reason why a string value was transformed during parsing and cannot be borrowed.
291///
292/// When deserializing to `&str`, the value must exist verbatim in the input. However,
293/// certain YAML constructs require string transformation, making borrowing impossible.
294#[non_exhaustive]
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum TransformReason {
297    /// Escape sequences were processed (e.g., `\n`, `\t`, `\uXXXX` in double-quoted strings).
298    EscapeSequence,
299    /// Line folding was applied (folded block scalar `>`).
300    LineFolding,
301    /// Multi-line plain or quoted scalar with whitespace normalization.
302    MultiLineNormalization,
303    /// Block scalar processing (literal `|` or folded `>` with chomping/indentation).
304    BlockScalarProcessing,
305    /// Single-quoted string with `''` escape processing.
306    SingleQuoteEscape,
307    /// Borrowing is not supported because the deserializer does not have access to the full input
308    /// buffer (for example, when deserializing from a `Read`er), or because the parser did not
309    /// provide a slice that is a subslice of the original input.
310    InputNotBorrowable,
311
312    /// The parser returned an owned string for this scalar.
313    ///
314    /// In newer `saphyr-parser` versions, zero-copy is represented directly as `Cow::Borrowed`.
315    /// If a scalar comes through as `Cow::Owned`, the deserializer cannot safely fabricate a
316    /// borrow, because it would not refer to the original input buffer.
317    ParserReturnedOwned,
318}
319
320impl fmt::Display for TransformReason {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        match self {
323            TransformReason::EscapeSequence => write!(f, "escape sequence processing"),
324            TransformReason::LineFolding => write!(f, "line folding"),
325            TransformReason::MultiLineNormalization => write!(f, "multi-line whitespace normalization"),
326            TransformReason::BlockScalarProcessing => write!(f, "block scalar processing"),
327            TransformReason::SingleQuoteEscape => write!(f, "single-quote escape processing"),
328            TransformReason::InputNotBorrowable => write!(f, "input is not available for borrowing"),
329            TransformReason::ParserReturnedOwned => write!(f, "parser returned an owned string"),
330        }
331    }
332}
333
334/// Error type compatible with `serde::de::Error`.
335#[non_exhaustive]
336#[derive(Debug)]
337pub enum Error {
338    /// Free-form error with optional source location.
339    Message {
340        msg: String,
341        location: Location,
342    },
343
344    /// Text primarily produced by a dependency (parser / validators).
345    ///
346    /// Renderers should call [`Localizer::override_external_message`] to allow callers
347    /// to replace or translate this text.
348    ExternalMessage {
349        source: ExternalMessageSource,
350        msg: String,
351        /// Stable-ish identifier when available (e.g. validator error code).
352        code: Option<String>,
353        /// Optional structured parameters when available.
354        params: Vec<(String, String)>,
355        location: Location,
356    },
357    /// Unexpected end of input.
358    Eof {
359        location: Location,
360    },
361    /// More than one YAML document was found when a single document was expected.
362    ///
363    /// This is typically returned by single-document entrypoints like `from_str*` / `from_slice*`
364    /// / `read_to_end*` when the input stream contains multiple `---`-delimited documents.
365    MultipleDocuments {
366        /// Developer-facing hint (may mention specific APIs).
367        hint: &'static str,
368        location: Location,
369    },
370    /// Structural/type mismatch — something else than the expected token/value was seen.
371    Unexpected {
372        expected: &'static str,
373        location: Location,
374    },
375
376    /// YAML merge (`<<`) value was not a mapping or a sequence of mappings.
377    MergeValueNotMapOrSeqOfMaps {
378        location: Location,
379    },
380
381    /// `!!binary` scalar could not be decoded as base64.
382    InvalidBinaryBase64 {
383        location: Location,
384    },
385
386    /// `!!binary` scalar decoded successfully but was not valid UTF-8 when a string was expected.
387    BinaryNotUtf8 {
388        location: Location,
389    },
390
391    /// A scalar was explicitly tagged but could not be deserialized into a string.
392    TaggedScalarCannotDeserializeIntoString {
393        location: Location,
394    },
395
396    /// Encountered a sequence end where it was not expected.
397    UnexpectedSequenceEnd {
398        location: Location,
399    },
400
401    /// Encountered a mapping end where it was not expected.
402    UnexpectedMappingEnd {
403        location: Location,
404    },
405
406    /// Invalid boolean literal in strict mode.
407    InvalidBooleanStrict {
408        location: Location,
409    },
410
411    /// Invalid char: null cannot be deserialized into `char`.
412    InvalidCharNull {
413        location: Location,
414    },
415
416    /// Invalid char: expected a single Unicode scalar value.
417    InvalidCharNotSingleScalar {
418        location: Location,
419    },
420
421    /// Cannot deserialize null into string.
422    NullIntoString {
423        location: Location,
424    },
425
426    /// Bytes (`&[u8]` / `Vec<u8>`) are not supported unless the scalar is tagged as `!!binary`.
427    BytesNotSupportedMissingBinaryTag {
428        location: Location,
429    },
430
431    /// Unexpected value for unit (`()`).
432    UnexpectedValueForUnit {
433        location: Location,
434    },
435
436    /// Unit struct expected an empty mapping.
437    ExpectedEmptyMappingForUnitStruct {
438        location: Location,
439    },
440
441    /// While skipping a node, a container end event was encountered unexpectedly.
442    UnexpectedContainerEndWhileSkippingNode {
443        location: Location,
444    },
445
446    /// Internal error: a seed was reused for a map key.
447    InternalSeedReusedForMapKey {
448        location: Location,
449    },
450
451    /// Internal error: value requested before key.
452    ValueRequestedBeforeKey {
453        location: Location,
454    },
455
456    /// Externally tagged enum: expected a string key.
457    ExpectedStringKeyForExternallyTaggedEnum {
458        location: Location,
459    },
460
461    /// Externally tagged enum: expected either a scalar or a mapping.
462    ExternallyTaggedEnumExpectedScalarOrMapping {
463        location: Location,
464    },
465
466    /// Unexpected value for unit enum variant.
467    UnexpectedValueForUnitEnumVariant {
468        location: Location,
469    },
470
471    /// Input was not valid UTF-8.
472    InvalidUtf8Input,
473
474    /// Alias replay counter overflow.
475    AliasReplayCounterOverflow {
476        location: Location,
477    },
478
479    /// Alias replay total event limit exceeded.
480    AliasReplayLimitExceeded {
481        total_replayed_events: usize,
482        max_total_replayed_events: usize,
483        location: Location,
484    },
485
486    /// Alias expansion limit exceeded for a single anchor.
487    AliasExpansionLimitExceeded {
488        anchor_id: usize,
489        expansions: usize,
490        max_expansions_per_anchor: usize,
491        location: Location,
492    },
493
494    /// Alias replay stack depth limit exceeded.
495    AliasReplayStackDepthExceeded {
496        depth: usize,
497        max_depth: usize,
498        location: Location,
499    },
500
501    /// Folded block scalars must indent their content.
502    FoldedBlockScalarMustIndentContent {
503        location: Location,
504    },
505
506    /// Internal: depth counter underflow.
507    InternalDepthUnderflow {
508        location: Location,
509    },
510
511    /// Internal: recursion stack empty.
512    InternalRecursionStackEmpty {
513        location: Location,
514    },
515
516    /// recursive references require weak recursion types.
517    RecursiveReferencesRequireWeakTypes {
518        location: Location,
519    },
520
521    /// Scalar parsing failed for the requested target type.
522    InvalidScalar {
523        ty: &'static str,
524        location: Location,
525    },
526
527    /// Serde-generated: invalid type.
528    SerdeInvalidType {
529        unexpected: String,
530        expected: String,
531        location: Location,
532    },
533
534    /// Serde-generated: invalid value.
535    SerdeInvalidValue {
536        unexpected: String,
537        expected: String,
538        location: Location,
539    },
540
541    /// Serde-generated: unknown enum variant.
542    SerdeUnknownVariant {
543        variant: String,
544        expected: Vec<&'static str>,
545        location: Location,
546    },
547
548    /// Serde-generated: unknown field.
549    SerdeUnknownField {
550        field: String,
551        expected: Vec<&'static str>,
552        location: Location,
553    },
554
555    /// Serde-generated: missing required field.
556    SerdeMissingField {
557        field: &'static str,
558        location: Location,
559    },
560
561    /// Encountered the end of a sequence or mapping while reading a key node.
562    ///
563    /// This indicates a structural mismatch in the input.
564    UnexpectedContainerEndWhileReadingKeyNode {
565        location: Location,
566    },
567
568    /// Duplicate key in a mapping.
569    ///
570    /// When the duplicate key can be rendered as a string-like scalar, `key` is provided.
571    DuplicateMappingKey {
572        key: Option<String>,
573        location: Location,
574    },
575
576    /// Tagged enum name does not match the target enum.
577    TaggedEnumMismatch {
578        tagged: String,
579        target: &'static str,
580        location: Location,
581    },
582
583    /// Serde-generated error while deserializing an enum variant identifier.
584    SerdeVariantId {
585        msg: String,
586        location: Location,
587    },
588
589    /// Expected the end of a mapping after an externally tagged enum variant value.
590    ExpectedMappingEndAfterEnumVariantValue {
591        location: Location,
592    },
593    ContainerEndMismatch {
594        location: Location,
595    },
596    /// Alias references a non-existent anchor.
597    UnknownAnchor { location: Location },
598    /// Error related to an alias, with both reference (use-site) and defined (anchor) locations.
599    ///
600    /// This variant allows reporting both where an alias is used and where the anchor is defined,
601    /// which is useful for errors that occur when deserializing aliased values.
602    AliasError {
603        msg: String,
604        locations: Locations,
605    },
606    /// Error when parsing robotic and other extensions beyond standard YAML.
607    /// (error in extension hook).
608    HookError {
609        msg: String,
610        location: Location,
611    },
612    /// A YAML budget limit was exceeded.
613    Budget {
614        breach: BudgetBreach,
615        location: Location,
616    },
617    /// Unexpected I/O error. This may happen only when deserializing from a reader.
618    IOError {
619        cause: std::io::Error,
620    },
621    /// The value is targeted to the string field but can be interpreted as a number or boolean.
622    /// This error can only happen if no_schema set true.
623    QuotingRequired {
624        value: String, // sanitized (checked) value that must be quoted
625        location: Location,
626    },
627
628    /// The target type requires a borrowed string (`&str`), but the value was transformed
629    /// during parsing (e.g., through escape processing, line folding, or multi-line normalization)
630    /// and cannot be borrowed from the input.
631    ///
632    /// Use `String` or `Cow<str>` instead of `&str` to handle transformed values.
633    CannotBorrowTransformedString {
634        /// The reason why the string had to be transformed and cannot be borrowed.
635        reason: TransformReason,
636        location: Location,
637    },
638
639    /// Wrap an error with the full input text, enabling rustc-like snippet rendering.
640    WithSnippet {
641        /// Cropped source windows used for snippet rendering.
642        ///
643        /// This intentionally does NOT store the full input text, to avoid retaining
644        /// large YAML inputs inside errors.
645        regions: Vec<CroppedRegion>,
646        crop_radius: usize,
647        error: Box<Error>,
648    },
649
650    /// Garde validation failure.
651    #[cfg(feature = "garde")]
652    ValidationError {
653        report: garde::Report,
654        locations: PathMap,
655    },
656
657    /// Garde validation failures (multiple, if multiple validations fail)
658    #[cfg(feature = "garde")]
659    ValidationErrors {
660        errors: Vec<Error>,
661    },
662
663    /// Validator validation failure.
664    #[cfg(feature = "validator")]
665    ValidatorError {
666        errors: ValidationErrors,
667        locations: PathMap,
668    },
669
670    /// Validator validation failures (multiple, if multiple validations fail)
671    #[cfg(feature = "validator")]
672    ValidatorErrors {
673        errors: Vec<Error>,
674    },
675}
676
677impl Error {
678    #[cold]
679    #[inline(never)]
680    pub(crate) fn with_snippet(self, text: &str, crop_radius: usize) -> Self {
681        // Avoid nesting snippet wrappers: keep the innermost error and rebuild the
682        // wrapper with freshly cropped source window.
683        let inner = match self {
684            Error::WithSnippet { error, .. } => *error,
685            other => other,
686        };
687
688        // Keep snippet coordinates aligned with parsers that ignore a leading UTF-8 BOM.
689        let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
690        
691        fn push_region_for_location(
692            regions: &mut Vec<CroppedRegion>,
693            text: &str,
694            location: &Location,
695            mapping: crate::de_snipped::LineMapping,
696            crop_radius: usize,
697        ) {
698            if crop_radius == 0 || *location == Location::UNKNOWN {
699                return;
700            }
701            let (cropped, start_line) =
702                crate::de_snipped::crop_source_window(text, location, mapping, crop_radius);
703            if cropped.is_empty() {
704                return;
705            }
706            let lines = line_count_including_trailing_empty_line(cropped.as_str());
707            let end_line = start_line.saturating_add(lines.saturating_sub(1));
708            regions.push(CroppedRegion {
709                text: cropped,
710                start_line,
711                end_line,
712            });
713        }
714
715        let mut regions: Vec<CroppedRegion> = Vec::new();
716        let mapping = crate::de_snipped::LineMapping::Identity;
717
718        // Validation errors may contain multiple independent issue locations; pre-crop
719        // one region per issue so we can later pick the region that covers the issue.
720        #[cfg(feature = "garde")]
721        if let Error::ValidationError { report, locations } = &inner {
722            for (path, _entry) in report.iter() {
723                let key = path_key_from_garde(path);
724                let (locs, _) = locations.search(&key).unwrap_or((Locations::UNKNOWN, String::new()));
725                push_region_for_location(&mut regions, text, &locs.reference_location, mapping, crop_radius);
726                if locs.defined_location != locs.reference_location {
727                    push_region_for_location(&mut regions, text, &locs.defined_location, mapping, crop_radius);
728                }
729            }
730        }
731        #[cfg(feature = "validator")]
732        if let Error::ValidatorError { errors, locations } = &inner {
733            for issue in collect_validator_issues(errors) {
734                let (locs, _) = locations
735                    .search(&issue.path)
736                    .unwrap_or((Locations::UNKNOWN, String::new()));
737                push_region_for_location(&mut regions, text, &locs.reference_location, mapping, crop_radius);
738                if locs.defined_location != locs.reference_location {
739                    push_region_for_location(&mut regions, text, &locs.defined_location, mapping, crop_radius);
740                }
741            }
742        }
743
744        // Fallback: crop around the top-level error locations (including dual-location
745        // errors such as AliasError).
746        if regions.is_empty() {
747            if let Some(locs) = inner.locations() {
748                push_region_for_location(&mut regions, text, &locs.reference_location, mapping, crop_radius);
749                if locs.defined_location != locs.reference_location {
750                    push_region_for_location(&mut regions, text, &locs.defined_location, mapping, crop_radius);
751                }
752            } else if let Some(loc) = inner.location() {
753                push_region_for_location(&mut regions, text, &loc, mapping, crop_radius);
754            }
755        }
756
757        Error::WithSnippet {
758            regions,
759            crop_radius,
760            error: Box::new(inner),
761        }
762    }
763
764    /// Attach a snippet from a partial YAML fragment (e.g., from `RingReader`).
765    ///
766    /// This is similar to `with_snippet`, but the `text` is a fragment that starts
767    /// at `start_line` (1-based) rather than at line 1. The renderer will adjust
768    /// line numbers accordingly.
769    #[cold]
770    #[inline(never)]
771    pub(crate) fn with_snippet_offset(
772        self,
773        text: &str,
774        start_line: usize,
775        crop_radius: usize,
776    ) -> Self {
777        let inner = match self {
778            Error::WithSnippet { error, .. } => *error,
779            other => other,
780        };
781
782        // Keep snippet coordinates aligned with parsers that ignore a leading UTF-8 BOM.
783        let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
784        
785        fn push_region_for_location(
786            regions: &mut Vec<CroppedRegion>,
787            text: &str,
788            location: &Location,
789            mapping: crate::de_snipped::LineMapping,
790            crop_radius: usize,
791        ) {
792            if crop_radius == 0 || *location == Location::UNKNOWN {
793                return;
794            }
795            let (cropped, region_start_line) =
796                crate::de_snipped::crop_source_window(text, location, mapping, crop_radius);
797            if cropped.is_empty() {
798                return;
799            }
800            let lines = line_count_including_trailing_empty_line(cropped.as_str());
801            let end_line = region_start_line.saturating_add(lines.saturating_sub(1));
802            regions.push(CroppedRegion {
803                text: cropped,
804                start_line: region_start_line,
805                end_line,
806            });
807        }
808
809        let mut regions: Vec<CroppedRegion> = Vec::new();
810        let mapping = crate::de_snipped::LineMapping::Offset { start_line };
811
812        #[cfg(feature = "garde")]
813        if let Error::ValidationError { report, locations } = &inner {
814            for (path, _entry) in report.iter() {
815                let key = path_key_from_garde(path);
816                let (locs, _) = locations.search(&key).unwrap_or((Locations::UNKNOWN, String::new()));
817                push_region_for_location(&mut regions, text, &locs.reference_location, mapping, crop_radius);
818                if locs.defined_location != locs.reference_location {
819                    push_region_for_location(&mut regions, text, &locs.defined_location, mapping, crop_radius);
820                }
821            }
822        }
823        #[cfg(feature = "validator")]
824        if let Error::ValidatorError { errors, locations } = &inner {
825            for issue in collect_validator_issues(errors) {
826                let (locs, _) = locations
827                    .search(&issue.path)
828                    .unwrap_or((Locations::UNKNOWN, String::new()));
829                push_region_for_location(&mut regions, text, &locs.reference_location, mapping, crop_radius);
830                if locs.defined_location != locs.reference_location {
831                    push_region_for_location(&mut regions, text, &locs.defined_location, mapping, crop_radius);
832                }
833            }
834        }
835
836        if regions.is_empty() {
837            if let Some(locs) = inner.locations() {
838                push_region_for_location(&mut regions, text, &locs.reference_location, mapping, crop_radius);
839                if locs.defined_location != locs.reference_location {
840                    push_region_for_location(&mut regions, text, &locs.defined_location, mapping, crop_radius);
841                }
842            } else if let Some(loc) = inner.location() {
843                push_region_for_location(&mut regions, text, &loc, mapping, crop_radius);
844            }
845        }
846
847        Error::WithSnippet {
848            regions,
849            crop_radius,
850            error: Box::new(inner),
851        }
852    }
853
854    /// Provide "no snippet" version for cases when snippet rendering is not  desired.
855    pub fn without_snippet(&self) -> &Self {
856        match self {
857            Error::WithSnippet { error, .. } => error,
858            other => other,
859        }
860    }
861
862    /// Render this error using the built-in developer formatter.
863    ///
864    /// This is the deferred-rendering entrypoint. It is equivalent to `Display`/`to_string()`
865    /// output, but also allows callers to choose a custom [`MessageFormatter`] via
866    /// [`Error::render_with_options`].
867    pub fn render(&self) -> String {
868        self.render_with_options(RenderOptions::default())
869    }
870
871    /// Render this error using a custom message formatter.
872    pub fn render_with_formatter(&self, formatter: &dyn MessageFormatter) -> String {
873        self.render_with_options(RenderOptions {
874            formatter,
875            snippets: SnippetMode::Auto,
876        })
877    }
878
879    /// Render this error using the provided options.
880    pub fn render_with_options(&self, options: RenderOptions<'_>) -> String {
881        struct RenderDisplay<'a> {
882            err: &'a Error,
883            options: RenderOptions<'a>,
884        }
885
886        impl fmt::Display for RenderDisplay<'_> {
887            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
888                fmt_error_rendered(f, self.err, self.options)
889            }
890        }
891
892        RenderDisplay {
893            err: self,
894            options,
895        }
896        .to_string()
897    }
898
899    /// Construct a `Message` error with no known location.
900    ///
901    /// Arguments:
902    /// - `s`: human-readable message.
903    ///
904    /// Returns:
905    /// - `Error::Message` pointing at [`Location::UNKNOWN`].
906    ///
907    /// Called by:
908    /// - Scalar parsers and helpers throughout this module.
909    #[cold]
910    #[inline(never)]
911    pub(crate) fn msg<S: Into<String>>(s: S) -> Self {
912        Error::Message {
913            msg: s.into(),
914            location: Location::UNKNOWN,
915        }
916    }
917
918    /// Construct a `QuotingRequired` error with no known location.
919    /// Called by:
920    /// - Deserializer, when deserializing into string if no_schema set to true.
921    #[cold]
922    #[inline(never)]
923    pub(crate) fn quoting_required(value: &str) -> Self {
924        // Ensure the value really is like number or boolean (do not reflect back content
925        // that may be used for attack)
926        let location = Location::UNKNOWN;
927        let value = if parse_yaml12_float::<f64>(value, location, SfTag::None, false).is_ok()
928            || parse_int_signed::<i128>(value, "i128", location, false).is_ok()
929            || parse_yaml11_bool(value).is_ok()
930            || scalar_is_nullish(value, &ScalarStyle::Plain)
931        {
932            value.to_string()
933        } else {
934            String::new()
935        };
936        Error::QuotingRequired { value, location }
937    }
938
939    /// Convenience for an `Unexpected` error pre-filled with a human phrase.
940    ///
941    /// Arguments:
942    /// - `what`: short description like "sequence start".
943    ///
944    /// Returns:
945    /// - `Error::Unexpected` at unknown location.
946    ///
947    /// Called by:
948    /// - Deserializer methods that validate the next event kind.
949    #[cold]
950    #[inline(never)]
951    pub(crate) fn unexpected(what: &'static str) -> Self {
952        Error::Unexpected {
953            expected: what,
954            location: Location::UNKNOWN,
955        }
956    }
957
958    /// Construct an unexpected end-of-input error with unknown location.
959    ///
960    /// Used by:
961    /// - Lookahead and pull methods when `None` appears prematurely.
962    #[cold]
963    #[inline(never)]
964    pub(crate) fn eof() -> Self {
965        Error::Eof {
966            location: Location::UNKNOWN,
967        }
968    }
969
970    #[cold]
971    #[inline(never)]
972    pub(crate) fn multiple_documents(hint: &'static str) -> Self {
973        Error::MultipleDocuments {
974            hint,
975            location: Location::UNKNOWN,
976        }
977    }
978
979    /// Construct an `UnknownAnchor` error (unknown location).
980    ///
981    /// Called by:
982    /// - Alias replay logic in the live event source.
983    #[cold]
984    #[inline(never)]
985    pub(crate) fn unknown_anchor() -> Self {
986        Error::UnknownAnchor {
987            location: Location::UNKNOWN,
988        }
989    }
990
991    /// Construct a `CannotBorrowTransformedString` error for the given reason.
992    ///
993    /// This error is returned when deserializing to `&str` but the string value
994    /// was transformed during parsing and cannot be borrowed from the input.
995    #[cold]
996    #[inline(never)]
997    pub fn cannot_borrow_transformed(reason: TransformReason) -> Self {
998        Error::CannotBorrowTransformedString {
999            reason,
1000            location: Location::UNKNOWN,
1001        }
1002    }
1003
1004    /// Attach/override a concrete location to this error and return it.
1005    ///
1006    /// Arguments:
1007    /// - `set_location`: location to store in the error.
1008    ///
1009    /// Returns:
1010    /// - The same `Error` with location updated.
1011    ///
1012    /// Called by:
1013    /// - Most error paths once the event position becomes known.
1014    #[cold]
1015    #[inline(never)]
1016    pub(crate) fn with_location(mut self, set_location: Location) -> Self {
1017        match &mut self {
1018            Error::Message { location, .. }
1019            | Error::ExternalMessage { location, .. }
1020            | Error::Eof { location }
1021            | Error::MultipleDocuments { location, .. }
1022            | Error::Unexpected { location, .. }
1023            | Error::MergeValueNotMapOrSeqOfMaps { location }
1024            | Error::InvalidBinaryBase64 { location }
1025            | Error::BinaryNotUtf8 { location }
1026            | Error::TaggedScalarCannotDeserializeIntoString { location }
1027            | Error::UnexpectedSequenceEnd { location }
1028            | Error::UnexpectedMappingEnd { location }
1029            | Error::InvalidBooleanStrict { location }
1030            | Error::InvalidCharNull { location }
1031            | Error::InvalidCharNotSingleScalar { location }
1032            | Error::NullIntoString { location }
1033            | Error::BytesNotSupportedMissingBinaryTag { location }
1034            | Error::UnexpectedValueForUnit { location }
1035            | Error::ExpectedEmptyMappingForUnitStruct { location }
1036            | Error::UnexpectedContainerEndWhileSkippingNode { location }
1037            | Error::InternalSeedReusedForMapKey { location }
1038            | Error::ValueRequestedBeforeKey { location }
1039            | Error::ExpectedStringKeyForExternallyTaggedEnum { location }
1040            | Error::ExternallyTaggedEnumExpectedScalarOrMapping { location }
1041            | Error::UnexpectedValueForUnitEnumVariant { location }
1042            | Error::AliasReplayCounterOverflow { location }
1043            | Error::AliasReplayLimitExceeded { location, .. }
1044            | Error::AliasExpansionLimitExceeded { location, .. }
1045            | Error::AliasReplayStackDepthExceeded { location, .. }
1046            | Error::FoldedBlockScalarMustIndentContent { location }
1047            | Error::InternalDepthUnderflow { location }
1048            | Error::InternalRecursionStackEmpty { location }
1049            | Error::RecursiveReferencesRequireWeakTypes { location }
1050            | Error::InvalidScalar { location, .. }
1051            | Error::SerdeInvalidType { location, .. }
1052            | Error::SerdeInvalidValue { location, .. }
1053            | Error::SerdeUnknownVariant { location, .. }
1054            | Error::SerdeUnknownField { location, .. }
1055            | Error::SerdeMissingField { location, .. }
1056            | Error::UnexpectedContainerEndWhileReadingKeyNode { location }
1057            | Error::DuplicateMappingKey { location, .. }
1058            | Error::TaggedEnumMismatch { location, .. }
1059            | Error::SerdeVariantId { location, .. }
1060            | Error::ExpectedMappingEndAfterEnumVariantValue { location }
1061            | Error::HookError { location, .. }
1062            | Error::ContainerEndMismatch { location, .. }
1063            | Error::UnknownAnchor { location, .. }
1064            | Error::QuotingRequired { location, .. }
1065            | Error::Budget { location, .. }
1066            | Error::CannotBorrowTransformedString { location, .. } => {
1067                *location = set_location;
1068            }
1069            Error::InvalidUtf8Input => {}
1070            Error::IOError { .. } => {} // this error does not support location
1071            Error::AliasError { .. } => {
1072                // AliasError carries its own Locations; don't override with a single location.
1073            }
1074            Error::WithSnippet { error, .. } => {
1075                let inner = *std::mem::replace(error, Box::new(Error::eof()));
1076                **error = inner.with_location(set_location);
1077            }
1078            #[cfg(feature = "garde")]
1079            Error::ValidationError { .. } => {
1080                // Validation errors carry their own per-path locations.
1081            }
1082            #[cfg(feature = "garde")]
1083            Error::ValidationErrors { .. } => {
1084                // Aggregate validation errors carry their own per-entry locations.
1085            }
1086            #[cfg(feature = "validator")]
1087            Error::ValidatorError { .. } => {
1088                // Validation errors carry their own per-path locations.
1089            }
1090            #[cfg(feature = "validator")]
1091            Error::ValidatorErrors { .. } => {
1092                // Aggregate validation errors carry their own per-entry locations.
1093            }
1094        }
1095        self
1096    }
1097
1098    /// If the error has a known location, return it.
1099    ///
1100    /// Returns:
1101    /// - `Some(Location)` when coordinates are known; `None` otherwise.
1102    ///
1103    /// Used by:
1104    /// - Callers that want to surface precise positions to users.
1105    pub fn location(&self) -> Option<Location> {
1106        match self {
1107            Error::Message { location, .. }
1108            | Error::ExternalMessage { location, .. }
1109            | Error::Eof { location }
1110            | Error::MultipleDocuments { location, .. }
1111            | Error::Unexpected { location, .. }
1112            | Error::MergeValueNotMapOrSeqOfMaps { location }
1113            | Error::InvalidBinaryBase64 { location }
1114            | Error::BinaryNotUtf8 { location }
1115            | Error::TaggedScalarCannotDeserializeIntoString { location }
1116            | Error::UnexpectedSequenceEnd { location }
1117            | Error::UnexpectedMappingEnd { location }
1118            | Error::InvalidBooleanStrict { location }
1119            | Error::InvalidCharNull { location }
1120            | Error::InvalidCharNotSingleScalar { location }
1121            | Error::NullIntoString { location }
1122            | Error::BytesNotSupportedMissingBinaryTag { location }
1123            | Error::UnexpectedValueForUnit { location }
1124            | Error::ExpectedEmptyMappingForUnitStruct { location }
1125            | Error::UnexpectedContainerEndWhileSkippingNode { location }
1126            | Error::InternalSeedReusedForMapKey { location }
1127            | Error::ValueRequestedBeforeKey { location }
1128            | Error::ExpectedStringKeyForExternallyTaggedEnum { location }
1129            | Error::ExternallyTaggedEnumExpectedScalarOrMapping { location }
1130            | Error::UnexpectedValueForUnitEnumVariant { location }
1131            | Error::AliasReplayCounterOverflow { location }
1132            | Error::AliasReplayLimitExceeded { location, .. }
1133            | Error::AliasExpansionLimitExceeded { location, .. }
1134            | Error::AliasReplayStackDepthExceeded { location, .. }
1135            | Error::FoldedBlockScalarMustIndentContent { location }
1136            | Error::InternalDepthUnderflow { location }
1137            | Error::InternalRecursionStackEmpty { location }
1138            | Error::RecursiveReferencesRequireWeakTypes { location }
1139            | Error::InvalidScalar { location, .. }
1140            | Error::SerdeInvalidType { location, .. }
1141            | Error::SerdeInvalidValue { location, .. }
1142            | Error::SerdeUnknownVariant { location, .. }
1143            | Error::SerdeUnknownField { location, .. }
1144            | Error::SerdeMissingField { location, .. }
1145            | Error::UnexpectedContainerEndWhileReadingKeyNode { location }
1146            | Error::DuplicateMappingKey { location, .. }
1147            | Error::TaggedEnumMismatch { location, .. }
1148            | Error::SerdeVariantId { location, .. }
1149            | Error::ExpectedMappingEndAfterEnumVariantValue { location }
1150            | Error::HookError { location, .. }
1151            | Error::ContainerEndMismatch { location, .. }
1152            | Error::UnknownAnchor { location, .. }
1153            | Error::QuotingRequired { location, .. }
1154            | Error::Budget { location, .. }
1155            | Error::CannotBorrowTransformedString { location, .. } => {
1156                if location != &Location::UNKNOWN {
1157                    Some(*location)
1158                } else {
1159                    None
1160                }
1161            }
1162            Error::InvalidUtf8Input => None,
1163            Error::IOError { cause: _ } => None,
1164            Error::AliasError { locations, .. } => Locations::primary_location(*locations),
1165            Error::WithSnippet { error, .. } => error.location(),
1166            #[cfg(feature = "garde")]
1167            Error::ValidationError { report, locations } => report.iter().next().and_then(|(path, _)| {
1168                let key = path_key_from_garde(path);
1169                let (locs, _) = locations.search(&key)?;
1170                let loc = if locs.reference_location != Location::UNKNOWN {
1171                    locs.reference_location
1172                } else {
1173                    locs.defined_location
1174                };
1175                if loc != Location::UNKNOWN { Some(loc) } else { None }
1176            }),
1177            #[cfg(feature = "garde")]
1178            Error::ValidationErrors { errors } => errors.iter().find_map(|e| e.location()),
1179            #[cfg(feature = "validator")]
1180            Error::ValidatorError { errors, locations } => collect_validator_issues(errors).first().and_then(|issue| {
1181                let (locs, _) = locations.search(&issue.path)?;
1182                let loc = if locs.reference_location != Location::UNKNOWN {
1183                    locs.reference_location
1184                } else {
1185                    locs.defined_location
1186                };
1187                if loc != Location::UNKNOWN { Some(loc) } else { None }
1188            }),
1189            #[cfg(feature = "validator")]
1190            Error::ValidatorErrors { errors } => errors.iter().find_map(|e| e.location()),
1191        }
1192    }
1193
1194    /// Return a pair of locations associated with this error.
1195    ///
1196    /// - For syntax and other errors that carry a single [`Location`], this returns two
1197    ///   identical locations.
1198    /// - For validation errors (when the `garde` / `validator` feature is enabled), this returns
1199    ///   the `(reference_location, defined_location)` pair for the *first* validation entry.
1200    ///
1201    ///   These two locations may differ when YAML anchors/aliases are involved.
1202    /// - Returns `None` when no meaningful location information is available.
1203    pub fn locations(&self) -> Option<Locations> {
1204        match self {
1205            Error::Message { location, .. }
1206            | Error::ExternalMessage { location, .. }
1207            | Error::Eof { location }
1208            | Error::MultipleDocuments { location, .. }
1209            | Error::Unexpected { location, .. }
1210            | Error::MergeValueNotMapOrSeqOfMaps { location }
1211            | Error::InvalidBinaryBase64 { location }
1212            | Error::BinaryNotUtf8 { location }
1213            | Error::TaggedScalarCannotDeserializeIntoString { location }
1214            | Error::UnexpectedSequenceEnd { location }
1215            | Error::UnexpectedMappingEnd { location }
1216            | Error::InvalidBooleanStrict { location }
1217            | Error::InvalidCharNull { location }
1218            | Error::InvalidCharNotSingleScalar { location }
1219            | Error::NullIntoString { location }
1220            | Error::BytesNotSupportedMissingBinaryTag { location }
1221            | Error::UnexpectedValueForUnit { location }
1222            | Error::ExpectedEmptyMappingForUnitStruct { location }
1223            | Error::UnexpectedContainerEndWhileSkippingNode { location }
1224            | Error::InternalSeedReusedForMapKey { location }
1225            | Error::ValueRequestedBeforeKey { location }
1226            | Error::ExpectedStringKeyForExternallyTaggedEnum { location }
1227            | Error::ExternallyTaggedEnumExpectedScalarOrMapping { location }
1228            | Error::UnexpectedValueForUnitEnumVariant { location }
1229            | Error::AliasReplayCounterOverflow { location }
1230            | Error::AliasReplayLimitExceeded { location, .. }
1231            | Error::AliasExpansionLimitExceeded { location, .. }
1232            | Error::AliasReplayStackDepthExceeded { location, .. }
1233            | Error::FoldedBlockScalarMustIndentContent { location }
1234            | Error::InternalDepthUnderflow { location }
1235            | Error::InternalRecursionStackEmpty { location }
1236            | Error::RecursiveReferencesRequireWeakTypes { location }
1237            | Error::InvalidScalar { location, .. }
1238            | Error::SerdeInvalidType { location, .. }
1239            | Error::SerdeInvalidValue { location, .. }
1240            | Error::SerdeUnknownVariant { location, .. }
1241            | Error::SerdeUnknownField { location, .. }
1242            | Error::SerdeMissingField { location, .. }
1243            | Error::UnexpectedContainerEndWhileReadingKeyNode { location }
1244            | Error::DuplicateMappingKey { location, .. }
1245            | Error::TaggedEnumMismatch { location, .. }
1246            | Error::SerdeVariantId { location, .. }
1247            | Error::ExpectedMappingEndAfterEnumVariantValue { location }
1248            | Error::HookError { location, .. }
1249            | Error::ContainerEndMismatch { location, .. }
1250            | Error::UnknownAnchor { location, .. }
1251            | Error::QuotingRequired { location, .. }
1252            | Error::Budget { location, .. }
1253            | Error::CannotBorrowTransformedString { location, .. } => Locations::same(location),
1254            Error::InvalidUtf8Input => None,
1255            Error::IOError { .. } => None,
1256            Error::AliasError { locations, .. } => Some(*locations),
1257            Error::WithSnippet { error, .. } => error.locations(),
1258            #[cfg(feature = "garde")]
1259            Error::ValidationError { report, locations } => {
1260                report.iter().next().and_then(|(path, _)| {
1261                    let key = path_key_from_garde(path);
1262                    search_locations_with_ancestor_fallback(locations, &key)
1263                })
1264            }
1265            #[cfg(feature = "garde")]
1266            Error::ValidationErrors { errors } => errors.first().and_then(Error::locations),
1267            #[cfg(feature = "validator")]
1268            Error::ValidatorError { errors, locations } => collect_validator_issues(errors)
1269                .first()
1270                .and_then(|issue| locations.search(&issue.path).map(|(locs, _)| locs)),
1271            #[cfg(feature = "validator")]
1272            Error::ValidatorErrors { errors } => errors.first().and_then(Error::locations),
1273        }
1274    }
1275
1276    /// Map a `saphyr_parser::ScanError` into our error type with location.
1277    ///
1278    /// Called by:
1279    /// - The live events adapter when the underlying parser fails.
1280    #[cold]
1281    #[inline(never)]
1282    pub(crate) fn from_scan_error(err: ScanError) -> Self {
1283        use crate::location::SpanIndex;
1284        let mark = err.marker();
1285        let location =
1286            Location::new(mark.line(), mark.col() + 1).with_span(crate::location::Span {
1287                offset: mark.index() as SpanIndex,
1288                len: 1,
1289                byte_info: (0, 0),
1290            });
1291
1292        // `saphyr_parser` reports missing aliases/anchors as a `ScanError` with a textual
1293        // message (e.g. "unknown anchor"). To keep our formatter overrides working for the
1294        // common real-world case (`*missing`), detect this and convert to our structured
1295        // `Error::UnknownAnchor`.
1296        //
1297        // Note: the parser message usually contains an anchor *name*. We intentionally do not
1298        // attempt to parse or store it, to keep this variant free of best-effort identifiers.
1299        let info = err.info();
1300        if info.to_ascii_lowercase().contains("unknown anchor") {
1301            return Error::UnknownAnchor { location };
1302        }
1303
1304        Error::ExternalMessage {
1305            source: ExternalMessageSource::SaphyrParser,
1306            msg: info.to_owned(),
1307            code: None,
1308            params: Vec::new(),
1309            location,
1310        }
1311    }
1312}
1313
1314fn fmt_error_plain_with_formatter(
1315    f: &mut fmt::Formatter<'_>,
1316    err: &Error,
1317    formatter: &dyn MessageFormatter,
1318) -> fmt::Result {
1319    let err = err.without_snippet();
1320
1321    let msg = formatter.format_message(err);
1322
1323    // Validation errors embed per-issue locations in their formatted message (potentially
1324    // multiple distinct locations). Do not attach a single top-level location suffix here,
1325    // or we'd duplicate location wording.
1326    #[cfg(feature = "garde")]
1327    if matches!(err, Error::ValidationError { .. }) {
1328        return write!(f, "{msg}");
1329    }
1330    #[cfg(feature = "validator")]
1331    if matches!(err, Error::ValidatorError { .. }) {
1332        return write!(f, "{msg}");
1333    }
1334
1335    if let Some(loc) = err.location() {
1336        fmt_with_location(f, formatter.localizer(), msg.as_ref(), &loc)?;
1337    } else {
1338        write!(f, "{msg}")?;
1339    }
1340
1341    #[cfg(feature = "garde")]
1342    if let Error::ValidationErrors { errors } = err {
1343        for err in errors {
1344            writeln!(f)?;
1345            writeln!(f)?;
1346            fmt_error_plain_with_formatter(f, err, formatter)?;
1347        }
1348    }
1349
1350    #[cfg(feature = "validator")]
1351    if let Error::ValidatorErrors { errors } = err {
1352        for err in errors {
1353            writeln!(f)?;
1354            writeln!(f)?;
1355            fmt_error_plain_with_formatter(f, err, formatter)?;
1356        }
1357    }
1358
1359    Ok(())
1360}
1361
1362fn pick_cropped_region<'a>(
1363    regions: &'a [CroppedRegion],
1364    location: &Location,
1365) -> Option<&'a CroppedRegion> {
1366    regions
1367        .iter()
1368        .find(|r| r.covers(location))
1369        .or_else(|| regions.first())
1370}
1371
1372fn fmt_error_rendered(
1373    f: &mut fmt::Formatter<'_>,
1374    err: &Error,
1375    options: RenderOptions<'_>,
1376) -> fmt::Result {
1377    if options.snippets == SnippetMode::Off {
1378        return fmt_error_plain_with_formatter(f, err, options.formatter);
1379    }
1380
1381    match err {
1382        #[cfg(feature = "garde")]
1383        Error::ValidationErrors { errors } => {
1384            let msg = options.formatter.format_message(err);
1385            if !msg.is_empty() {
1386                writeln!(f, "{}", msg)?;
1387            }
1388            let mut first = true;
1389            for err in errors {
1390                if !first {
1391                    writeln!(f)?;
1392                    writeln!(f)?;
1393                }
1394                first = false;
1395                fmt_error_rendered(f, err, options)?;
1396            }
1397            Ok(())
1398        }
1399
1400        #[cfg(feature = "validator")]
1401        Error::ValidatorErrors { errors } => {
1402            let msg = options.formatter.format_message(err);
1403            if !msg.is_empty() {
1404                writeln!(f, "{}", msg)?;
1405            }
1406            let mut first = true;
1407            for err in errors {
1408                if !first {
1409                    writeln!(f)?;
1410                    writeln!(f)?;
1411                }
1412                first = false;
1413                fmt_error_rendered(f, err, options)?;
1414            }
1415            Ok(())
1416        }
1417
1418        Error::WithSnippet {
1419            regions,
1420            crop_radius,
1421            error,
1422        } => {
1423            if *crop_radius == 0 {
1424                // Treat as "snippet disabled".
1425                return fmt_error_plain_with_formatter(f, error, options.formatter);
1426            }
1427
1428            if regions.is_empty() {
1429                return fmt_error_plain_with_formatter(f, error, options.formatter);
1430            }
1431
1432            // Validation errors have custom snippet formatting (paths, alias context, and
1433            // messages without location duplication).
1434            #[cfg(feature = "garde")]
1435            if let Error::ValidationError { report, locations } = error.as_ref() {
1436                return fmt_validation_error_with_snippets_offset(
1437                    f,
1438                    options.formatter.localizer(),
1439                    report,
1440                    locations,
1441                    regions,
1442                    *crop_radius,
1443                );
1444            }
1445            #[cfg(feature = "garde")]
1446            if let Error::ValidationErrors { errors } = error.as_ref() {
1447                let msg = options.formatter.format_message(error);
1448                if !msg.is_empty() {
1449                    writeln!(f, "{}", msg)?;
1450                }
1451                let mut first = true;
1452                for err in errors {
1453                    if !first {
1454                        writeln!(f)?;
1455                        writeln!(f)?;
1456                    }
1457                    first = false;
1458                    fmt_error_with_snippets_offset(
1459                        f,
1460                        err,
1461                        regions,
1462                        *crop_radius,
1463                        options.formatter,
1464                    )?;
1465                }
1466                return Ok(());
1467            }
1468
1469            #[cfg(feature = "validator")]
1470            if let Error::ValidatorError { errors, locations } = error.as_ref() {
1471                return fmt_validator_error_with_snippets_offset(
1472                    f,
1473                    options.formatter.localizer(),
1474                    errors,
1475                    locations,
1476                    regions,
1477                    *crop_radius,
1478                );
1479            }
1480            #[cfg(feature = "validator")]
1481            if let Error::ValidatorErrors { errors } = error.as_ref() {
1482                let msg = options.formatter.format_message(error);
1483                if !msg.is_empty() {
1484                    writeln!(f, "{}", msg)?;
1485                }
1486                let mut first = true;
1487                for err in errors {
1488                    if !first {
1489                        writeln!(f)?;
1490                        writeln!(f)?;
1491                    }
1492                    first = false;
1493                    fmt_error_with_snippets_offset(
1494                        f,
1495                        err,
1496                        regions,
1497                        *crop_radius,
1498                        options.formatter,
1499                    )?;
1500                }
1501                return Ok(());
1502            }
1503
1504            // Render a snippet from the cropped source window. If anything is missing,
1505            // fall back to the plain nested error.
1506            let Some(location) = error.location() else {
1507                return fmt_error_plain_with_formatter(f, error, options.formatter);
1508            };
1509            if location == Location::UNKNOWN {
1510                return fmt_error_plain_with_formatter(f, error, options.formatter);
1511            }
1512
1513            let l10n = options.formatter.localizer();
1514
1515            let region = match pick_cropped_region(regions, &location) {
1516                Some(r) => r,
1517                None => return fmt_error_plain_with_formatter(f, error, options.formatter),
1518            };
1519
1520            // Dual-location rendering: show both the reference and the definition window.
1521            let dual_locations = error.locations().filter(|locs| {
1522                locs.reference_location != Location::UNKNOWN
1523                    && locs.defined_location != Location::UNKNOWN
1524                    && locs.reference_location != locs.defined_location
1525            });
1526
1527            let mut msg = options.formatter.format_message(error);
1528
1529            // Renderer-level de-duplication for AliasError:
1530            // when we are about to show a secondary “defined here” window, drop the
1531            // default message suffix " (defined at …)" if present.
1532            if dual_locations.is_some() && let Error::AliasError { locations, .. } = error.as_ref() {
1533                let suffix = l10n.alias_defined_at(locations.defined_location);
1534                if let Some(stripped) = msg.as_ref().strip_suffix(&suffix) {
1535                    msg = Cow::Owned(stripped.to_string());
1536                }
1537            }
1538
1539            if let Some(locs) = dual_locations {
1540                let ref_loc = locs.reference_location;
1541                let def_loc = locs.defined_location;
1542
1543                let used_region = pick_cropped_region(regions, &ref_loc).unwrap_or(region);
1544                let label = l10n.value_used_here();
1545                let ctx = crate::de_snipped::Snippet::new(
1546                    used_region.text.as_str(),
1547                    label.as_ref(),
1548                    *crop_radius,
1549                )
1550                .with_offset(used_region.start_line);
1551                ctx.fmt_or_fallback(f, Level::ERROR, l10n, msg.as_ref(), &ref_loc)?;
1552
1553                let def_region = pick_cropped_region(regions, &def_loc).unwrap_or(region);
1554                writeln!(f)?;
1555                writeln!(f, "{}", l10n.value_comes_from_the_anchor(def_loc))?;
1556                fmt_snippet_window_offset_or_fallback(
1557                    f,
1558                    l10n,
1559                    &def_loc,
1560                    def_region.text.as_str(),
1561                    def_region.start_line,
1562                    l10n.defined_window().as_ref(),
1563                    *crop_radius,
1564                )?;
1565                Ok(())
1566            } else {
1567                // Single location rendering.
1568                let ctx = crate::de_snipped::Snippet::new(region.text.as_str(), "<input>", *crop_radius)
1569                    .with_offset(region.start_line);
1570                ctx.fmt_or_fallback(f, Level::ERROR, l10n, msg.as_ref(), &location)
1571            }
1572        }
1573        _ => fmt_error_plain_with_formatter(f, err, options.formatter),
1574    }
1575}
1576
1577#[cfg(any(feature = "garde", feature = "validator"))]
1578fn search_locations_with_ancestor_fallback(
1579    locations: &PathMap,
1580    path: &PathKey,
1581) -> Option<Locations> {
1582    if let Some((locs, _)) = locations.search(path) {
1583        return Some(locs);
1584    }
1585
1586    let mut p = path.parent();
1587    while let Some(cur) = p {
1588        if let Some((locs, _)) = locations.search(&cur) {
1589            return Some(locs);
1590        }
1591        p = cur.parent();
1592    }
1593
1594    None
1595}
1596
1597impl fmt::Display for Error {
1598    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1599        fmt_error_rendered(
1600            f,
1601            self,
1602            RenderOptions::default(),
1603        )
1604    }
1605}
1606
1607
1608
1609#[cfg(feature = "garde")]
1610fn fmt_validation_error_with_snippets_offset(
1611    f: &mut fmt::Formatter<'_>,
1612    l10n: &dyn Localizer,
1613    report: &garde::Report,
1614    locations: &PathMap,
1615    regions: &[CroppedRegion],
1616    crop_radius: usize,
1617) -> fmt::Result {
1618    let mut first = true;
1619    for (path, entry) in report.iter() {
1620        if !first {
1621            writeln!(f)?;
1622        }
1623        first = false;
1624
1625        let path_key = path_key_from_garde(path);
1626        let original_leaf = path_key
1627            .leaf_string()
1628            .unwrap_or_else(|| l10n.root_path_label().into_owned());
1629
1630        let (locs, resolved_leaf) = locations
1631            .search(&path_key)
1632            .unwrap_or((Locations::UNKNOWN, original_leaf));
1633
1634        let ref_loc = locs.reference_location;
1635        let def_loc = locs.defined_location;
1636
1637        let resolved_path = format_path_with_resolved_leaf(&path_key, &resolved_leaf);
1638        let entry_raw = entry.to_string();
1639        let entry = l10n
1640            .override_external_message(ExternalMessage {
1641                source: ExternalMessageSource::Garde,
1642                original: entry_raw.as_str(),
1643                code: None,
1644                params: &[],
1645            })
1646            .unwrap_or(Cow::Borrowed(entry_raw.as_str()));
1647        let base_msg = l10n.validation_base_message(entry.as_ref(), &resolved_path);
1648
1649        match (ref_loc, def_loc) {
1650            (Location::UNKNOWN, Location::UNKNOWN) => {
1651                write!(f, "{base_msg}")?;
1652            }
1653            (r, d) if r != Location::UNKNOWN && (d == Location::UNKNOWN || d == r) => {
1654                let label = l10n.defined();
1655                if let Some(region) = pick_cropped_region(regions, &r) {
1656                    let ctx = crate::de_snipped::Snippet::new(
1657                        region.text.as_str(),
1658                        label.as_ref(),
1659                        crop_radius,
1660                    )
1661                    .with_offset(region.start_line);
1662                    ctx.fmt_or_fallback(f, Level::ERROR, l10n, &base_msg, &r)?;
1663                } else {
1664                    fmt_with_location(f, l10n, &base_msg, &r)?;
1665                }
1666            }
1667            (r, d) if r == Location::UNKNOWN && d != Location::UNKNOWN => {
1668                let label = l10n.defined_here();
1669                if let Some(region) = pick_cropped_region(regions, &d) {
1670                    let ctx = crate::de_snipped::Snippet::new(
1671                        region.text.as_str(),
1672                        label.as_ref(),
1673                        crop_radius,
1674                    )
1675                    .with_offset(region.start_line);
1676                    ctx.fmt_or_fallback(f, Level::ERROR, l10n, &base_msg, &d)?;
1677                } else {
1678                    fmt_with_location(f, l10n, &base_msg, &d)?;
1679                }
1680            }
1681            (r, d) => {
1682                let label = l10n.value_used_here();
1683                let invalid_here = l10n.invalid_here(&base_msg);
1684                if let Some(region) = pick_cropped_region(regions, &r) {
1685                    let ctx = crate::de_snipped::Snippet::new(
1686                        region.text.as_str(),
1687                        label.as_ref(),
1688                        crop_radius,
1689                    )
1690                    .with_offset(region.start_line);
1691                    ctx.fmt_or_fallback(f, Level::ERROR, l10n, &invalid_here, &r)?;
1692                } else {
1693                    fmt_with_location(f, l10n, &invalid_here, &r)?;
1694                }
1695                writeln!(f)?;
1696                writeln!(f, "{}", l10n.value_comes_from_the_anchor(d))?;
1697                if let Some(region) = pick_cropped_region(regions, &d) {
1698                    crate::de_snipped::fmt_snippet_window_offset_or_fallback(
1699                        f,
1700                        l10n,
1701                        &d,
1702                        region.text.as_str(),
1703                        region.start_line,
1704                        l10n.defined_window().as_ref(),
1705                        crop_radius,
1706                    )?;
1707                } else {
1708                    fmt_with_location(f, l10n, l10n.defined_window().as_ref(), &d)?;
1709                }
1710            }
1711        }
1712    }
1713    Ok(())
1714}
1715
1716
1717#[cfg(feature = "validator")]
1718fn fmt_validator_error_with_snippets_offset(
1719    f: &mut fmt::Formatter<'_>,
1720    l10n: &dyn Localizer,
1721    errors: &ValidationErrors,
1722    locations: &PathMap,
1723    regions: &[CroppedRegion],
1724    crop_radius: usize,
1725) -> fmt::Result {
1726    let entries = collect_validator_issues(errors);
1727    let mut first = true;
1728
1729    for issue in entries {
1730        if !first {
1731            writeln!(f)?;
1732        }
1733        first = false;
1734
1735        let original_leaf = issue
1736            .path
1737            .leaf_string()
1738            .unwrap_or_else(|| l10n.root_path_label().into_owned());
1739        let (locs, resolved_leaf) = locations
1740            .search(&issue.path)
1741            .unwrap_or((Locations::UNKNOWN, original_leaf));
1742
1743        let resolved_path = format_path_with_resolved_leaf(&issue.path, &resolved_leaf);
1744        let entry = issue.display_entry_overridden(l10n, ExternalMessageSource::Validator);
1745        let base_msg = l10n.validation_base_message(&entry, &resolved_path);
1746
1747        match (locs.reference_location, locs.defined_location) {
1748            (Location::UNKNOWN, Location::UNKNOWN) => {
1749                write!(f, "{base_msg}")?;
1750            }
1751            (r, d) if r != Location::UNKNOWN && (d == Location::UNKNOWN || d == r) => {
1752                let label = l10n.defined();
1753                if let Some(region) = pick_cropped_region(regions, &r) {
1754                    let ctx = crate::de_snipped::Snippet::new(
1755                        region.text.as_str(),
1756                        label.as_ref(),
1757                        crop_radius,
1758                    )
1759                    .with_offset(region.start_line);
1760                    ctx.fmt_or_fallback(f, Level::ERROR, l10n, &base_msg, &r)?;
1761                } else {
1762                    fmt_with_location(f, l10n, &base_msg, &r)?;
1763                }
1764            }
1765            (r, d) if r == Location::UNKNOWN && d != Location::UNKNOWN => {
1766                let label = l10n.defined_here();
1767                if let Some(region) = pick_cropped_region(regions, &d) {
1768                    let ctx = crate::de_snipped::Snippet::new(
1769                        region.text.as_str(),
1770                        label.as_ref(),
1771                        crop_radius,
1772                    )
1773                    .with_offset(region.start_line);
1774                    ctx.fmt_or_fallback(f, Level::ERROR, l10n, &base_msg, &d)?;
1775                } else {
1776                    fmt_with_location(f, l10n, &base_msg, &d)?;
1777                }
1778            }
1779            (r, d) => {
1780                let label = l10n.value_used_here();
1781                let invalid_here = l10n.invalid_here(&base_msg);
1782                if let Some(region) = pick_cropped_region(regions, &r) {
1783                    let ctx = crate::de_snipped::Snippet::new(
1784                        region.text.as_str(),
1785                        label.as_ref(),
1786                        crop_radius,
1787                    )
1788                    .with_offset(region.start_line);
1789                    ctx.fmt_or_fallback(f, Level::ERROR, l10n, &invalid_here, &r)?;
1790                } else {
1791                    fmt_with_location(f, l10n, &invalid_here, &r)?;
1792                }
1793                writeln!(f)?;
1794                writeln!(f, "{}", l10n.value_comes_from_the_anchor(d))?;
1795                if let Some(region) = pick_cropped_region(regions, &d) {
1796                    crate::de_snipped::fmt_snippet_window_offset_or_fallback(
1797                        f,
1798                        l10n,
1799                        &d,
1800                        region.text.as_str(),
1801                        region.start_line,
1802                        l10n.defined_window().as_ref(),
1803                        crop_radius,
1804                    )?;
1805                } else {
1806                    fmt_with_location(f, l10n, l10n.defined_window().as_ref(), &d)?;
1807                }
1808            }
1809        }
1810    }
1811
1812    Ok(())
1813}
1814
1815#[cfg(any(feature = "garde", feature = "validator"))]
1816fn fmt_error_with_snippets_offset(
1817    f: &mut fmt::Formatter<'_>,
1818    err: &Error,
1819    regions: &[CroppedRegion],
1820    crop_radius: usize,
1821    formatter: &dyn MessageFormatter,
1822) -> fmt::Result {
1823    if crop_radius == 0 {
1824        return fmt_error_plain_with_formatter(f, err, formatter);
1825    }
1826
1827    // Keep existing snippet output if the nested error is already wrapped.
1828    if let Error::WithSnippet { .. } = err {
1829        return fmt_error_rendered(f, err, RenderOptions::new(formatter));
1830    }
1831
1832    #[cfg(feature = "garde")]
1833    if let Error::ValidationError { report, locations } = err {
1834        return fmt_validation_error_with_snippets_offset(
1835            f,
1836            formatter.localizer(),
1837            report,
1838            locations,
1839            regions,
1840            crop_radius,
1841        );
1842    }
1843
1844    #[cfg(feature = "validator")]
1845    if let Error::ValidatorError { errors, locations } = err {
1846        return fmt_validator_error_with_snippets_offset(
1847            f,
1848            formatter.localizer(),
1849            errors,
1850            locations,
1851            regions,
1852            crop_radius,
1853        );
1854    }
1855
1856    let msg = formatter.format_message(err);
1857    let Some(location) = err.location() else {
1858        return write!(f, "{msg}");
1859    };
1860    if location == Location::UNKNOWN {
1861        return write!(f, "{msg}");
1862    }
1863
1864    let Some(region) = pick_cropped_region(regions, &location) else {
1865        return fmt_with_location(f, formatter.localizer(), msg.as_ref(), &location);
1866    };
1867    let ctx = crate::de_snipped::Snippet::new(region.text.as_str(), "<input>", crop_radius)
1868        .with_offset(region.start_line);
1869    ctx.fmt_or_fallback(f, Level::ERROR, formatter.localizer(), msg.as_ref(), &location)
1870}
1871
1872#[cfg(feature = "validator")]
1873pub(crate) fn collect_validator_issues(errors: &ValidationErrors) -> Vec<ValidationIssue> {
1874    let mut out = Vec::new();
1875    let root = PathKey::empty();
1876    collect_validator_issues_inner(errors, &root, &mut out);
1877    out
1878}
1879
1880#[cfg(feature = "validator")]
1881fn collect_validator_issues_inner(
1882    errors: &ValidationErrors,
1883    path: &PathKey,
1884    out: &mut Vec<ValidationIssue>,
1885) {
1886    for (field, kind) in errors.errors() {
1887        let field_path = path.clone().join(field.as_ref());
1888        match kind {
1889            ValidationErrorsKind::Field(entries) => {
1890                for entry in entries {
1891                    let mut params = Vec::new();
1892                    for (k, v) in &entry.params {
1893                        params.push((k.to_string(), v.to_string()));
1894                    }
1895
1896                    out.push(ValidationIssue {
1897                        path: field_path.clone(),
1898                        code: entry.code.to_string(),
1899                        message: entry.message.as_ref().map(|m| m.to_string()),
1900                        params,
1901                    });
1902                }
1903            }
1904            ValidationErrorsKind::Struct(inner) => {
1905                collect_validator_issues_inner(inner, &field_path, out);
1906            }
1907            ValidationErrorsKind::List(list) => {
1908                for (idx, inner) in list {
1909                    let index_path = field_path.clone().join(*idx);
1910                    collect_validator_issues_inner(inner, &index_path, out);
1911                }
1912            }
1913        }
1914    }
1915}
1916
1917#[cfg(feature = "garde")]
1918pub(crate) fn collect_garde_issues(report: &garde::Report) -> Vec<ValidationIssue> {
1919    let mut out = Vec::new();
1920    for (path, entry) in report.iter() {
1921        out.push(ValidationIssue {
1922            path: path_key_from_garde(path),
1923            code: "garde".to_string(),
1924            message: Some(entry.message().to_string()),
1925            params: Vec::new(),
1926        });
1927    }
1928    out
1929}
1930impl std::error::Error for Error {}
1931
1932/// Attach the current [`MISSING_FIELD_FALLBACK`] location to `err`, if available.
1933fn maybe_attach_fallback_location(mut err: Error) -> Error {
1934    let loc = MISSING_FIELD_FALLBACK.with(|c| c.get());
1935    if let Some(loc) = loc
1936        && loc != Location::UNKNOWN
1937    {
1938        err = err.with_location(loc);
1939    }
1940    err
1941}
1942
1943impl de::Error for Error {
1944    fn custom<T: fmt::Display>(msg: T) -> Self {
1945        // Keep custom errors locationless by default; the deserializer should attach an explicit
1946        // location when it can. For Serde-generated errors, we override the relevant hooks below
1947        // and attach a best-effort fallback location.
1948        Error::msg(msg.to_string())
1949    }
1950
1951    fn invalid_type(unexp: de::Unexpected, exp: &dyn de::Expected) -> Self {
1952        // Mirror serde’s default formatting, but add a best-effort location.
1953        maybe_attach_fallback_location(Error::SerdeInvalidType {
1954            unexpected: unexp.to_string(),
1955            expected: exp.to_string(),
1956            location: Location::UNKNOWN,
1957        })
1958    }
1959
1960    fn invalid_value(unexp: de::Unexpected, exp: &dyn de::Expected) -> Self {
1961        maybe_attach_fallback_location(Error::SerdeInvalidValue {
1962            unexpected: unexp.to_string(),
1963            expected: exp.to_string(),
1964            location: Location::UNKNOWN,
1965        })
1966    }
1967
1968    fn unknown_variant(variant: &str, expected: &'static [&'static str]) -> Self {
1969        maybe_attach_fallback_location(Error::SerdeUnknownVariant {
1970            variant: variant.to_owned(),
1971            expected: expected.to_vec(),
1972            location: Location::UNKNOWN,
1973        })
1974    }
1975
1976    fn unknown_field(field: &str, expected: &'static [&'static str]) -> Self {
1977        maybe_attach_fallback_location(Error::SerdeUnknownField {
1978            field: field.to_owned(),
1979            expected: expected.to_vec(),
1980            location: Location::UNKNOWN,
1981        })
1982    }
1983
1984    fn missing_field(field: &'static str) -> Self {
1985        maybe_attach_fallback_location(Error::SerdeMissingField {
1986            field,
1987            location: Location::UNKNOWN,
1988        })
1989    }
1990}
1991
1992/// Print a message optionally suffixed with "at line X, column Y".
1993///
1994/// Arguments:
1995/// - `f`: destination formatter.
1996/// - `msg`: main text.
1997/// - `location`: position to attach if known.
1998///
1999/// Returns:
2000/// - `fmt::Result` as required by `Display`.
2001#[cold]
2002#[inline(never)]
2003fn fmt_with_location(
2004    f: &mut fmt::Formatter<'_>,
2005    l10n: &dyn Localizer,
2006    msg: &str,
2007    location: &Location,
2008) -> fmt::Result {
2009    let out = l10n.attach_location(Cow::Borrowed(msg), *location);
2010    write!(f, "{out}")
2011}
2012
2013
2014/// Convert a budget breach report into a user-facing error.
2015///
2016/// Arguments:
2017/// - `breach`: which limit was exceeded (from the streaming budget checker).
2018///
2019/// Returns:
2020/// - `Error::Message` with a formatted description.
2021///
2022/// Called by:
2023/// - The live events layer when enforcing budgets during/after parsing.
2024#[cold]
2025#[inline(never)]
2026pub(crate) fn budget_error(breach: BudgetBreach) -> Error {
2027    Error::Budget {
2028        breach,
2029        location: Location::UNKNOWN,
2030    }
2031}
2032
2033#[cfg(test)]
2034mod tests {
2035    use super::*;
2036
2037    #[test]
2038    fn locations_for_basic_error_duplicates_location() {
2039        let l = Location::new(3, 7);
2040        let err = Error::Message {
2041            msg: "x".to_owned(),
2042            location: l,
2043        };
2044        assert_eq!(
2045            err.locations(),
2046            Some(Locations {
2047                reference_location: l,
2048                defined_location: l,
2049            })
2050        );
2051    }
2052
2053    #[test]
2054    fn locations_for_io_error_is_unknown() {
2055        let err = Error::IOError {
2056            cause: std::io::Error::other("x"),
2057        };
2058        assert_eq!(err.locations(), None);
2059    }
2060
2061    #[test]
2062    fn alias_error_returns_both_locations() {
2063        let ref_loc = Location::new(5, 10);
2064        let def_loc = Location::new(2, 3);
2065        let err = Error::AliasError {
2066            msg: "test error".to_owned(),
2067            locations: Locations {
2068                reference_location: ref_loc,
2069                defined_location: def_loc,
2070            },
2071        };
2072
2073        // location() should return the primary (reference) location
2074        assert_eq!(err.location(), Some(ref_loc));
2075
2076        // locations() should return both
2077        assert_eq!(
2078            err.locations(),
2079            Some(Locations {
2080                reference_location: ref_loc,
2081                defined_location: def_loc,
2082            })
2083        );
2084    }
2085
2086    #[test]
2087    fn alias_error_display_shows_both_locations() {
2088        let ref_loc = Location::new(5, 10);
2089        let def_loc = Location::new(2, 3);
2090        let err = Error::AliasError {
2091            msg: "invalid value".to_owned(),
2092            locations: Locations {
2093                reference_location: ref_loc,
2094                defined_location: def_loc,
2095            },
2096        };
2097
2098        let display = err.to_string();
2099        assert!(display.contains("invalid value"));
2100        assert!(display.contains("line 5"));
2101        assert!(display.contains("column 10"));
2102        assert!(display.contains("line 2"));
2103        assert!(display.contains("column 3"));
2104    }
2105
2106    #[test]
2107    fn alias_error_display_with_same_locations() {
2108        let loc = Location::new(3, 7);
2109        let err = Error::AliasError {
2110            msg: "test".to_owned(),
2111            locations: Locations {
2112                reference_location: loc,
2113                defined_location: loc,
2114            },
2115        };
2116
2117        let display = err.to_string();
2118        // When both locations are the same, should only show one
2119        assert!(display.contains("line 3"));
2120        assert!(display.contains("column 7"));
2121        // Should not contain "defined at" since locations are the same
2122        assert!(!display.contains("defined at"));
2123    }
2124
2125    #[test]
2126    fn with_snippet_counts_trailing_empty_line_for_end_line() {
2127        // `"a\n"` has two logical lines: "a" and a trailing empty line.
2128        let text = "a\n";
2129        let err = Error::Message {
2130            msg: "x".to_owned(),
2131            location: Location::new(2, 1),
2132        };
2133
2134        let wrapped = err.with_snippet(text, 50);
2135        let Error::WithSnippet { regions, .. } = wrapped else {
2136            panic!("expected WithSnippet wrapper");
2137        };
2138        assert_eq!(regions.len(), 1);
2139        assert_eq!(regions[0].start_line, 1);
2140        assert_eq!(regions[0].end_line, 2);
2141    }
2142
2143    #[test]
2144    fn with_snippet_offset_counts_trailing_empty_line_for_end_line() {
2145        // Fragment starts at line 10, and ends with a newline -> includes empty line 11.
2146        let text = "a\n";
2147        let err = Error::Message {
2148            msg: "x".to_owned(),
2149            location: Location::new(11, 1),
2150        };
2151
2152        let wrapped = err.with_snippet_offset(text, 10, 50);
2153        let Error::WithSnippet { regions, .. } = wrapped else {
2154            panic!("expected WithSnippet wrapper");
2155        };
2156        assert_eq!(regions.len(), 1);
2157        assert_eq!(regions[0].start_line, 10);
2158        assert_eq!(regions[0].end_line, 11);
2159    }
2160
2161    #[cfg(feature = "validator")]
2162    #[test]
2163    fn locations_for_validator_error_uses_first_entry() {
2164        use validator::Validate;
2165
2166        #[derive(Debug, Validate)]
2167        struct Cfg {
2168            #[validate(length(min = 2))]
2169            second_string: String,
2170        }
2171
2172        let cfg = Cfg {
2173            second_string: "x".to_owned(),
2174        };
2175        let errors = cfg.validate().expect_err("validation error expected");
2176
2177        let referenced_loc = Location::new(3, 15);
2178        let defined_loc = Location::new(2, 18);
2179
2180        let mut locations = PathMap::new();
2181        locations.insert(
2182            PathKey::empty().join("secondString"),
2183            Locations {
2184                reference_location: referenced_loc,
2185                defined_location: defined_loc,
2186            },
2187        );
2188
2189        let err = Error::ValidatorError { errors, locations };
2190        assert_eq!(
2191            err.locations(),
2192            Some(Locations {
2193                reference_location: referenced_loc,
2194                defined_location: defined_loc,
2195            })
2196        );
2197    }
2198
2199    #[test]
2200    fn nested_snippet_preserves_custom_formatter() {
2201        struct Custom;
2202        impl MessageFormatter for Custom {
2203            fn localizer(&self) -> &dyn Localizer {
2204                &DEFAULT_ENGLISH_LOCALIZER
2205            }
2206            fn format_message<'a>(&self, err: &'a Error) -> Cow<'a, str> {
2207                match err {
2208                    Error::Message { msg, .. } => Cow::Owned(format!("CUSTOM: {}", msg.as_str())),
2209                    _ => Cow::Borrowed(""),
2210                }
2211            }
2212        }
2213        let loc = Location::new(1, 1);
2214        let base = Error::Message {
2215            msg: "original".to_string(),
2216            location: loc,
2217        };
2218        let text = "input";
2219        let start_line = 1;
2220        let radius = 1;
2221        let inner = base.with_snippet_offset(text, start_line, radius);
2222        let outer = inner.with_snippet_offset(text, start_line, radius);
2223        let rendered = outer.render_with_options(RenderOptions::new(&Custom));
2224        assert!(rendered.contains("CUSTOM: original"));
2225    }
2226
2227    #[test]
2228    fn alias_error_dual_snippet_rendering() {
2229        // YAML with anchor on line 2 and alias usage on line 5
2230        let yaml = r#"config:
2231  anchor: &myval 42
2232  other: stuff
2233  more: data
2234  use_it: *myval
2235"#;
2236        // Reference location: line 5, column 11 (where *myval is used)
2237        let ref_loc = Location::new(5, 11);
2238        // Defined location: line 2, column 11 (where &myval is defined)
2239        let def_loc = Location::new(2, 11);
2240
2241        let err = Error::AliasError {
2242            msg: "invalid value type".to_owned(),
2243            locations: Locations {
2244                reference_location: ref_loc,
2245                defined_location: def_loc,
2246            },
2247        };
2248
2249        // Wrap with snippet
2250        let wrapped = err.with_snippet(yaml, 5);
2251        let rendered = wrapped.render();
2252
2253        // Should contain the error message
2254        assert!(rendered.contains("invalid value type"), "rendered: {}", rendered);
2255
2256        // When a secondary snippet window is shown, avoid duplicating the alias
2257        // "defined at …" suffix in the main message.
2258        assert!(
2259            !rendered.contains("(defined at line"),
2260            "did not expect alias defined-at suffix when secondary window is present: {}",
2261            rendered
2262        );
2263        // Should show "the value is used here" for the reference location
2264        assert!(rendered.contains("the value is used here") || rendered.contains("use_it"), 
2265            "rendered should show reference location context: {}", rendered);
2266        // Should show "defined here" for the anchor location
2267        assert!(rendered.contains("defined here") || rendered.contains("anchor"), 
2268            "rendered should show defined location context: {}", rendered);
2269        // Should mention both line numbers in some form
2270        assert!(rendered.contains("5") || rendered.contains("use_it"), 
2271            "rendered should reference line 5: {}", rendered);
2272        assert!(rendered.contains("2") || rendered.contains("anchor"), 
2273            "rendered should reference line 2: {}", rendered);
2274    }
2275
2276    #[test]
2277    fn alias_error_same_location_single_snippet() {
2278        let yaml = "value: &anchor 42\n";
2279        let loc = Location::new(1, 8);
2280
2281        let err = Error::AliasError {
2282            msg: "test error".to_owned(),
2283            locations: Locations {
2284                reference_location: loc,
2285                defined_location: loc,
2286            },
2287        };
2288
2289        let wrapped = err.with_snippet(yaml, 5);
2290        let rendered = wrapped.render();
2291
2292        // Should contain the error message
2293        assert!(rendered.contains("test error"), "rendered: {}", rendered);
2294        // Should NOT show dual-snippet labels when locations are the same
2295        assert!(!rendered.contains("defined here"), 
2296            "should not show 'defined here' when locations are same: {}", rendered);
2297        assert!(!rendered.contains("the value is used here"), 
2298            "should not show 'value used here' when locations are same: {}", rendered);
2299    }
2300}