Skip to main content

serde_saphyr/de/
message_formatters.rs

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