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