Skip to main content

serde_saphyr/de/
message_formatters.rs

1use crate::Location;
2use crate::de_error::{Error, MessageFormatter, UserMessageFormatter};
3use crate::localizer::{ExternalMessage, Localizer};
4
5use std::borrow::Cow;
6
7#[cfg(any(feature = "garde", feature = "validator"))]
8use crate::{
9    Locations, localizer::ExternalMessageSource, path_map::format_path_with_resolved_leaf,
10};
11
12/// Default developer-oriented message formatter.
13///
14/// This formatter at places produces recommendations on how to adjust settings and API
15/// calls for the parsing to work, so normally should not be user-facing. Use UserMessageFormatter
16/// for user-facing content, or implement custom MessageFormatter for full control over output.
17#[derive(Debug, Default, Clone, Copy)]
18pub struct DefaultMessageFormatter;
19
20/// Alias for the default developer-oriented formatter.
21pub type DeveloperMessageFormatter = DefaultMessageFormatter;
22
23fn default_format_message<'a>(formatter: &dyn MessageFormatter, err: &'a Error) -> Cow<'a, str> {
24    match err {
25        Error::WithSnippet { error, .. } => default_format_message(formatter, error),
26        Error::ExternalMessage {
27            source,
28            msg,
29            code,
30            params,
31            ..
32        } => {
33            let l10n = formatter.localizer();
34            l10n.override_external_message(ExternalMessage {
35                source: *source,
36                original: msg.as_str(),
37                code: code.as_deref(),
38                params,
39            })
40            .unwrap_or(Cow::Borrowed(msg.as_str()))
41        }
42        Error::Message { msg, .. }
43        | Error::HookError { msg, .. }
44        | Error::SerdeVariantId { msg, .. } => Cow::Borrowed(msg.as_str()),
45        Error::UnresolvedProperty { name, .. } => Cow::Owned(format!("missing property `{name}`")),
46        Error::InvalidPropertyName { name, .. } => Cow::Owned(format!("Invalid name: '{name}'")),
47        Error::Eof { .. } => Cow::Borrowed("unexpected end of input"),
48        Error::MultipleDocuments { hint, .. } => {
49            Cow::Owned(format!("multiple YAML documents detected; {hint}"))
50        }
51        Error::Unexpected { expected, .. } => {
52            Cow::Owned(format!("unexpected event: expected {expected}"))
53        }
54        Error::MergeValueNotMapOrSeqOfMaps { .. } => {
55            Cow::Borrowed("YAML merge value must be mapping or sequence of mappings")
56        }
57        Error::InvalidBinaryBase64 { .. } => Cow::Borrowed("invalid !!binary base64"),
58        Error::InvalidUtf8Input => Cow::Borrowed("input is not valid UTF-8"),
59        Error::BinaryNotUtf8 { .. } => Cow::Borrowed(
60            "!!binary scalar is not valid UTF-8 so cannot be stored into string. \
61                 If you just use !!binary for documentation/annotation, set ignore_binary_tag_for_string in Options",
62        ),
63        Error::TaggedScalarCannotDeserializeIntoString { .. } => {
64            Cow::Borrowed("cannot deserialize tagged scalar into string")
65        }
66        Error::UnexpectedSequenceEnd { .. } => Cow::Borrowed("unexpected sequence end"),
67        Error::UnexpectedMappingEnd { .. } => Cow::Borrowed("unexpected mapping end"),
68        Error::InvalidBooleanStrict { .. } => {
69            Cow::Borrowed("invalid boolean (strict mode expects true/false)")
70        }
71        Error::InvalidCharNull { .. } => {
72            Cow::Borrowed("invalid char: cannot deserialize null; use Option<char>")
73        }
74        Error::InvalidCharNotSingleScalar { .. } => {
75            Cow::Borrowed("invalid char: expected a single Unicode scalar value")
76        }
77        Error::NullIntoString { .. } => {
78            Cow::Borrowed("cannot deserialize null into string; use Option<String>")
79        }
80        Error::BytesNotSupportedMissingBinaryTag { .. } => {
81            Cow::Borrowed("bytes not supported (missing !!binary tag)")
82        }
83        Error::UnexpectedValueForUnit { .. } => Cow::Borrowed("unexpected value for unit"),
84        Error::ExpectedEmptyMappingForUnitStruct { .. } => {
85            Cow::Borrowed("expected empty mapping for unit struct")
86        }
87        Error::UnexpectedContainerEndWhileSkippingNode { .. } => {
88            Cow::Borrowed("unexpected container end while skipping node")
89        }
90        Error::InternalSeedReusedForMapKey { .. } => {
91            Cow::Borrowed("internal error: seed reused for map key")
92        }
93        Error::ValueRequestedBeforeKey { .. } => Cow::Borrowed("value requested before key"),
94        Error::ExpectedStringKeyForExternallyTaggedEnum { .. } => {
95            Cow::Borrowed("expected string key for externally tagged enum")
96        }
97        Error::ExternallyTaggedEnumExpectedScalarOrMapping { .. } => {
98            Cow::Borrowed("externally tagged enum expected scalar or mapping")
99        }
100        Error::UnexpectedValueForUnitEnumVariant { .. } => {
101            Cow::Borrowed("unexpected value for unit enum variant")
102        }
103        Error::AliasReplayCounterOverflow { .. } => Cow::Borrowed("alias replay counter overflow"),
104        Error::AliasReplayLimitExceeded {
105            total_replayed_events,
106            max_total_replayed_events,
107            ..
108        } => Cow::Owned(format!(
109            "alias replay limit exceeded: total_replayed_events={total_replayed_events} > {max_total_replayed_events}"
110        )),
111        Error::AliasExpansionLimitExceeded {
112            anchor_id,
113            expansions,
114            max_expansions_per_anchor,
115            ..
116        } => Cow::Owned(format!(
117            "alias expansion limit exceeded for anchor id {anchor_id}: {expansions} > {max_expansions_per_anchor}"
118        )),
119        Error::AliasReplayStackDepthExceeded {
120            depth, max_depth, ..
121        } => Cow::Owned(format!(
122            "alias replay stack depth exceeded: depth={depth} > {max_depth}"
123        )),
124        Error::FoldedBlockScalarMustIndentContent { .. } => {
125            Cow::Borrowed("folded block scalars must indent their content")
126        }
127        Error::InternalDepthUnderflow { .. } => Cow::Borrowed("internal depth underflow"),
128        Error::InternalRecursionStackEmpty { .. } => {
129            Cow::Borrowed("internal recursion stack empty")
130        }
131        Error::RecursiveReferencesRequireWeakTypes { .. } => {
132            Cow::Borrowed("recursive references require weak recursion types")
133        }
134        Error::InvalidScalar { ty, .. } => Cow::Owned(format!("invalid {ty}")),
135        Error::SerdeInvalidType {
136            unexpected,
137            expected,
138            ..
139        } => Cow::Owned(format!("invalid type: {unexpected}, expected {expected}")),
140        Error::SerdeInvalidValue {
141            unexpected,
142            expected,
143            ..
144        } => Cow::Owned(format!("invalid value: {unexpected}, expected {expected}")),
145        Error::SerdeUnknownVariant {
146            variant, expected, ..
147        } => Cow::Owned(format!(
148            "unknown variant `{variant}`, expected one of {}",
149            expected.join(", ")
150        )),
151        Error::SerdeUnknownField {
152            field, expected, ..
153        } => Cow::Owned(format!(
154            "unknown field `{field}`, expected one of {}",
155            expected.join(", ")
156        )),
157        Error::SerdeMissingField { field, .. } => Cow::Owned(format!("missing field `{field}`")),
158        Error::UnexpectedContainerEndWhileReadingKeyNode { .. } => {
159            Cow::Borrowed("unexpected container end while reading key")
160        }
161        Error::DuplicateMappingKey { key, .. } => match key {
162            Some(k) => Cow::Owned(format!(
163                "duplicate mapping key: {k}, set DuplicateKeyPolicy in Options if acceptable"
164            )),
165            None => Cow::Borrowed(
166                "duplicate mapping key, set DuplicateKeyPolicy in Options if acceptable",
167            ),
168        },
169        Error::TaggedEnumMismatch { tagged, target, .. } => Cow::Owned(format!(
170            "tagged enum `{tagged}` does not match target enum `{target}`",
171        )),
172        Error::ExpectedMappingEndAfterEnumVariantValue { .. } => {
173            Cow::Borrowed("expected end of mapping after enum variant value")
174        }
175        Error::ContainerEndMismatch { .. } => Cow::Borrowed("list or mapping end with no start"),
176        Error::UnknownAnchor { .. } => Cow::Borrowed("alias references unknown anchor"),
177        Error::CyclicInclude { id, stack, .. } => {
178            let mut full_msg = format!("cyclic include detected: {id}");
179            if !stack.is_empty() {
180                full_msg.push_str("\nwhile processing include from ");
181                full_msg.push_str(&stack.join(" -> "));
182            }
183            Cow::Owned(full_msg)
184        }
185        Error::UnsupportedIncludeForm { .. } => {
186            Cow::Borrowed("!include currently only supports the scalar form: !include <path>")
187        }
188        Error::ResolverError {
189            target,
190            error,
191            stack,
192            ..
193        } => {
194            let mut full_msg = format!("failed to resolve include {target:?}");
195            if !stack.is_empty() {
196                full_msg.push_str("\nwhile processing include from ");
197                full_msg.push_str(&stack.join(" -> "));
198            }
199            full_msg.push('\n');
200            let msg = match error {
201                crate::input_source::IncludeResolveError::Io(e) => e.to_string(),
202                crate::input_source::IncludeResolveError::Message(m) => m.clone(),
203                crate::input_source::IncludeResolveError::SizeLimitExceeded(size, limit) => {
204                    format!("include size {size} bytes exceeds remaining size limit {limit} bytes")
205                }
206                crate::input_source::IncludeResolveError::FileInclude(problem) => {
207                    match &**problem {
208                        crate::input_source::ResolveProblem::ResolveFailed {
209                            spec,
210                            base_dir,
211                            err,
212                        } => {
213                            format!(
214                                "failed to resolve include '{}' from '{}': {}",
215                                spec, base_dir, err
216                            )
217                        }
218                        crate::input_source::ResolveProblem::TargetNotRegularFile { target } => {
219                            format!("include target '{}' is not a regular file", target)
220                        }
221                        crate::input_source::ResolveProblem::TargetIsRootFile { spec } => {
222                            format!(
223                                "include target '{}' resolves to the configured root file itself",
224                                spec
225                            )
226                        }
227                        crate::input_source::ResolveProblem::ParentIdNotAbsoluteCanonical {
228                            parent_id,
229                        } => {
230                            format!(
231                                "SafeFileResolver expected parent include id to be an absolute canonical path, got '{}'",
232                                parent_id
233                            )
234                        }
235                        crate::input_source::ResolveProblem::ParentResolveFailed {
236                            parent_id,
237                            from_name,
238                            err,
239                        } => {
240                            format!(
241                                "failed to resolve parent include source '{}' (from '{}'): {}",
242                                parent_id, from_name, err
243                            )
244                        }
245                        crate::input_source::ResolveProblem::ParentNotRegularFile { parent } => {
246                            format!("include parent '{}' is not a regular file", parent)
247                        }
248                        crate::input_source::ResolveProblem::ParentHasNoDirectory { parent } => {
249                            format!(
250                                "include parent '{}' does not have a parent directory",
251                                parent
252                            )
253                        }
254                        crate::input_source::ResolveProblem::ResolvesOutsideRoot { spec, root } => {
255                            format!(
256                                "include '{}' resolves outside the configured root '{}'",
257                                spec, root
258                            )
259                        }
260                        crate::input_source::ResolveProblem::TraversesSymlink { spec } => {
261                            format!(
262                                "include '{}' traverses a symlink, which is disabled by policy",
263                                spec
264                            )
265                        }
266                        crate::input_source::ResolveProblem::AbsolutePathNotAllowed { spec } => {
267                            format!("absolute include paths are not allowed: {}", spec)
268                        }
269                        crate::input_source::ResolveProblem::EmptyPath => {
270                            "include path must not be empty".to_string()
271                        }
272                        crate::input_source::ResolveProblem::InvalidExtension { spec } => {
273                            format!(
274                                "include target '{}' does not have a valid YAML extension (.yml or .yaml)",
275                                spec
276                            )
277                        }
278                        crate::input_source::ResolveProblem::HiddenFile { spec } => {
279                            format!(
280                                "include target '{}' is a hidden file, which is not allowed",
281                                spec
282                            )
283                        }
284                        crate::input_source::ResolveProblem::EmptyFragment => {
285                            "include fragment must not be empty".to_string()
286                        }
287                        crate::input_source::ResolveProblem::FragmentContainsHash { spec } => {
288                            format!("include fragment must not contain '#': {}", spec)
289                        }
290                    }
291                }
292            };
293            full_msg.push_str(&msg);
294            Cow::Owned(full_msg)
295        }
296        Error::Budget { breach, .. } => Cow::Owned(format!("budget breached: {breach:?}")),
297        Error::QuotingRequired { value, .. } => {
298            Cow::Owned(format!("The string value [{value}] must be quoted"))
299        }
300        Error::CannotBorrowTransformedString { reason, .. } => Cow::Owned(format!(
301            "input does not contain value verbatim so cannot deserialize into &str ({reason}); use String or Cow<str> instead",
302        )),
303        Error::IndentationError {
304            required, actual, ..
305        } => Cow::Owned(format!(
306            "indentation error: expected {required}, found {actual} spaces"
307        )),
308        Error::IOError { cause } => Cow::Owned(format!("IO error: {cause}")),
309        Error::AliasError { msg, locations } => {
310            let l10n = formatter.localizer();
311            let ref_loc = locations.reference_location;
312            let def_loc = locations.defined_location;
313            match (ref_loc, def_loc) {
314                (Location::UNKNOWN, Location::UNKNOWN) => Cow::Borrowed(msg.as_str()),
315                (r, d) if r != Location::UNKNOWN && (d == Location::UNKNOWN || d == r) => {
316                    Cow::Borrowed(msg.as_str())
317                }
318                (_r, d) => Cow::Owned(format!("{msg}{}", l10n.alias_defined_at(d))),
319            }
320        }
321
322        #[cfg(feature = "garde")]
323        Error::ValidationError { issues, locations } => {
324            let l10n = formatter.localizer();
325
326            let mut lines = Vec::with_capacity(issues.len());
327            for issue in issues {
328                let entry = issue.display_entry_overridden(l10n, ExternalMessageSource::Garde);
329                let path_key = &issue.path;
330                let original_leaf = path_key
331                    .leaf_string()
332                    .unwrap_or_else(|| l10n.root_path_label().into_owned());
333
334                let (locs, resolved_leaf) = locations
335                    .search(path_key)
336                    .unwrap_or((Locations::UNKNOWN, original_leaf));
337
338                let loc = if locs.reference_location != Location::UNKNOWN {
339                    locs.reference_location
340                } else {
341                    locs.defined_location
342                };
343
344                let resolved_path = format_path_with_resolved_leaf(path_key, &resolved_leaf);
345
346                lines.push(l10n.validation_issue_line(
347                    &resolved_path,
348                    &entry,
349                    (loc != Location::UNKNOWN).then_some(loc),
350                ));
351            }
352            Cow::Owned(l10n.join_validation_issues(&lines))
353        }
354        #[cfg(feature = "garde")]
355        Error::ValidationErrors { errors } => Cow::Owned(format!(
356            "validation failed for {} document(s)",
357            errors.len()
358        )),
359        #[cfg(feature = "validator")]
360        Error::ValidatorError { issues, locations } => {
361            let l10n = formatter.localizer();
362
363            let mut lines = Vec::with_capacity(issues.len());
364            for issue in issues {
365                let entry = issue.display_entry_overridden(l10n, ExternalMessageSource::Validator);
366                let path_key = &issue.path;
367                let original_leaf = path_key
368                    .leaf_string()
369                    .unwrap_or_else(|| l10n.root_path_label().into_owned());
370
371                let (locs, resolved_leaf) = locations
372                    .search(path_key)
373                    .unwrap_or((Locations::UNKNOWN, original_leaf));
374
375                let loc = if locs.reference_location != Location::UNKNOWN {
376                    locs.reference_location
377                } else {
378                    locs.defined_location
379                };
380
381                let resolved_path = format_path_with_resolved_leaf(path_key, &resolved_leaf);
382
383                lines.push(l10n.validation_issue_line(
384                    &resolved_path,
385                    &entry,
386                    (loc != Location::UNKNOWN).then_some(loc),
387                ));
388            }
389            Cow::Owned(l10n.join_validation_issues(&lines))
390        }
391        #[cfg(feature = "validator")]
392        Error::ValidatorErrors { errors } => Cow::Owned(format!(
393            "validation failed for {} document(s)",
394            errors.len()
395        )),
396    }
397}
398
399impl MessageFormatter for DefaultMessageFormatter {
400    fn format_message<'a>(&self, err: &'a Error) -> Cow<'a, str> {
401        default_format_message(self, err)
402    }
403}
404
405pub struct DefaultMessageFormatterWithLocalizer<'a> {
406    localizer: &'a dyn Localizer,
407}
408
409impl MessageFormatter for DefaultMessageFormatterWithLocalizer<'_> {
410    fn localizer(&self) -> &dyn Localizer {
411        self.localizer
412    }
413
414    fn format_message<'a>(&self, err: &'a Error) -> Cow<'a, str> {
415        default_format_message(self, err)
416    }
417}
418
419impl DefaultMessageFormatter {
420    /// Return a formatter that uses a custom [`Localizer`].
421    ///
422    /// This allows reusing the built-in developer-oriented messages while customizing
423    /// wording that is produced outside `format_message` (location suffixes, validation
424    /// issue composition, snippet labels, etc.).
425    pub fn with_localizer<'a>(
426        &self,
427        localizer: &'a dyn Localizer,
428    ) -> DefaultMessageFormatterWithLocalizer<'a> {
429        DefaultMessageFormatterWithLocalizer { localizer }
430    }
431}
432
433fn user_format_message<'a>(formatter: &dyn MessageFormatter, err: &'a Error) -> Cow<'a, str> {
434    if let Error::WithSnippet { error, .. } = err {
435        return user_format_message(formatter, error);
436    }
437
438    match err {
439        // handled by early return above
440        Error::WithSnippet { .. } => unreachable!(),
441
442        Error::Eof { .. } => Cow::Borrowed("unexpected end of file"),
443        Error::MultipleDocuments { .. } => {
444            Cow::Borrowed("only single YAML document expected but multiple found")
445        }
446        Error::InvalidUtf8Input => Cow::Borrowed("YAML parser input is not valid UTF-8"),
447        Error::BinaryNotUtf8 { .. } => {
448            Cow::Borrowed("!!binary scalar is not valid UTF-8 so cannot be stored into string.")
449        }
450        Error::InvalidBooleanStrict { .. } => {
451            Cow::Borrowed("invalid boolean (true or false expected)")
452        }
453        Error::NullIntoString { .. } | Error::InvalidCharNull { .. } => {
454            Cow::Borrowed("null is not allowed here")
455        }
456        Error::InvalidCharNotSingleScalar { .. } => {
457            Cow::Borrowed("only single character allowed here")
458        }
459        Error::BytesNotSupportedMissingBinaryTag { .. } => Cow::Borrowed("missing !!binary tag"),
460        Error::ExpectedEmptyMappingForUnitStruct { .. } => {
461            Cow::Borrowed("expected empty mapping here")
462        }
463        Error::UnexpectedContainerEndWhileSkippingNode { .. } => {
464            Cow::Borrowed("unexpected container end")
465        }
466        Error::AliasReplayCounterOverflow { .. } => {
467            Cow::Borrowed("YAML document too large or too complex")
468        }
469        Error::AliasReplayLimitExceeded {
470            total_replayed_events,
471            max_total_replayed_events,
472            ..
473        } => Cow::Owned(format!(
474            "YAML document too large or too complex: total_replayed_events={total_replayed_events} > {max_total_replayed_events}"
475        )),
476        Error::AliasExpansionLimitExceeded {
477            anchor_id,
478            expansions,
479            max_expansions_per_anchor,
480            ..
481        } => Cow::Owned(format!(
482            "YAML document too large or too complex: anchor id {anchor_id}: {expansions} > {max_expansions_per_anchor}"
483        )),
484        Error::AliasReplayStackDepthExceeded {
485            depth, max_depth, ..
486        } => Cow::Owned(format!(
487            "YAML document too large or too complex: depth={depth} > {max_depth}"
488        )),
489        Error::UnknownAnchor { .. } => Cow::Borrowed("reference to unknown value"),
490        Error::CyclicInclude { .. } => Cow::Borrowed("cyclic include detected"),
491        Error::UnsupportedIncludeForm { .. } => {
492            Cow::Borrowed("!include currently only supports the scalar form: !include <path>")
493        }
494        Error::ResolverError { .. } => Cow::Borrowed("failed to resolve include"),
495        Error::RecursiveReferencesRequireWeakTypes { .. } => {
496            Cow::Borrowed("Recursive reference not allowed here")
497        }
498        Error::DuplicateMappingKey { key, .. } => match key {
499            Some(k) => Cow::Owned(format!("duplicate mapping key: {k} not allowed here")),
500            None => Cow::Borrowed("duplicate mapping key not allowed here"),
501        },
502        Error::QuotingRequired { .. } => Cow::Borrowed("value requires quoting"),
503        Error::Budget { breach, .. } => Cow::Owned(format!(
504            "YAML document too large or too complex: limits breached: {breach:?}"
505        )),
506        Error::CannotBorrowTransformedString { .. } => {
507            Cow::Borrowed("Only single string with no escape sequences is allowed here")
508        }
509        Error::IndentationError {
510            required, actual, ..
511        } => Cow::Owned(format!(
512            "incorrect indentation: expected {required}, found {actual} spaces"
513        )),
514
515        // All cases when the standard message is good enough.
516        _ => default_format_message(formatter, err),
517    }
518}
519
520impl MessageFormatter for UserMessageFormatter {
521    fn format_message<'a>(&self, err: &'a Error) -> Cow<'a, str> {
522        user_format_message(self, err)
523    }
524}
525
526pub struct UserMessageFormatterWithLocalizer<'a> {
527    localizer: &'a dyn Localizer,
528}
529
530impl MessageFormatter for UserMessageFormatterWithLocalizer<'_> {
531    fn localizer(&self) -> &dyn Localizer {
532        self.localizer
533    }
534
535    fn format_message<'a>(&self, err: &'a Error) -> Cow<'a, str> {
536        user_format_message(self, err)
537    }
538}
539
540impl UserMessageFormatter {
541    /// Return a formatter that uses a custom [`Localizer`].
542    ///
543    /// This allows reusing the built-in user-facing messages while customizing wording
544    /// that is produced outside `format_message` (location suffixes, validation issue
545    /// composition, snippet labels, etc.).
546    pub fn with_localizer<'a>(
547        &self,
548        localizer: &'a dyn Localizer,
549    ) -> UserMessageFormatterWithLocalizer<'a> {
550        UserMessageFormatterWithLocalizer { localizer }
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::Location;
558    use crate::de_error::{Error, MessageFormatter, TransformReason};
559    use crate::location::Locations;
560
561    fn loc() -> Location {
562        Location::UNKNOWN
563    }
564
565    // -----------------------------------------------------------------------
566    // DefaultMessageFormatter – uncovered arms
567    // -----------------------------------------------------------------------
568
569    #[test]
570    fn default_with_snippet_delegates() {
571        let formatter = DefaultMessageFormatter;
572        let inner = Error::Eof { location: loc() };
573        let err = Error::WithSnippet {
574            regions: vec![],
575            crop_radius: 3,
576            error: Box::new(inner),
577        };
578        assert_eq!(formatter.format_message(&err), "unexpected end of input");
579    }
580
581    #[test]
582    fn default_hook_error() {
583        let formatter = DefaultMessageFormatter;
584        let err = Error::HookError {
585            msg: "hook msg".to_owned(),
586            location: loc(),
587        };
588        assert_eq!(formatter.format_message(&err), "hook msg");
589    }
590
591    #[test]
592    fn default_serde_variant_id() {
593        let formatter = DefaultMessageFormatter;
594        let err = Error::SerdeVariantId {
595            msg: "variant id msg".to_owned(),
596            location: loc(),
597        };
598        assert_eq!(formatter.format_message(&err), "variant id msg");
599    }
600
601    #[test]
602    fn default_invalid_binary_base64() {
603        let formatter = DefaultMessageFormatter;
604        let err = Error::InvalidBinaryBase64 { location: loc() };
605        assert_eq!(formatter.format_message(&err), "invalid !!binary base64");
606    }
607
608    #[test]
609    fn default_unexpected_sequence_end() {
610        let formatter = DefaultMessageFormatter;
611        let err = Error::UnexpectedSequenceEnd { location: loc() };
612        assert_eq!(formatter.format_message(&err), "unexpected sequence end");
613    }
614
615    #[test]
616    fn default_unexpected_mapping_end() {
617        let formatter = DefaultMessageFormatter;
618        let err = Error::UnexpectedMappingEnd { location: loc() };
619        assert_eq!(formatter.format_message(&err), "unexpected mapping end");
620    }
621
622    #[test]
623    fn default_unexpected_container_end_while_skipping() {
624        let formatter = DefaultMessageFormatter;
625        let err = Error::UnexpectedContainerEndWhileSkippingNode { location: loc() };
626        assert_eq!(
627            formatter.format_message(&err),
628            "unexpected container end while skipping node"
629        );
630    }
631
632    #[test]
633    fn default_internal_seed_reused() {
634        let formatter = DefaultMessageFormatter;
635        let err = Error::InternalSeedReusedForMapKey { location: loc() };
636        assert_eq!(
637            formatter.format_message(&err),
638            "internal error: seed reused for map key"
639        );
640    }
641
642    #[test]
643    fn default_value_requested_before_key() {
644        let formatter = DefaultMessageFormatter;
645        let err = Error::ValueRequestedBeforeKey { location: loc() };
646        assert_eq!(formatter.format_message(&err), "value requested before key");
647    }
648
649    #[test]
650    fn default_alias_replay_counter_overflow() {
651        let formatter = DefaultMessageFormatter;
652        let err = Error::AliasReplayCounterOverflow { location: loc() };
653        assert_eq!(
654            formatter.format_message(&err),
655            "alias replay counter overflow"
656        );
657    }
658
659    #[test]
660    fn default_folded_block_scalar() {
661        let formatter = DefaultMessageFormatter;
662        let err = Error::FoldedBlockScalarMustIndentContent { location: loc() };
663        assert_eq!(
664            formatter.format_message(&err),
665            "folded block scalars must indent their content"
666        );
667    }
668
669    #[test]
670    fn default_internal_depth_underflow() {
671        let formatter = DefaultMessageFormatter;
672        let err = Error::InternalDepthUnderflow { location: loc() };
673        assert_eq!(formatter.format_message(&err), "internal depth underflow");
674    }
675
676    #[test]
677    fn default_internal_recursion_stack_empty() {
678        let formatter = DefaultMessageFormatter;
679        let err = Error::InternalRecursionStackEmpty { location: loc() };
680        assert_eq!(
681            formatter.format_message(&err),
682            "internal recursion stack empty"
683        );
684    }
685
686    #[test]
687    fn default_recursive_references_require_weak_types() {
688        let formatter = DefaultMessageFormatter;
689        let err = Error::RecursiveReferencesRequireWeakTypes { location: loc() };
690        assert_eq!(
691            formatter.format_message(&err),
692            "recursive references require weak recursion types"
693        );
694    }
695
696    #[test]
697    fn default_serde_invalid_value() {
698        let formatter = DefaultMessageFormatter;
699        let err = Error::SerdeInvalidValue {
700            unexpected: "null".to_owned(),
701            expected: "string".to_owned(),
702            location: loc(),
703        };
704        let msg = formatter.format_message(&err);
705        assert!(msg.contains("invalid value"), "got: {msg}");
706        assert!(msg.contains("null"), "got: {msg}");
707        assert!(msg.contains("string"), "got: {msg}");
708    }
709
710    #[test]
711    fn default_serde_unknown_variant() {
712        let formatter = DefaultMessageFormatter;
713        let err = Error::SerdeUnknownVariant {
714            variant: "foo".to_owned(),
715            expected: vec!["bar", "baz"],
716            location: loc(),
717        };
718        let msg = formatter.format_message(&err);
719        assert!(msg.contains("unknown variant"), "got: {msg}");
720        assert!(msg.contains("foo"), "got: {msg}");
721    }
722
723    #[test]
724    fn default_serde_unknown_field() {
725        let formatter = DefaultMessageFormatter;
726        let err = Error::SerdeUnknownField {
727            field: "xyz".to_owned(),
728            expected: vec!["a", "b"],
729            location: loc(),
730        };
731        let msg = formatter.format_message(&err);
732        assert!(msg.contains("unknown field"), "got: {msg}");
733        assert!(msg.contains("xyz"), "got: {msg}");
734    }
735
736    #[test]
737    fn default_unexpected_container_end_while_reading_key() {
738        let formatter = DefaultMessageFormatter;
739        let err = Error::UnexpectedContainerEndWhileReadingKeyNode { location: loc() };
740        assert_eq!(
741            formatter.format_message(&err),
742            "unexpected container end while reading key"
743        );
744    }
745
746    #[test]
747    fn default_expected_mapping_end_after_enum_variant() {
748        let formatter = DefaultMessageFormatter;
749        let err = Error::ExpectedMappingEndAfterEnumVariantValue { location: loc() };
750        assert_eq!(
751            formatter.format_message(&err),
752            "expected end of mapping after enum variant value"
753        );
754    }
755
756    #[test]
757    fn default_container_end_mismatch() {
758        let formatter = DefaultMessageFormatter;
759        let err = Error::ContainerEndMismatch { location: loc() };
760        assert_eq!(
761            formatter.format_message(&err),
762            "list or mapping end with no start"
763        );
764    }
765
766    #[test]
767    fn default_io_error() {
768        let formatter = DefaultMessageFormatter;
769        let err = Error::IOError {
770            cause: std::io::Error::other("disk full"),
771        };
772        let msg = formatter.format_message(&err);
773        assert!(msg.contains("IO error"), "got: {msg}");
774        assert!(msg.contains("disk full"), "got: {msg}");
775    }
776
777    #[test]
778    fn default_unresolved_property() {
779        let formatter = DefaultMessageFormatter;
780        let err = Error::UnresolvedProperty {
781            name: "MISSING".to_owned(),
782            location: loc(),
783        };
784        assert_eq!(formatter.format_message(&err), "missing property `MISSING`");
785    }
786
787    #[test]
788    fn default_invalid_property_name() {
789        let formatter = DefaultMessageFormatter;
790        let err = Error::InvalidPropertyName {
791            name: "${ab-cd}".to_owned(),
792            location: loc(),
793        };
794        assert_eq!(formatter.format_message(&err), "Invalid name: '${ab-cd}'");
795    }
796
797    #[test]
798    fn default_alias_error_both_unknown() {
799        let formatter = DefaultMessageFormatter;
800        let err = Error::AliasError {
801            msg: "alias msg".to_owned(),
802            locations: Locations::UNKNOWN,
803        };
804        assert_eq!(formatter.format_message(&err), "alias msg");
805    }
806
807    #[test]
808    fn default_alias_error_ref_known_def_unknown() {
809        let formatter = DefaultMessageFormatter;
810        let ref_loc = Location::new(1, 0);
811        let err = Error::AliasError {
812            msg: "alias msg".to_owned(),
813            locations: Locations {
814                reference_location: ref_loc,
815                defined_location: Location::UNKNOWN,
816            },
817        };
818        // r != UNKNOWN and d == UNKNOWN → returns msg as-is
819        assert_eq!(formatter.format_message(&err), "alias msg");
820    }
821
822    #[test]
823    fn default_alias_error_both_known_different() {
824        let formatter = DefaultMessageFormatter;
825        let ref_loc = Location::new(1, 0);
826        let def_loc = Location::new(5, 0);
827        let err = Error::AliasError {
828            msg: "alias msg".to_owned(),
829            locations: Locations {
830                reference_location: ref_loc,
831                defined_location: def_loc,
832            },
833        };
834        // _r != UNKNOWN, d != UNKNOWN, d != r → appends defined-at suffix
835        let msg = formatter.format_message(&err);
836        assert!(msg.starts_with("alias msg"), "got: {msg}");
837    }
838
839    // -----------------------------------------------------------------------
840    // UserMessageFormatter – all arms
841    // -----------------------------------------------------------------------
842
843    #[test]
844    fn user_with_snippet_delegates() {
845        let formatter = UserMessageFormatter;
846        let inner = Error::Eof { location: loc() };
847        let err = Error::WithSnippet {
848            regions: vec![],
849            crop_radius: 3,
850            error: Box::new(inner),
851        };
852        assert_eq!(formatter.format_message(&err), "unexpected end of file");
853    }
854
855    #[test]
856    fn user_eof() {
857        let formatter = UserMessageFormatter;
858        let err = Error::Eof { location: loc() };
859        assert_eq!(formatter.format_message(&err), "unexpected end of file");
860    }
861
862    #[test]
863    fn user_multiple_documents() {
864        let formatter = UserMessageFormatter;
865        let err = Error::MultipleDocuments {
866            hint: "use from_str_multidoc",
867            location: loc(),
868        };
869        assert_eq!(
870            formatter.format_message(&err),
871            "only single YAML document expected but multiple found"
872        );
873    }
874
875    #[test]
876    fn user_invalid_utf8_input() {
877        let formatter = UserMessageFormatter;
878        let err = Error::InvalidUtf8Input;
879        assert_eq!(
880            formatter.format_message(&err),
881            "YAML parser input is not valid UTF-8"
882        );
883    }
884
885    #[test]
886    fn user_binary_not_utf8() {
887        let formatter = UserMessageFormatter;
888        let err = Error::BinaryNotUtf8 { location: loc() };
889        assert!(formatter.format_message(&err).contains("!!binary"));
890    }
891
892    #[test]
893    fn user_invalid_boolean_strict() {
894        let formatter = UserMessageFormatter;
895        let err = Error::InvalidBooleanStrict { location: loc() };
896        assert_eq!(
897            formatter.format_message(&err),
898            "invalid boolean (true or false expected)"
899        );
900    }
901
902    #[test]
903    fn user_null_into_string() {
904        let formatter = UserMessageFormatter;
905        let err = Error::NullIntoString { location: loc() };
906        assert_eq!(formatter.format_message(&err), "null is not allowed here");
907    }
908
909    #[test]
910    fn user_invalid_char_null() {
911        let formatter = UserMessageFormatter;
912        let err = Error::InvalidCharNull { location: loc() };
913        assert_eq!(formatter.format_message(&err), "null is not allowed here");
914    }
915
916    #[test]
917    fn user_invalid_char_not_single_scalar() {
918        let formatter = UserMessageFormatter;
919        let err = Error::InvalidCharNotSingleScalar { location: loc() };
920        assert_eq!(
921            formatter.format_message(&err),
922            "only single character allowed here"
923        );
924    }
925
926    #[test]
927    fn user_bytes_not_supported_missing_binary_tag() {
928        let formatter = UserMessageFormatter;
929        let err = Error::BytesNotSupportedMissingBinaryTag { location: loc() };
930        assert_eq!(formatter.format_message(&err), "missing !!binary tag");
931    }
932
933    #[test]
934    fn user_expected_empty_mapping_for_unit_struct() {
935        let formatter = UserMessageFormatter;
936        let err = Error::ExpectedEmptyMappingForUnitStruct { location: loc() };
937        assert_eq!(
938            formatter.format_message(&err),
939            "expected empty mapping here"
940        );
941    }
942
943    #[test]
944    fn user_unexpected_container_end_while_skipping() {
945        let formatter = UserMessageFormatter;
946        let err = Error::UnexpectedContainerEndWhileSkippingNode { location: loc() };
947        assert_eq!(formatter.format_message(&err), "unexpected container end");
948    }
949
950    #[test]
951    fn user_alias_replay_counter_overflow() {
952        let formatter = UserMessageFormatter;
953        let err = Error::AliasReplayCounterOverflow { location: loc() };
954        assert_eq!(
955            formatter.format_message(&err),
956            "YAML document too large or too complex"
957        );
958    }
959
960    #[test]
961    fn user_alias_replay_limit_exceeded() {
962        let formatter = UserMessageFormatter;
963        let err = Error::AliasReplayLimitExceeded {
964            total_replayed_events: 1000,
965            max_total_replayed_events: 500,
966            location: loc(),
967        };
968        let msg = formatter.format_message(&err);
969        assert!(msg.contains("too large or too complex"), "got: {msg}");
970        assert!(msg.contains("1000"), "got: {msg}");
971    }
972
973    #[test]
974    fn user_alias_expansion_limit_exceeded() {
975        let formatter = UserMessageFormatter;
976        let err = Error::AliasExpansionLimitExceeded {
977            anchor_id: 7,
978            expansions: 200,
979            max_expansions_per_anchor: 100,
980            location: loc(),
981        };
982        let msg = formatter.format_message(&err);
983        assert!(msg.contains("too large or too complex"), "got: {msg}");
984        assert!(msg.contains("7"), "got: {msg}");
985    }
986
987    #[test]
988    fn user_alias_replay_stack_depth_exceeded() {
989        let formatter = UserMessageFormatter;
990        let err = Error::AliasReplayStackDepthExceeded {
991            depth: 50,
992            max_depth: 20,
993            location: loc(),
994        };
995        let msg = formatter.format_message(&err);
996        assert!(msg.contains("too large or too complex"), "got: {msg}");
997        assert!(msg.contains("50"), "got: {msg}");
998    }
999
1000    #[test]
1001    fn user_unknown_anchor() {
1002        let formatter = UserMessageFormatter;
1003        let err = Error::UnknownAnchor { location: loc() };
1004        assert_eq!(formatter.format_message(&err), "reference to unknown value");
1005    }
1006
1007    #[test]
1008    fn user_recursive_references_require_weak_types() {
1009        let formatter = UserMessageFormatter;
1010        let err = Error::RecursiveReferencesRequireWeakTypes { location: loc() };
1011        assert_eq!(
1012            formatter.format_message(&err),
1013            "Recursive reference not allowed here"
1014        );
1015    }
1016
1017    #[test]
1018    fn user_duplicate_mapping_key_with_key() {
1019        let formatter = UserMessageFormatter;
1020        let err = Error::DuplicateMappingKey {
1021            key: Some("mykey".to_owned()),
1022            location: loc(),
1023        };
1024        let msg = formatter.format_message(&err);
1025        assert!(msg.contains("mykey"), "got: {msg}");
1026        assert!(msg.contains("duplicate"), "got: {msg}");
1027    }
1028
1029    #[test]
1030    fn user_duplicate_mapping_key_without_key() {
1031        let formatter = UserMessageFormatter;
1032        let err = Error::DuplicateMappingKey {
1033            key: None,
1034            location: loc(),
1035        };
1036        let msg = formatter.format_message(&err);
1037        assert!(msg.contains("duplicate"), "got: {msg}");
1038    }
1039
1040    #[test]
1041    fn user_quoting_required() {
1042        let formatter = UserMessageFormatter;
1043        let err = Error::QuotingRequired {
1044            value: "yes".to_owned(),
1045            location: loc(),
1046        };
1047        assert_eq!(formatter.format_message(&err), "value requires quoting");
1048    }
1049
1050    #[test]
1051    fn user_budget() {
1052        use crate::budget::BudgetBreach;
1053        let formatter = UserMessageFormatter;
1054        let err = Error::Budget {
1055            breach: BudgetBreach::Events { events: 9999 },
1056            location: loc(),
1057        };
1058        let msg = formatter.format_message(&err);
1059        assert!(msg.contains("too large or too complex"), "got: {msg}");
1060    }
1061
1062    #[test]
1063    fn user_cannot_borrow_transformed_string() {
1064        let formatter = UserMessageFormatter;
1065        let err = Error::CannotBorrowTransformedString {
1066            reason: TransformReason::EscapeSequence,
1067            location: loc(),
1068        };
1069        assert_eq!(
1070            formatter.format_message(&err),
1071            "Only single string with no escape sequences is allowed here"
1072        );
1073    }
1074
1075    #[test]
1076    fn user_falls_through_to_default_for_unhandled() {
1077        // SerdeInvalidType is not explicitly handled by user_format_message → falls through to default
1078        let formatter = UserMessageFormatter;
1079        let err = Error::SerdeInvalidType {
1080            unexpected: "seq".to_owned(),
1081            expected: "map".to_owned(),
1082            location: loc(),
1083        };
1084        let msg = formatter.format_message(&err);
1085        assert!(msg.contains("invalid type"), "got: {msg}");
1086    }
1087
1088    // -----------------------------------------------------------------------
1089    // UserMessageFormatterWithLocalizer
1090    // -----------------------------------------------------------------------
1091
1092    #[test]
1093    fn user_with_localizer_delegates() {
1094        use crate::localizer::DefaultEnglishLocalizer;
1095        let localizer = DefaultEnglishLocalizer;
1096        let formatter = UserMessageFormatter.with_localizer(&localizer);
1097        let err = Error::Eof { location: loc() };
1098        assert_eq!(formatter.format_message(&err), "unexpected end of file");
1099    }
1100
1101    // -----------------------------------------------------------------------
1102    // DefaultMessageFormatterWithLocalizer
1103    // -----------------------------------------------------------------------
1104
1105    #[test]
1106    fn default_with_localizer_delegates() {
1107        use crate::localizer::DefaultEnglishLocalizer;
1108        let localizer = DefaultEnglishLocalizer;
1109        let formatter = DefaultMessageFormatter.with_localizer(&localizer);
1110        let err = Error::Eof { location: loc() };
1111        assert_eq!(formatter.format_message(&err), "unexpected end of input");
1112    }
1113}