Skip to main content

vld/error/
code.rs

1//! [`IssueCode`] and the small enums it carries.
2
3use serde_json::Value;
4use std::borrow::Cow;
5
6/// What a size / range bound (`TooSmall`, `TooBig`, `InvalidLength`) was
7/// measured on. Selects the message template (`too_small.<origin>`).
8#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum SizeOrigin {
12    /// String length in characters.
13    String,
14    /// Numeric value (`ZNumber`, `ZInt`, byte values).
15    Number,
16    /// Arbitrary-precision decimal value (bound is a string).
17    Decimal,
18    /// Array length.
19    Array,
20    /// Tuple length.
21    Tuple,
22    /// Number of unique set elements.
23    Set,
24    /// Number of record keys.
25    Record,
26    /// Number of object keys.
27    Object,
28    /// Decoded byte length.
29    Bytes,
30    /// Calendar date (bound is a `YYYY-MM-DD` string).
31    Date,
32    /// Datetime (bound is an RFC 3339 string).
33    #[serde(rename = "datetime")]
34    DateTime,
35    /// Time of day (bound is an `HH:MM:SS[.frac]` string).
36    Time,
37    /// Duration in seconds.
38    Duration,
39    /// File size in bytes.
40    File,
41    /// Image width in pixels.
42    ImageWidth,
43    /// Image height in pixels.
44    ImageHeight,
45    /// Socket address port.
46    Port,
47    /// Number of array items equal to the `contains(...)` value.
48    Contains,
49}
50
51impl SizeOrigin {
52    /// Stable snake_case key (`"string"`, `"datetime"`, `"image_width"`, …).
53    pub fn key(&self) -> &'static str {
54        match self {
55            SizeOrigin::String => "string",
56            SizeOrigin::Number => "number",
57            SizeOrigin::Decimal => "decimal",
58            SizeOrigin::Array => "array",
59            SizeOrigin::Tuple => "tuple",
60            SizeOrigin::Set => "set",
61            SizeOrigin::Record => "record",
62            SizeOrigin::Object => "object",
63            SizeOrigin::Bytes => "bytes",
64            SizeOrigin::Date => "date",
65            SizeOrigin::DateTime => "datetime",
66            SizeOrigin::Time => "time",
67            SizeOrigin::Duration => "duration",
68            SizeOrigin::File => "file",
69            SizeOrigin::ImageWidth => "image_width",
70            SizeOrigin::ImageHeight => "image_height",
71            SizeOrigin::Port => "port",
72            SizeOrigin::Contains => "contains",
73        }
74    }
75}
76
77/// Date, datetime or time of day, for the date-specific issue codes.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
79#[serde(rename_all = "snake_case")]
80#[non_exhaustive]
81pub enum DateKind {
82    /// Calendar date (`YYYY-MM-DD`).
83    Date,
84    /// Date and time (RFC 3339).
85    #[serde(rename = "datetime")]
86    DateTime,
87    /// Time of day (`HH:MM:SS[.frac]`).
88    Time,
89}
90
91impl DateKind {
92    /// `"date"`, `"datetime"` or `"time"`.
93    pub fn key(&self) -> &'static str {
94        match self {
95            DateKind::Date => "date",
96            DateKind::DateTime => "datetime",
97            DateKind::Time => "time",
98        }
99    }
100}
101
102/// Non-string-schema value formats reported by [`IssueCode::InvalidFormat`].
103#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
104#[serde(rename_all = "snake_case")]
105#[non_exhaustive]
106pub enum FormatKind {
107    /// Arbitrary-precision decimal (`vld::decimal()`).
108    Decimal,
109    /// IPv4 or IPv6 network in CIDR notation.
110    IpNetwork,
111    /// IPv4 network in CIDR notation.
112    Ipv4Network,
113    /// IPv6 network in CIDR notation.
114    Ipv6Network,
115    /// `host:port` socket address.
116    SocketAddr,
117    /// Standard base64 bytes.
118    Base64,
119    /// URL-safe base64 bytes.
120    Base64Url,
121    /// Hex-encoded bytes.
122    Hex,
123    /// Image data whose header could not be read.
124    Image,
125    /// A `FromStr` parse failed ([`vld::from_str()`](crate::from_str)).
126    FromStr,
127}
128
129impl FormatKind {
130    /// Stable snake_case key.
131    pub fn key(&self) -> &'static str {
132        match self {
133            FormatKind::Decimal => "decimal",
134            FormatKind::IpNetwork => "ip_network",
135            FormatKind::Ipv4Network => "ipv4_network",
136            FormatKind::Ipv6Network => "ipv6_network",
137            FormatKind::SocketAddr => "socket_addr",
138            FormatKind::Base64 => "base64",
139            FormatKind::Base64Url => "base64url",
140            FormatKind::Hex => "hex",
141            FormatKind::Image => "image",
142            FormatKind::FromStr => "from_str",
143        }
144    }
145}
146
147/// Which file type property a file-type issue refers to.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum FileTypeKind {
151    /// File name extension (`.png`).
152    Extension,
153    /// Declared media type (`image/png`).
154    MediaType,
155    /// Type detected from the file's magic bytes.
156    MagicType,
157}
158
159impl FileTypeKind {
160    /// Stable snake_case key.
161    pub fn key(&self) -> &'static str {
162        match self {
163            FileTypeKind::Extension => "extension",
164            FileTypeKind::MediaType => "media_type",
165            FileTypeKind::MagicType => "magic_type",
166        }
167    }
168
169    /// Human-readable label used in messages (`"media type"`).
170    pub fn label(&self) -> &'static str {
171        match self {
172            FileTypeKind::Extension => "extension",
173            FileTypeKind::MediaType => "media type",
174            FileTypeKind::MagicType => "magic type",
175        }
176    }
177}
178
179/// Type of string validation that failed.
180#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
181#[non_exhaustive]
182pub enum StringValidation {
183    /// Email address.
184    Email,
185    /// `http` / `https` URL.
186    Url,
187    /// URL whose host is not loopback, private or link-local (`url_strict()`).
188    UrlStrict,
189    /// Any absolute URI.
190    Uri,
191    /// UUID of any version.
192    Uuid,
193    /// Version 1 UUID.
194    UuidV1,
195    /// Version 4 UUID.
196    UuidV4,
197    /// Version 7 UUID.
198    UuidV7,
199    /// IPv4 or IPv6 address.
200    Ip,
201    /// Lowercase slug (`a-z`, `0-9`, `-`).
202    Slug,
203    /// CSS hex color.
204    Color,
205    /// ISO 4217 currency code.
206    Currency,
207    /// ISO 3166-1 alpha-2 country code.
208    CountryCode,
209    /// BCP 47 locale tag.
210    Locale,
211    /// Cron expression.
212    Cron,
213    /// Custom regular expression.
214    Regex,
215    /// Starts with `prefix`.
216    StartsWith {
217        /// Required prefix.
218        prefix: String,
219    },
220    /// Ends with `suffix`.
221    EndsWith {
222        /// Required suffix.
223        suffix: String,
224    },
225    /// Contains `substring`.
226    Contains {
227        /// Required substring.
228        substring: String,
229    },
230    /// IPv4 address.
231    Ipv4,
232    /// IPv6 address.
233    Ipv6,
234    /// IP network in CIDR notation.
235    Cidr,
236    /// MAC address.
237    Mac,
238    /// Hexadecimal string.
239    Hex,
240    /// Credit card number (Luhn check).
241    CreditCard,
242    /// Phone number.
243    Phone,
244    /// Phone number in E.164 format.
245    PhoneE164,
246    /// Semantic version (`MAJOR.MINOR.PATCH`).
247    Semver,
248    /// Semantic version with optional pre-release and build metadata.
249    SemverFull,
250    /// JSON Web Token (three base64url parts).
251    Jwt,
252    /// ASCII-only text.
253    Ascii,
254    /// Letters only.
255    Alpha,
256    /// Letters and digits only.
257    Alphanumeric,
258    /// No uppercase letters.
259    Lowercase,
260    /// No lowercase letters.
261    Uppercase,
262    /// Standard base64.
263    Base64,
264    /// URL-safe base64.
265    Base64Url,
266    /// Valid JSON text.
267    Json,
268    /// ISO 8601 date (`YYYY-MM-DD`).
269    IsoDate,
270    /// ISO 8601 datetime.
271    IsoDatetime,
272    /// ISO 8601 time of day.
273    IsoTime,
274    /// DNS hostname.
275    Hostname,
276    /// CUID2 identifier.
277    Cuid2,
278    /// ULID identifier.
279    Ulid,
280    /// Nano ID.
281    Nanoid,
282    /// Emoji only.
283    Emoji,
284}
285
286impl StringValidation {
287    /// Stable snake_case key (`invalid_string.<key>` message template).
288    pub fn key(&self) -> &'static str {
289        use StringValidation::*;
290        match self {
291            Email => "email",
292            Url => "url",
293            UrlStrict => "url_strict",
294            Uri => "uri",
295            Uuid => "uuid",
296            UuidV1 => "uuid_v1",
297            UuidV4 => "uuid_v4",
298            UuidV7 => "uuid_v7",
299            Ip => "ip",
300            Slug => "slug",
301            Color => "color",
302            Currency => "currency",
303            CountryCode => "country_code",
304            Locale => "locale",
305            Cron => "cron",
306            Regex => "regex",
307            StartsWith { .. } => "starts_with",
308            EndsWith { .. } => "ends_with",
309            Contains { .. } => "contains",
310            Ipv4 => "ipv4",
311            Ipv6 => "ipv6",
312            Cidr => "cidr",
313            Mac => "mac",
314            Hex => "hex",
315            CreditCard => "credit_card",
316            Phone => "phone",
317            PhoneE164 => "phone_e164",
318            Semver => "semver",
319            SemverFull => "semver_full",
320            Jwt => "jwt",
321            Ascii => "ascii",
322            Alpha => "alpha",
323            Alphanumeric => "alphanumeric",
324            Lowercase => "lowercase",
325            Uppercase => "uppercase",
326            Base64 => "base64",
327            Base64Url => "base64url",
328            Json => "json",
329            IsoDate => "iso_date",
330            IsoDatetime => "iso_datetime",
331            IsoTime => "iso_time",
332            Hostname => "hostname",
333            Cuid2 => "cuid2",
334            Ulid => "ulid",
335            Nanoid => "nanoid",
336            Emoji => "emoji",
337        }
338    }
339}
340
341/// Validation issue code — describes what went wrong.
342///
343/// Every variant except [`Custom`](IssueCode::Custom) is produced by `vld`
344/// itself; `Custom` is reserved for user-defined codes (`refine`, `custom`,
345/// `super_refine`, `IssueBuilder`). See the [module docs](crate::error) for
346/// the key of every variant.
347#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
348#[non_exhaustive]
349pub enum IssueCode {
350    /// Wrong JSON type.
351    InvalidType {
352        /// Expected type name (`"string"`, `"object"`, …).
353        expected: String,
354        /// Received JSON type name.
355        received: String,
356    },
357    /// Below a lower bound. `minimum` is a JSON number, or a string for
358    /// `Decimal` / `Date` / `DateTime` origins.
359    TooSmall {
360        /// What the bound was measured on.
361        origin: SizeOrigin,
362        /// The lower bound.
363        minimum: Value,
364        /// `true` for `>=`, `false` for `>`.
365        inclusive: bool,
366    },
367    /// Above an upper bound (see `TooSmall`).
368    TooBig {
369        /// What the bound was measured on.
370        origin: SizeOrigin,
371        /// The upper bound.
372        maximum: Value,
373        /// `true` for `<=`, `false` for `<`.
374        inclusive: bool,
375    },
376    /// Exact length mismatch.
377    InvalidLength {
378        /// What the length was measured on.
379        origin: SizeOrigin,
380        /// Required exact length.
381        expected: usize,
382    },
383    /// String format / content check failed.
384    InvalidString {
385        /// The check that failed.
386        validation: StringValidation,
387    },
388    /// A non-string schema could not parse its textual format.
389    InvalidFormat {
390        /// The format that could not be parsed.
391        format: FormatKind,
392    },
393    /// Not a valid date / datetime.
394    InvalidDate {
395        /// Date, datetime or time.
396        kind: DateKind,
397    },
398    /// Datetime offset differs from the required one (`±HH:MM`).
399    InvalidTimezoneOffset {
400        /// Required offset (`±HH:MM`).
401        expected: String,
402    },
403    /// Date / datetime is not in the past.
404    NotInPast {
405        /// Date or datetime.
406        kind: DateKind,
407    },
408    /// Date / datetime is not in the future.
409    NotInFuture {
410        /// Date or datetime.
411        kind: DateKind,
412    },
413    /// Number is not an integer.
414    NotInt,
415    /// Number is NaN or infinite.
416    NotFinite,
417    /// Number is not a multiple of `divisor`.
418    NotMultipleOf {
419        /// The required divisor.
420        divisor: Value,
421    },
422    /// Number is outside `-(2^53-1)..=2^53-1`.
423    NotSafeInteger,
424    /// Value differs from the literal.
425    InvalidLiteral {
426        /// The literal value.
427        expected: Value,
428    },
429    /// String is not one of the enum options.
430    InvalidEnumValue {
431        /// Allowed values.
432        options: Vec<String>,
433    },
434    /// No union variant matched (followed by every variant's issues).
435    InvalidUnion,
436    /// Discriminator value matches no variant.
437    InvalidDiscriminator {
438        /// Discriminator values of the variants.
439        options: Vec<Value>,
440    },
441    /// Array item repeats an earlier one (`unique()`).
442    NotUnique,
443    /// Array lacks the `contains(...)` value.
444    MissingContains,
445    /// Map key repeats an earlier one.
446    DuplicateKey,
447    /// Map entry is not a `[key, value]` pair.
448    InvalidMapEntry,
449    /// Required field is missing or null.
450    MissingField,
451    /// Field not declared by a strict object.
452    UnrecognizedField,
453    /// Path escapes the `within(base)` directory.
454    PathOutsideBase {
455        /// The base directory.
456        base: String,
457    },
458    /// Path must be absolute.
459    PathNotAbsolute,
460    /// Path must be relative.
461    PathNotRelative,
462    /// Path does not exist (`exists()`, `file()`, `dir()`, `vld::file()`).
463    PathNotFound,
464    /// Path exists but is not a regular file.
465    PathNotFile,
466    /// Path exists but is not a directory.
467    PathNotDir,
468    /// JSON nesting is deeper than `max_depth`.
469    JsonTooDeep {
470        /// Allowed nesting depth.
471        max_depth: usize,
472    },
473    /// File extension / media type / magic type is not in the allow list.
474    FileTypeNotAllowed {
475        /// Which property was checked.
476        kind: FileTypeKind,
477        /// The file's value.
478        actual: String,
479        /// Allowed values.
480        allowed: Vec<String>,
481    },
482    /// File extension / media type / magic type is on the deny list.
483    FileTypeDenied {
484        /// Which property was checked.
485        kind: FileTypeKind,
486        /// The file's value.
487        actual: String,
488    },
489    /// File media / magic type could not be detected.
490    FileTypeUnknown {
491        /// Which property could not be detected.
492        kind: FileTypeKind,
493    },
494    /// File hash differs (`algorithm` is `"SHA-256"` or `"MD5"`).
495    ChecksumMismatch {
496        /// `"SHA-256"` or `"MD5"`.
497        algorithm: String,
498    },
499    /// Image has no EXIF metadata.
500    MissingExif,
501    /// A validated value could not be serialized to JSON.
502    SerializeError {
503        /// The serializer's message.
504        detail: String,
505    },
506    /// Filesystem error.
507    IoError,
508    /// Input is not valid JSON.
509    ParseError,
510    /// User-defined code.
511    Custom {
512        /// The user-defined code.
513        code: String,
514    },
515}
516
517/// Render a bound / literal for messages: strings without quotes, integral
518/// floats without a fraction (`5`, not `5.0`).
519pub(crate) fn display_value(v: &Value) -> String {
520    match v {
521        Value::String(s) => s.clone(),
522        Value::Number(n) => match n.as_f64() {
523            Some(f) if n.is_f64() && f.fract() == 0.0 && f.abs() < 1e15 => (f as i64).to_string(),
524            _ => n.to_string(),
525        },
526        other => other.to_string(),
527    }
528}
529
530fn join<T>(items: &[T], f: impl Fn(&T) -> String) -> String {
531    items.iter().map(f).collect::<Vec<_>>().join(", ")
532}
533
534impl IssueCode {
535    /// Stable snake_case key for this code (the user code for `Custom`).
536    pub fn key(&self) -> &str {
537        use IssueCode::*;
538        match self {
539            InvalidType { .. } => "invalid_type",
540            TooSmall { .. } => "too_small",
541            TooBig { .. } => "too_big",
542            InvalidLength { .. } => "invalid_length",
543            InvalidString { .. } => "invalid_string",
544            InvalidFormat { .. } => "invalid_format",
545            InvalidDate { .. } => "invalid_date",
546            InvalidTimezoneOffset { .. } => "invalid_timezone_offset",
547            NotInPast { .. } => "not_in_past",
548            NotInFuture { .. } => "not_in_future",
549            NotInt => "not_int",
550            NotFinite => "not_finite",
551            NotMultipleOf { .. } => "not_multiple_of",
552            NotSafeInteger => "not_safe_integer",
553            InvalidLiteral { .. } => "invalid_literal",
554            InvalidEnumValue { .. } => "invalid_enum_value",
555            InvalidUnion => "invalid_union",
556            InvalidDiscriminator { .. } => "invalid_discriminator",
557            NotUnique => "not_unique",
558            MissingContains => "missing_contains",
559            DuplicateKey => "duplicate_key",
560            InvalidMapEntry => "invalid_map_entry",
561            MissingField => "missing_field",
562            UnrecognizedField => "unrecognized_field",
563            PathOutsideBase { .. } => "path_outside_base",
564            PathNotAbsolute => "path_not_absolute",
565            PathNotRelative => "path_not_relative",
566            PathNotFound => "path_not_found",
567            PathNotFile => "path_not_file",
568            PathNotDir => "path_not_dir",
569            JsonTooDeep { .. } => "json_too_deep",
570            FileTypeNotAllowed { .. } => "file_type_not_allowed",
571            FileTypeDenied { .. } => "file_type_denied",
572            FileTypeUnknown { .. } => "file_type_unknown",
573            ChecksumMismatch { .. } => "checksum_mismatch",
574            MissingExif => "missing_exif",
575            SerializeError { .. } => "serialize_error",
576            IoError => "io_error",
577            ParseError => "parse_error",
578            Custom { code } => code,
579        }
580    }
581
582    /// Template key parts: the generic key and, when the code has one, the
583    /// specific sub-key (`("too_small", Some("string"))`).
584    fn template_key(&self) -> (&str, Option<&'static str>) {
585        use IssueCode::*;
586        match self {
587            TooSmall {
588                origin, inclusive, ..
589            } => (
590                if *inclusive {
591                    "too_small"
592                } else {
593                    "too_small_exclusive"
594                },
595                Some(origin.key()),
596            ),
597            TooBig {
598                origin, inclusive, ..
599            } => (
600                if *inclusive {
601                    "too_big"
602                } else {
603                    "too_big_exclusive"
604                },
605                Some(origin.key()),
606            ),
607            InvalidLength { origin, .. } => ("invalid_length", Some(origin.key())),
608            InvalidString { validation } => ("invalid_string", Some(validation.key())),
609            InvalidFormat { format } => ("invalid_format", Some(format.key())),
610            InvalidDate { kind } => ("invalid_date", Some(kind.key())),
611            NotInPast { kind } => ("not_in_past", Some(kind.key())),
612            NotInFuture { kind } => ("not_in_future", Some(kind.key())),
613            other => (other.key(), None),
614        }
615    }
616
617    /// Message-template keys, most specific first.
618    ///
619    /// E.g. `["too_small.string", "too_small"]`; exclusive bounds use
620    /// `too_small_exclusive` / `too_big_exclusive`; `Custom` codes fall back
621    /// to `"custom"`. Resolvers ([`MessageResolver`](super::i18n::MessageResolver))
622    /// are asked in this order.
623    pub fn message_keys(&self) -> Vec<String> {
624        let (base, sub) = self.template_key();
625        let mut keys = Vec::with_capacity(2);
626        if let Some(sub) = sub {
627            keys.push(format!("{}.{}", base, sub));
628        }
629        keys.push(base.to_string());
630        if let IssueCode::Custom { .. } = self {
631            keys.push("custom".to_string());
632        }
633        keys
634    }
635
636    /// Count selecting a plural template variant: the `minimum` / `maximum`
637    /// of `too_small` / `too_big` or the `expected` of `invalid_length`,
638    /// when it is a non-negative integer.
639    pub fn plural_count(&self) -> Option<u64> {
640        match self {
641            IssueCode::TooSmall { minimum, .. } => minimum.as_u64(),
642            IssueCode::TooBig { maximum, .. } => maximum.as_u64(),
643            IssueCode::InvalidLength { expected, .. } => Some(*expected as u64),
644            _ => None,
645        }
646    }
647
648    /// One template parameter by name (`"minimum"`, `"expected"`, …).
649    fn param(&self, name: &str) -> Option<Cow<'_, str>> {
650        use IssueCode::*;
651        let b = |v: &'static str| Some(Cow::Borrowed(v));
652        match (self, name) {
653            (InvalidType { expected, .. }, "expected") => Some(Cow::Borrowed(expected)),
654            (InvalidType { received, .. }, "received") => Some(Cow::Borrowed(received)),
655            (TooSmall { origin, .. }, "origin")
656            | (TooBig { origin, .. }, "origin")
657            | (InvalidLength { origin, .. }, "origin") => b(origin.key()),
658            (TooSmall { minimum, .. }, "minimum") => Some(Cow::Owned(display_value(minimum))),
659            (TooBig { maximum, .. }, "maximum") => Some(Cow::Owned(display_value(maximum))),
660            (TooSmall { inclusive, .. }, "inclusive") | (TooBig { inclusive, .. }, "inclusive") => {
661                b(if *inclusive { "true" } else { "false" })
662            }
663            (InvalidLength { expected, .. }, "expected") => Some(Cow::Owned(expected.to_string())),
664            (InvalidString { validation }, "validation") => b(validation.key()),
665            (InvalidString { validation }, _) => match (validation, name) {
666                (StringValidation::StartsWith { prefix }, "prefix") => Some(Cow::Borrowed(prefix)),
667                (StringValidation::EndsWith { suffix }, "suffix") => Some(Cow::Borrowed(suffix)),
668                (StringValidation::Contains { substring }, "substring") => {
669                    Some(Cow::Borrowed(substring))
670                }
671                _ => None,
672            },
673            (InvalidFormat { format }, "format") => b(format.key()),
674            (InvalidDate { kind }, "kind")
675            | (NotInPast { kind }, "kind")
676            | (NotInFuture { kind }, "kind") => b(kind.key()),
677            (InvalidTimezoneOffset { expected }, "expected") => Some(Cow::Borrowed(expected)),
678            (NotMultipleOf { divisor }, "divisor") => Some(Cow::Owned(display_value(divisor))),
679            (InvalidLiteral { expected }, "expected") => Some(Cow::Owned(expected.to_string())),
680            (InvalidEnumValue { options }, "options") => {
681                Some(Cow::Owned(join(options, |o| format!("\"{}\"", o))))
682            }
683            (InvalidDiscriminator { options }, "options") => {
684                Some(Cow::Owned(join(options, |o| o.to_string())))
685            }
686            (PathOutsideBase { base }, "base") => Some(Cow::Borrowed(base)),
687            (JsonTooDeep { max_depth }, "max_depth") => Some(Cow::Owned(max_depth.to_string())),
688            (FileTypeNotAllowed { kind, .. }, "kind")
689            | (FileTypeDenied { kind, .. }, "kind")
690            | (FileTypeUnknown { kind }, "kind") => b(kind.label()),
691            (FileTypeNotAllowed { actual, .. }, "actual")
692            | (FileTypeDenied { actual, .. }, "actual") => Some(Cow::Borrowed(actual)),
693            (FileTypeNotAllowed { allowed, .. }, "allowed") => Some(Cow::Owned(allowed.join(", "))),
694            (ChecksumMismatch { algorithm }, "algorithm") => Some(Cow::Borrowed(algorithm)),
695            (SerializeError { detail }, "detail") => Some(Cow::Borrowed(detail)),
696            (Custom { code }, "code") => Some(Cow::Borrowed(code)),
697            _ => None,
698        }
699    }
700
701    /// Key-value parameters for message templates (`{minimum}`, `{expected}`, …).
702    pub fn params(&self) -> Vec<(&'static str, String)> {
703        PARAM_NAMES
704            .iter()
705            .filter_map(|name| self.param(name).map(|v| (*name, v.into_owned())))
706            .collect()
707    }
708
709    /// Fill the `{name}` placeholders of `template` from this code's
710    /// parameters (unknown placeholders are kept as is).
711    #[cfg(test)]
712    pub(crate) fn render(&self, template: &str) -> String {
713        self.render_with(template, |_, _| None)
714    }
715
716    /// [`render`](Self::render) with `map(name, value)` able to replace a
717    /// parameter value (e.g. translated type names).
718    pub(crate) fn render_with(
719        &self,
720        template: &str,
721        map: impl Fn(&str, &str) -> Option<String>,
722    ) -> String {
723        let mut out = String::with_capacity(template.len() + 16);
724        let mut rest = template;
725        while let Some(open) = rest.find('{') {
726            out.push_str(&rest[..open]);
727            let after = &rest[open + 1..];
728            let Some(close) = after.find('}') else {
729                out.push_str(&rest[open..]);
730                return out;
731            };
732            let name = &after[..close];
733            match self.param(name) {
734                Some(v) => out.push_str(&map(name, &v).unwrap_or_else(|| v.into_owned())),
735                None => out.push_str(&rest[open..open + close + 2]),
736            }
737            rest = &after[close + 1..];
738        }
739        out.push_str(rest);
740        out
741    }
742
743    /// Default English message, rendered from the
744    /// [`english()`](super::i18n::english) template table and [`params()`](Self::params).
745    ///
746    /// Size checks with a count of 1 use the `_one` variant
747    /// (`"String must be exactly 1 character"`); `invalid_type` type keys
748    /// render through their `type.<key>` label when one exists.
749    pub fn default_message(&self) -> String {
750        let (base, sub) = match self {
751            IssueCode::Custom { .. } => ("custom", None),
752            other => other.template_key(),
753        };
754        let one = sub
755            .filter(|_| self.plural_count() == Some(1))
756            .and_then(|s| super::i18n::english_template_exact(base, &format!("{s}_one")));
757        let template = one
758            .or_else(|| super::i18n::english_template_for(base, sub))
759            .unwrap_or("{code}");
760        self.render_with(template, |name, value| match (self, name) {
761            (IssueCode::InvalidType { .. }, "expected" | "received") => {
762                super::i18n::english_template_for("type", Some(value)).map(str::to_string)
763            }
764            _ => None,
765        })
766    }
767}
768
769/// Every parameter name a built-in code can provide (order of [`IssueCode::params`]).
770const PARAM_NAMES: &[&str] = &[
771    "expected",
772    "received",
773    "origin",
774    "minimum",
775    "maximum",
776    "inclusive",
777    "validation",
778    "prefix",
779    "suffix",
780    "substring",
781    "format",
782    "kind",
783    "divisor",
784    "options",
785    "base",
786    "max_depth",
787    "actual",
788    "allowed",
789    "algorithm",
790    "detail",
791    "code",
792];
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797    use serde_json::json;
798
799    /// One value of every variant; the exhaustive `match` breaks the build
800    /// when a variant is added without a sample here.
801    fn samples() -> Vec<IssueCode> {
802        use IssueCode::*;
803        let all = vec![
804            InvalidType {
805                expected: "string".into(),
806                received: "number".into(),
807            },
808            TooSmall {
809                origin: SizeOrigin::String,
810                minimum: json!(1),
811                inclusive: true,
812            },
813            TooBig {
814                origin: SizeOrigin::Number,
815                maximum: json!(1),
816                inclusive: false,
817            },
818            InvalidLength {
819                origin: SizeOrigin::Array,
820                expected: 2,
821            },
822            InvalidString {
823                validation: StringValidation::StartsWith { prefix: "a".into() },
824            },
825            InvalidFormat {
826                format: FormatKind::Hex,
827            },
828            InvalidDate {
829                kind: DateKind::Date,
830            },
831            InvalidTimezoneOffset {
832                expected: "+03:00".into(),
833            },
834            NotInPast {
835                kind: DateKind::Date,
836            },
837            NotInFuture {
838                kind: DateKind::DateTime,
839            },
840            NotInt,
841            NotFinite,
842            NotMultipleOf { divisor: json!(2) },
843            NotSafeInteger,
844            InvalidLiteral { expected: json!(1) },
845            InvalidEnumValue {
846                options: vec!["a".into()],
847            },
848            InvalidUnion,
849            InvalidDiscriminator {
850                options: vec![json!("a")],
851            },
852            NotUnique,
853            MissingContains,
854            DuplicateKey,
855            InvalidMapEntry,
856            MissingField,
857            UnrecognizedField,
858            PathOutsideBase { base: "/b".into() },
859            PathNotAbsolute,
860            PathNotRelative,
861            PathNotFound,
862            PathNotFile,
863            PathNotDir,
864            JsonTooDeep { max_depth: 1 },
865            FileTypeNotAllowed {
866                kind: FileTypeKind::Extension,
867                actual: "exe".into(),
868                allowed: vec!["png".into()],
869            },
870            FileTypeDenied {
871                kind: FileTypeKind::MediaType,
872                actual: "x".into(),
873            },
874            FileTypeUnknown {
875                kind: FileTypeKind::MagicType,
876            },
877            ChecksumMismatch {
878                algorithm: "MD5".into(),
879            },
880            MissingExif,
881            SerializeError { detail: "x".into() },
882            IoError,
883            ParseError,
884            Custom {
885                code: "mine".into(),
886            },
887        ];
888        for c in &all {
889            match c {
890                InvalidType { .. }
891                | TooSmall { .. }
892                | TooBig { .. }
893                | InvalidLength { .. }
894                | InvalidString { .. }
895                | InvalidFormat { .. }
896                | InvalidDate { .. }
897                | InvalidTimezoneOffset { .. }
898                | NotInPast { .. }
899                | NotInFuture { .. }
900                | NotInt
901                | NotFinite
902                | NotMultipleOf { .. }
903                | NotSafeInteger
904                | InvalidLiteral { .. }
905                | InvalidEnumValue { .. }
906                | InvalidUnion
907                | InvalidDiscriminator { .. }
908                | NotUnique
909                | MissingContains
910                | DuplicateKey
911                | InvalidMapEntry
912                | MissingField
913                | UnrecognizedField
914                | PathOutsideBase { .. }
915                | PathNotAbsolute
916                | PathNotRelative
917                | PathNotFound
918                | PathNotFile
919                | PathNotDir
920                | JsonTooDeep { .. }
921                | FileTypeNotAllowed { .. }
922                | FileTypeDenied { .. }
923                | FileTypeUnknown { .. }
924                | ChecksumMismatch { .. }
925                | MissingExif
926                | SerializeError { .. }
927                | IoError
928                | ParseError
929                | Custom { .. } => {}
930            }
931        }
932        all
933    }
934
935    #[test]
936    fn every_code_has_an_english_template() {
937        for code in samples() {
938            let generic = code.message_keys().pop().unwrap();
939            assert!(
940                crate::error::i18n::english_template(&generic).is_some(),
941                "missing English template for `{}`",
942                generic
943            );
944        }
945        for key in ["too_small_exclusive", "too_big_exclusive"] {
946            assert!(crate::error::i18n::english_template(key).is_some());
947        }
948    }
949
950    #[test]
951    fn templates_only_use_known_params() {
952        for code in samples() {
953            for key in code.message_keys() {
954                if let Some(t) = crate::error::i18n::english_template(&key) {
955                    let filled = code.render(t);
956                    assert!(!filled.contains('{'), "{}: {}", key, filled);
957                }
958            }
959        }
960    }
961
962    #[test]
963    fn every_size_origin_has_templates() {
964        use SizeOrigin::*;
965        for o in [
966            String,
967            Number,
968            Decimal,
969            Array,
970            Set,
971            Record,
972            Object,
973            Bytes,
974            Date,
975            DateTime,
976            Time,
977            Duration,
978            File,
979            ImageWidth,
980            ImageHeight,
981            Port,
982            Contains,
983        ] {
984            for base in ["too_small", "too_big"] {
985                let key = format!("{}.{}", base, o.key());
986                assert!(
987                    crate::error::i18n::english_template(&key).is_some(),
988                    "{key}"
989                );
990            }
991        }
992    }
993}