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