Skip to main content

serde_saphyr/
de_error.rs

1//! Defines error and its location
2use crate::Location;
3use crate::budget::BudgetBreach;
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 std::cell::RefCell;
16use std::fmt;
17#[cfg(feature = "validator")]
18use validator::{ValidationErrors, ValidationErrorsKind};
19
20thread_local! {
21    // Best-effort fallback location for Serde structural errors that have no inherent span,
22    // such as `missing_field`. This is set by the deserializer when entering a container.
23    static MISSING_FIELD_FALLBACK: RefCell<Option<Location>> = const { RefCell::new(None) };
24}
25
26pub(crate) struct MissingFieldLocationGuard {
27    prev: Option<Location>,
28}
29
30impl MissingFieldLocationGuard {
31    pub(crate) fn new(location: Location) -> Self {
32        let prev = MISSING_FIELD_FALLBACK.with(|c| c.replace(Some(location)));
33        Self { prev }
34    }
35}
36
37impl Drop for MissingFieldLocationGuard {
38    fn drop(&mut self) {
39        MISSING_FIELD_FALLBACK.with(|c| {
40            c.replace(self.prev.take());
41        });
42    }
43}
44
45/// The reason why a string value was transformed during parsing and cannot be borrowed.
46///
47/// When deserializing to `&str`, the value must exist verbatim in the input. However,
48/// certain YAML constructs require string transformation, making borrowing impossible.
49#[non_exhaustive]
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum TransformReason {
52    /// Escape sequences were processed (e.g., `\n`, `\t`, `\uXXXX` in double-quoted strings).
53    EscapeSequence,
54    /// Line folding was applied (folded block scalar `>`).
55    LineFolding,
56    /// Multi-line plain or quoted scalar with whitespace normalization.
57    MultiLineNormalization,
58    /// Block scalar processing (literal `|` or folded `>` with chomping/indentation).
59    BlockScalarProcessing,
60    /// Single-quoted string with `''` escape processing.
61    SingleQuoteEscape,
62    /// Borrowing is not supported because the deserializer does not have access to the full input
63    /// buffer (for example, when deserializing from a `Read`er), or because the parser did not
64    /// provide a slice that is a subslice of the original input.
65    InputNotBorrowable,
66
67    /// The parser returned an owned string for this scalar.
68    ///
69    /// In newer `saphyr-parser` versions, zero-copy is represented directly as `Cow::Borrowed`.
70    /// If a scalar comes through as `Cow::Owned`, the deserializer cannot safely fabricate a
71    /// borrow, because it would not refer to the original input buffer.
72    ParserReturnedOwned,
73}
74
75impl fmt::Display for TransformReason {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        match self {
78            TransformReason::EscapeSequence => write!(f, "escape sequence processing"),
79            TransformReason::LineFolding => write!(f, "line folding"),
80            TransformReason::MultiLineNormalization => write!(f, "multi-line whitespace normalization"),
81            TransformReason::BlockScalarProcessing => write!(f, "block scalar processing"),
82            TransformReason::SingleQuoteEscape => write!(f, "single-quote escape processing"),
83            TransformReason::InputNotBorrowable => write!(f, "input is not available for borrowing"),
84            TransformReason::ParserReturnedOwned => write!(f, "parser returned an owned string"),
85        }
86    }
87}
88
89/// Error type compatible with `serde::de::Error`.
90#[non_exhaustive]
91#[derive(Debug)]
92pub enum Error {
93    /// Free-form error with optional source location.
94    Message {
95        msg: String,
96        location: Location,
97    },
98    /// Unexpected end of input.
99    Eof {
100        location: Location,
101    },
102    /// Structural/type mismatch — something else than the expected token/value was seen.
103    Unexpected {
104        expected: &'static str,
105        location: Location,
106    },
107    ContainerEndMismatch {
108        location: Location,
109    },
110    /// Alias references a non-existent anchor id.
111    UnknownAnchor {
112        id: usize,
113        location: Location,
114    },
115    /// Error related to an alias, with both reference (use-site) and defined (anchor) locations.
116    ///
117    /// This variant allows reporting both where an alias is used and where the anchor is defined,
118    /// which is useful for errors that occur when deserializing aliased values.
119    AliasError {
120        msg: String,
121        locations: Locations,
122    },
123    /// Error when parsing robotic and other extensions beyond standard YAML.
124    /// (error in extension hook).
125    HookError {
126        msg: String,
127        location: Location,
128    },
129    /// A YAML budget limit was exceeded.
130    Budget {
131        breach: BudgetBreach,
132        location: Location,
133    },
134    /// Unexpected I/O error. This may happen only when deserializing from a reader.
135    IOError {
136        cause: std::io::Error,
137    },
138    /// The value is targeted to the string field but can be interpreted as a number or boolean.
139    /// This error can only happen if no_schema set true.
140    QuotingRequired {
141        value: String, // sanitized (checked) value that must be quoted
142        location: Location,
143    },
144
145    /// The target type requires a borrowed string (`&str`), but the value was transformed
146    /// during parsing (e.g., through escape processing, line folding, or multi-line normalization)
147    /// and cannot be borrowed from the input.
148    ///
149    /// Use `String` or `Cow<str>` instead of `&str` to handle transformed values.
150    CannotBorrowTransformedString {
151        /// The reason why the string had to be transformed and cannot be borrowed.
152        reason: TransformReason,
153        location: Location,
154    },
155
156    /// Wrap an error with the full input text, enabling rustc-like snippet rendering.
157    WithSnippet {
158        /// Pre-rendered snippet output (cropped) for display.
159        ///
160        /// Note: this intentionally does NOT store the full input text, to avoid
161        /// retaining large YAML inputs inside errors.
162        text: String,
163        crop_radius: usize,
164        error: Box<Error>,
165    },
166
167    /// Garde validation failure.
168    #[cfg(feature = "garde")]
169    ValidationError {
170        report: garde::Report,
171        locations: PathMap,
172    },
173
174    /// Garde validation failures (multiple, if multiple validations fail)
175    #[cfg(feature = "garde")]
176    ValidationErrors {
177        errors: Vec<Error>,
178    },
179
180    /// Validator validation failure.
181    #[cfg(feature = "validator")]
182    ValidatorError {
183        errors: ValidationErrors,
184        locations: PathMap,
185    },
186
187    /// Validator validation failures (multiple, if multiple validations fail)
188    #[cfg(feature = "validator")]
189    ValidatorErrors {
190        errors: Vec<Error>,
191    },
192}
193
194impl Error {
195    #[cold]
196    #[inline(never)]
197    pub(crate) fn with_snippet(self, text: &str, crop_radius: usize) -> Self {
198        // Avoid nesting snippet wrappers: keep the innermost error and rebuild the
199        // wrapper with freshly rendered/cropped snippet output.
200        let inner = match self {
201            Error::WithSnippet { error, .. } => *error,
202            other => other,
203        };
204
205        let rendered = crate::de_snipped::render_error_with_snippets(&inner, text, crop_radius);
206
207        Error::WithSnippet {
208            text: rendered,
209            crop_radius,
210            error: Box::new(inner),
211        }
212    }
213
214    /// Attach a snippet from a partial YAML fragment (e.g., from `RingReader`).
215    ///
216    /// This is similar to `with_snippet`, but the `text` is a fragment that starts
217    /// at `start_line` (1-based) rather than at line 1. The renderer will adjust
218    /// line numbers accordingly.
219    #[cold]
220    #[inline(never)]
221    pub(crate) fn with_snippet_offset(
222        self,
223        text: &str,
224        start_line: usize,
225        crop_radius: usize,
226    ) -> Self {
227        let inner = match self {
228            Error::WithSnippet { error, .. } => *error,
229            other => other,
230        };
231
232        let rendered =
233            crate::de_snipped::render_error_with_snippets_offset(&inner, text, start_line, crop_radius);
234
235        Error::WithSnippet {
236            text: rendered,
237            crop_radius,
238            error: Box::new(inner),
239        }
240    }
241
242    /// Provide "no snippet" version for cases when snippet rendering is not  desired.
243    pub fn without_snippet(&self) -> &Self {
244        match self {
245            Error::WithSnippet { error, .. } => error,
246            other => other,
247        }
248    }
249
250    /// Construct a `Message` error with no known location.
251    ///
252    /// Arguments:
253    /// - `s`: human-readable message.
254    ///
255    /// Returns:
256    /// - `Error::Message` pointing at [`Location::UNKNOWN`].
257    ///
258    /// Called by:
259    /// - Scalar parsers and helpers throughout this module.
260    #[cold]
261    #[inline(never)]
262    pub(crate) fn msg<S: Into<String>>(s: S) -> Self {
263        Error::Message {
264            msg: s.into(),
265            location: Location::UNKNOWN,
266        }
267    }
268
269    /// Construct a `QuotingRequired` error with no known location.
270    /// Called by:
271    /// - Deserializer, when deserializing into string if no_schema set to true.
272    #[cold]
273    #[inline(never)]
274    pub(crate) fn quoting_required(value: &str) -> Self {
275        // Ensure the value really is like number or boolean (do not reflect back content
276        // that may be used for attack)
277        let location = Location::UNKNOWN;
278        let value = if parse_yaml12_float::<f64>(value, location, SfTag::None, false).is_ok()
279            || parse_int_signed::<i128>(value, "i128", location, false).is_ok()
280            || parse_yaml11_bool(value).is_ok()
281            || scalar_is_nullish(value, &ScalarStyle::Plain)
282        {
283            value.to_string()
284        } else {
285            String::new()
286        };
287        Error::QuotingRequired { value, location }
288    }
289
290    /// Convenience for an `Unexpected` error pre-filled with a human phrase.
291    ///
292    /// Arguments:
293    /// - `what`: short description like "sequence start".
294    ///
295    /// Returns:
296    /// - `Error::Unexpected` at unknown location.
297    ///
298    /// Called by:
299    /// - Deserializer methods that validate the next event kind.
300    #[cold]
301    #[inline(never)]
302    pub(crate) fn unexpected(what: &'static str) -> Self {
303        Error::Unexpected {
304            expected: what,
305            location: Location::UNKNOWN,
306        }
307    }
308
309    /// Construct an unexpected end-of-input error with unknown location.
310    ///
311    /// Used by:
312    /// - Lookahead and pull methods when `None` appears prematurely.
313    #[cold]
314    #[inline(never)]
315    pub(crate) fn eof() -> Self {
316        Error::Eof {
317            location: Location::UNKNOWN,
318        }
319    }
320
321    /// Construct an `UnknownAnchor` error for the given anchor id (unknown location).
322    ///
323    /// Called by:
324    /// - Alias replay logic in the live event source.
325    #[cold]
326    #[inline(never)]
327    pub(crate) fn unknown_anchor(id: usize) -> Self {
328        Error::UnknownAnchor {
329            id,
330            location: Location::UNKNOWN,
331        }
332    }
333
334    /// Construct a `CannotBorrowTransformedString` error for the given reason.
335    ///
336    /// This error is returned when deserializing to `&str` but the string value
337    /// was transformed during parsing and cannot be borrowed from the input.
338    #[cold]
339    #[inline(never)]
340    pub fn cannot_borrow_transformed(reason: TransformReason) -> Self {
341        Error::CannotBorrowTransformedString {
342            reason,
343            location: Location::UNKNOWN,
344        }
345    }
346
347    /// Attach/override a concrete location to this error and return it.
348    ///
349    /// Arguments:
350    /// - `set_location`: location to store in the error.
351    ///
352    /// Returns:
353    /// - The same `Error` with location updated.
354    ///
355    /// Called by:
356    /// - Most error paths once the event position becomes known.
357    #[cold]
358    #[inline(never)]
359    pub(crate) fn with_location(mut self, set_location: Location) -> Self {
360        match &mut self {
361            Error::Message { location, .. }
362            | Error::Eof { location }
363            | Error::Unexpected { location, .. }
364            | Error::HookError { location, .. }
365            | Error::ContainerEndMismatch { location, .. }
366            | Error::UnknownAnchor { location, .. }
367            | Error::QuotingRequired { location, .. }
368            | Error::Budget { location, .. }
369            | Error::CannotBorrowTransformedString { location, .. } => {
370                *location = set_location;
371            }
372            Error::IOError { .. } => {} // this error does not support location
373            Error::AliasError { .. } => {
374                // AliasError carries its own Locations; don't override with a single location.
375            }
376            Error::WithSnippet { error, .. } => {
377                let inner = *std::mem::replace(error, Box::new(Error::eof()));
378                **error = inner.with_location(set_location);
379            }
380            #[cfg(feature = "garde")]
381            Error::ValidationError { .. } => {
382                // Validation errors carry their own per-path locations.
383            }
384            #[cfg(feature = "garde")]
385            Error::ValidationErrors { .. } => {
386                // Aggregate validation errors carry their own per-entry locations.
387            }
388            #[cfg(feature = "validator")]
389            Error::ValidatorError { .. } => {
390                // Validation errors carry their own per-path locations.
391            }
392            #[cfg(feature = "validator")]
393            Error::ValidatorErrors { .. } => {
394                // Aggregate validation errors carry their own per-entry locations.
395            }
396        }
397        self
398    }
399
400    /// If the error has a known location, return it.
401    ///
402    /// Returns:
403    /// - `Some(Location)` when coordinates are known; `None` otherwise.
404    ///
405    /// Used by:
406    /// - Callers that want to surface precise positions to users.
407    pub fn location(&self) -> Option<Location> {
408        match self {
409            Error::Message { location, .. }
410            | Error::Eof { location }
411            | Error::Unexpected { location, .. }
412            | Error::HookError { location, .. }
413            | Error::ContainerEndMismatch { location, .. }
414            | Error::UnknownAnchor { location, .. }
415            | Error::QuotingRequired { location, .. }
416            | Error::Budget { location, .. }
417            | Error::CannotBorrowTransformedString { location, .. } => {
418                if location != &Location::UNKNOWN {
419                    Some(*location)
420                } else {
421                    None
422                }
423            }
424            Error::IOError { cause: _ } => None,
425            Error::AliasError { locations, .. } => Locations::primary_location(*locations),
426            Error::WithSnippet { error, .. } => error.location(),
427            #[cfg(feature = "garde")]
428            Error::ValidationError { locations, .. } => locations
429                .map
430                .values()
431                .copied()
432                .find_map(Locations::primary_location),
433            #[cfg(feature = "garde")]
434            Error::ValidationErrors { errors } => errors.iter().find_map(|e| e.location()),
435            #[cfg(feature = "validator")]
436            Error::ValidatorError { locations, .. } => locations
437                .map
438                .values()
439                .copied()
440                .find_map(Locations::primary_location),
441            #[cfg(feature = "validator")]
442            Error::ValidatorErrors { errors } => errors.iter().find_map(|e| e.location()),
443        }
444    }
445
446    /// Return a pair of locations associated with this error.
447    ///
448    /// - For syntax and other errors that carry a single [`Location`], this returns two
449    ///   identical locations.
450    /// - For validation errors (when the `garde` / `validator` feature is enabled), this returns
451    ///   the `(reference_location, defined_location)` pair for the *first* validation entry.
452    ///
453    ///   These two locations may differ when YAML anchors/aliases are involved.
454    /// - Returns `None` when no meaningful location information is available.
455    pub fn locations(&self) -> Option<Locations> {
456        match self {
457            Error::Message { location, .. }
458            | Error::Eof { location }
459            | Error::Unexpected { location, .. }
460            | Error::HookError { location, .. }
461            | Error::ContainerEndMismatch { location, .. }
462            | Error::UnknownAnchor { location, .. }
463            | Error::QuotingRequired { location, .. }
464            | Error::Budget { location, .. }
465            | Error::CannotBorrowTransformedString { location, .. } => Locations::same(location),
466            Error::IOError { .. } => None,
467            Error::AliasError { locations, .. } => Some(*locations),
468            Error::WithSnippet { error, .. } => error.locations(),
469            #[cfg(feature = "garde")]
470            Error::ValidationError { report, locations } => {
471                report.iter().next().and_then(|(path, _)| {
472                    let key = path_key_from_garde(path);
473                    search_locations_with_ancestor_fallback(locations, &key)
474                })
475            }
476            #[cfg(feature = "garde")]
477            Error::ValidationErrors { errors } => errors.first().and_then(Error::locations),
478            #[cfg(feature = "validator")]
479            Error::ValidatorError { errors, locations } => collect_validator_entries(errors)
480                .first()
481                .and_then(|(path, _)| locations.search(path).map(|(locs, _)| locs)),
482            #[cfg(feature = "validator")]
483            Error::ValidatorErrors { errors } => errors.first().and_then(Error::locations),
484        }
485    }
486
487    /// Map a `saphyr_parser::ScanError` into our error type with location.
488    ///
489    /// Called by:
490    /// - The live events adapter when the underlying parser fails.
491    #[cold]
492    #[inline(never)]
493    pub(crate) fn from_scan_error(err: ScanError) -> Self {
494        use crate::location::SpanIndex;
495        let mark = err.marker();
496        let location =
497            Location::new(mark.line(), mark.col() + 1).with_span(crate::location::Span {
498                offset: mark.index() as SpanIndex,
499                len: 1,
500                byte_info: (0, 0),
501            });
502        Error::Message {
503            msg: err.info().to_owned(),
504            location,
505        }
506    }
507}
508
509#[cfg(any(feature = "garde", feature = "validator"))]
510fn search_locations_with_ancestor_fallback(
511    locations: &PathMap,
512    path: &PathKey,
513) -> Option<Locations> {
514    if let Some((locs, _)) = locations.search(path) {
515        return Some(locs);
516    }
517
518    let mut p = path.parent();
519    while let Some(cur) = p {
520        if let Some((locs, _)) = locations.search(&cur) {
521            return Some(locs);
522        }
523        p = cur.parent();
524    }
525
526    None
527}
528
529impl fmt::Display for Error {
530    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
531        match self {
532            Error::WithSnippet {
533                text,
534                crop_radius,
535                error,
536            } => {
537                if *crop_radius == 0 {
538                    // Treat as "snippet disabled".
539                    return write!(f, "{}", error);
540                }
541                // `text` is already the pre-rendered snippet output.
542                write!(f, "{text}")
543            }
544            Error::Message { msg, location } => fmt_with_location(f, msg, location),
545            Error::HookError { msg, location } => fmt_with_location(f, msg, location),
546            Error::Eof { location } => fmt_with_location(f, "unexpected end of input", location),
547            Error::Unexpected { expected, location } => fmt_with_location(
548                f,
549                &format!("unexpected event: expected {expected}"),
550                location,
551            ),
552            Error::ContainerEndMismatch { location } => {
553                fmt_with_location(f, "list or mapping end with no start", location)
554            }
555            Error::UnknownAnchor { id, location } => fmt_with_location(
556                f,
557                &format!("alias references unknown anchor id {id}"),
558                location,
559            ),
560            Error::Budget { breach, location } => {
561                fmt_with_location(f, &format!("YAML budget breached: {breach:?}"), location)
562            }
563            Error::QuotingRequired { value, location } => fmt_with_location(
564                f,
565                &format!("The string value [{value}] must be quoted"),
566                location,
567            ),
568            Error::CannotBorrowTransformedString { reason, location } => fmt_with_location(
569                f,
570                &format!(
571                    "cannot borrow string: value was transformed during parsing ({reason}). \
572                     Use String or Cow<str> instead of &str"
573                ),
574                location,
575            ),
576            Error::IOError { cause } => write!(f, "IO error: {}", cause),
577            Error::AliasError { msg, locations } => {
578                fmt_alias_error_plain(f, msg, locations)
579            }
580
581            #[cfg(feature = "garde")]
582            Error::ValidationError { report, locations } => {
583                // No input text available here, so we fall back to a location-suffixed
584                // message format (snippets are only rendered via `Error::WithSnippet`).
585                fmt_validation_error_plain(f, report, locations)
586            }
587
588            #[cfg(feature = "garde")]
589            Error::ValidationErrors { errors } => {
590                let mut first = true;
591                for err in errors {
592                    if !first {
593                        writeln!(f)?;
594                        writeln!(f)?;
595                    }
596                    first = false;
597                    write!(f, "{err}")?;
598                }
599                Ok(())
600            }
601
602            #[cfg(feature = "validator")]
603            Error::ValidatorError { errors, locations } => {
604                fmt_validator_error_plain(f, errors, locations)
605            }
606
607            #[cfg(feature = "validator")]
608            Error::ValidatorErrors { errors } => {
609                let mut first = true;
610                for err in errors {
611                    if !first {
612                        writeln!(f)?;
613                        writeln!(f)?;
614                    }
615                    first = false;
616                    write!(f, "{err}")?;
617                }
618                Ok(())
619            }
620        }
621    }
622}
623
624
625#[cfg(feature = "garde")]
626fn fmt_validation_error_plain(
627    f: &mut fmt::Formatter<'_>,
628    report: &garde::Report,
629    locations: &PathMap,
630) -> fmt::Result {
631    let mut first = true;
632    for (path, entry) in report.iter() {
633        if !first {
634            writeln!(f)?;
635        }
636        first = false;
637        let path_key = path_key_from_garde(path);
638        let original_leaf = path_key
639            .leaf_string()
640            .unwrap_or_else(|| "<root>".to_string());
641
642        let (locs, resolved_leaf) = locations
643            .search(&path_key)
644            .unwrap_or((Locations::UNKNOWN, original_leaf));
645
646        let loc = if locs.reference_location != Location::UNKNOWN {
647            locs.reference_location
648        } else {
649            locs.defined_location
650        };
651
652        let resolved_path = format_path_with_resolved_leaf(&path_key, &resolved_leaf);
653        let msg = format!("validation error at {resolved_path}: {entry}");
654        fmt_with_location(f, &msg, &loc)?;
655    }
656    Ok(())
657}
658
659#[cfg(feature = "validator")]
660fn fmt_validator_error_plain(
661    f: &mut fmt::Formatter<'_>,
662    errors: &ValidationErrors,
663    locations: &PathMap,
664) -> fmt::Result {
665    let entries = collect_validator_entries(errors);
666    let mut first = true;
667
668    for (path, entry) in entries {
669        if !first {
670            writeln!(f)?;
671        }
672        first = false;
673
674        let original_leaf = path.leaf_string().unwrap_or_else(|| "<root>".to_string());
675        let (locs, resolved_leaf) = locations
676            .search(&path)
677            .unwrap_or((Locations::UNKNOWN, original_leaf));
678
679        let loc = if locs.reference_location != Location::UNKNOWN {
680            locs.reference_location
681        } else {
682            locs.defined_location
683        };
684
685        let resolved_path = format_path_with_resolved_leaf(&path, &resolved_leaf);
686        let msg = format!("validation error at {resolved_path}: {entry}");
687        fmt_with_location(f, &msg, &loc)?;
688    }
689
690    Ok(())
691}
692
693#[cfg(feature = "validator")]
694fn collect_validator_entries(errors: &ValidationErrors) -> Vec<(PathKey, String)> {
695    let mut out = Vec::new();
696    let root = PathKey::empty();
697    collect_validator_entries_inner(errors, &root, &mut out);
698    out
699}
700
701#[cfg(feature = "validator")]
702fn collect_validator_entries_inner(
703    errors: &ValidationErrors,
704    path: &PathKey,
705    out: &mut Vec<(PathKey, String)>,
706) {
707    for (field, kind) in errors.errors() {
708        let field_path = path.clone().join(field.as_ref());
709        match kind {
710            ValidationErrorsKind::Field(entries) => {
711                for entry in entries {
712                    out.push((field_path.clone(), entry.to_string()));
713                }
714            }
715            ValidationErrorsKind::Struct(inner) => {
716                collect_validator_entries_inner(inner, &field_path, out);
717            }
718            ValidationErrorsKind::List(list) => {
719                for (idx, inner) in list {
720                    let index_path = field_path.clone().join(*idx);
721                    collect_validator_entries_inner(inner, &index_path, out);
722                }
723            }
724        }
725    }
726}
727impl std::error::Error for Error {}
728
729fn maybe_attach_fallback_location(mut err: Error) -> Error {
730    let loc = MISSING_FIELD_FALLBACK.with(|c| *c.borrow());
731    if let Some(loc) = loc
732        && loc != Location::UNKNOWN
733    {
734        err = err.with_location(loc);
735    }
736    err
737}
738
739impl de::Error for Error {
740    fn custom<T: fmt::Display>(msg: T) -> Self {
741        // Keep custom errors locationless by default; the deserializer should attach an explicit
742        // location when it can. For Serde-generated errors, we override the relevant hooks below
743        // and attach a best-effort fallback location.
744        Error::msg(msg.to_string())
745    }
746
747    fn invalid_type(unexp: de::Unexpected, exp: &dyn de::Expected) -> Self {
748        // Mirror serde’s default formatting, but add a best-effort location.
749        maybe_attach_fallback_location(Error::msg(format!("invalid type: {unexp}, expected {exp}")))
750    }
751
752    fn invalid_value(unexp: de::Unexpected, exp: &dyn de::Expected) -> Self {
753        maybe_attach_fallback_location(Error::msg(format!(
754            "invalid value: {unexp}, expected {exp}"
755        )))
756    }
757
758    fn unknown_variant(variant: &str, expected: &'static [&'static str]) -> Self {
759        maybe_attach_fallback_location(Error::msg(format!(
760            "unknown variant `{variant}`, expected one of {}",
761            expected.join(", ")
762        )))
763    }
764
765    fn unknown_field(field: &str, expected: &'static [&'static str]) -> Self {
766        maybe_attach_fallback_location(Error::msg(format!(
767            "unknown field `{field}`, expected one of {}",
768            expected.join(", ")
769        )))
770    }
771
772    fn missing_field(field: &'static str) -> Self {
773        maybe_attach_fallback_location(Error::msg(format!("missing field `{field}`")))
774    }
775}
776
777/// Print a message optionally suffixed with "at line X, column Y".
778///
779/// Arguments:
780/// - `f`: destination formatter.
781/// - `msg`: main text.
782/// - `location`: position to attach if known.
783///
784/// Returns:
785/// - `fmt::Result` as required by `Display`.
786#[cold]
787#[inline(never)]
788fn fmt_with_location(f: &mut fmt::Formatter<'_>, msg: &str, location: &Location) -> fmt::Result {
789    if location != &Location::UNKNOWN {
790        write!(
791            f,
792            "{msg} at line {}, column {}",
793            location.line, location.column
794        )
795    } else {
796        write!(f, "{msg}")
797    }
798}
799
800/// Format an alias error with both reference (use-site) and defined (anchor) locations.
801///
802/// This provides a plain-text representation showing where the alias is used and where
803/// the anchor is defined, which is useful for errors that occur when deserializing aliased values.
804#[cold]
805#[inline(never)]
806fn fmt_alias_error_plain(
807    f: &mut fmt::Formatter<'_>,
808    msg: &str,
809    locations: &Locations,
810) -> fmt::Result {
811    let ref_loc = locations.reference_location;
812    let def_loc = locations.defined_location;
813
814    match (ref_loc, def_loc) {
815        (Location::UNKNOWN, Location::UNKNOWN) => {
816            write!(f, "{msg}")
817        }
818        (r, d) if r != Location::UNKNOWN && (d == Location::UNKNOWN || d == r) => {
819            // Only reference location known, or both are the same
820            write!(f, "{msg} at line {}, column {}", r.line, r.column)
821        }
822        (r, d) if r == Location::UNKNOWN && d != Location::UNKNOWN => {
823            // Only defined location known
824            write!(f, "{msg} (defined at line {}, column {})", d.line, d.column)
825        }
826        (r, d) => {
827            // Both locations known and different
828            write!(
829                f,
830                "{msg} at line {}, column {} (defined at line {}, column {})",
831                r.line, r.column, d.line, d.column
832            )
833        }
834    }
835}
836
837/// Convert a budget breach report into a user-facing error.
838///
839/// Arguments:
840/// - `breach`: which limit was exceeded (from the streaming budget checker).
841///
842/// Returns:
843/// - `Error::Message` with a formatted description.
844///
845/// Called by:
846/// - The live events layer when enforcing budgets during/after parsing.
847#[cold]
848#[inline(never)]
849pub(crate) fn budget_error(breach: BudgetBreach) -> Error {
850    Error::Budget {
851        breach,
852        location: Location::UNKNOWN,
853    }
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859
860    #[test]
861    fn locations_for_basic_error_duplicates_location() {
862        let l = Location::new(3, 7);
863        let err = Error::Message {
864            msg: "x".to_owned(),
865            location: l,
866        };
867        assert_eq!(
868            err.locations(),
869            Some(Locations {
870                reference_location: l,
871                defined_location: l,
872            })
873        );
874    }
875
876    #[test]
877    fn locations_for_io_error_is_unknown() {
878        let err = Error::IOError {
879            cause: std::io::Error::other("x"),
880        };
881        assert_eq!(err.locations(), None);
882    }
883
884    #[test]
885    fn alias_error_returns_both_locations() {
886        let ref_loc = Location::new(5, 10);
887        let def_loc = Location::new(2, 3);
888        let err = Error::AliasError {
889            msg: "test error".to_owned(),
890            locations: Locations {
891                reference_location: ref_loc,
892                defined_location: def_loc,
893            },
894        };
895
896        // location() should return the primary (reference) location
897        assert_eq!(err.location(), Some(ref_loc));
898
899        // locations() should return both
900        assert_eq!(
901            err.locations(),
902            Some(Locations {
903                reference_location: ref_loc,
904                defined_location: def_loc,
905            })
906        );
907    }
908
909    #[test]
910    fn alias_error_display_shows_both_locations() {
911        let ref_loc = Location::new(5, 10);
912        let def_loc = Location::new(2, 3);
913        let err = Error::AliasError {
914            msg: "invalid value".to_owned(),
915            locations: Locations {
916                reference_location: ref_loc,
917                defined_location: def_loc,
918            },
919        };
920
921        let display = err.to_string();
922        assert!(display.contains("invalid value"));
923        assert!(display.contains("line 5"));
924        assert!(display.contains("column 10"));
925        assert!(display.contains("line 2"));
926        assert!(display.contains("column 3"));
927    }
928
929    #[test]
930    fn alias_error_display_with_same_locations() {
931        let loc = Location::new(3, 7);
932        let err = Error::AliasError {
933            msg: "test".to_owned(),
934            locations: Locations {
935                reference_location: loc,
936                defined_location: loc,
937            },
938        };
939
940        let display = err.to_string();
941        // When both locations are the same, should only show one
942        assert!(display.contains("line 3"));
943        assert!(display.contains("column 7"));
944        // Should not contain "defined at" since locations are the same
945        assert!(!display.contains("defined at"));
946    }
947
948    #[cfg(feature = "validator")]
949    #[test]
950    fn locations_for_validator_error_uses_first_entry() {
951        use validator::Validate;
952
953        #[derive(Debug, Validate)]
954        struct Cfg {
955            #[validate(length(min = 2))]
956            second_string: String,
957        }
958
959        let cfg = Cfg {
960            second_string: "x".to_owned(),
961        };
962        let errors = cfg.validate().expect_err("validation error expected");
963
964        let referenced_loc = Location::new(3, 15);
965        let defined_loc = Location::new(2, 18);
966
967        let mut locations = PathMap::new();
968        locations.insert(
969            PathKey::empty().join("secondString"),
970            Locations {
971                reference_location: referenced_loc,
972                defined_location: defined_loc,
973            },
974        );
975
976        let err = Error::ValidatorError { errors, locations };
977        assert_eq!(
978            err.locations(),
979            Some(Locations {
980                reference_location: referenced_loc,
981                defined_location: defined_loc,
982            })
983        );
984    }
985}