1use serde_json::Value;
4use std::borrow::Cow;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum SizeOrigin {
12 String,
14 Number,
16 Decimal,
18 Array,
20 Tuple,
22 Set,
24 Record,
26 Object,
28 Bytes,
30 Date,
32 #[serde(rename = "datetime")]
34 DateTime,
35 Time,
37 Duration,
39 File,
41 ImageWidth,
43 ImageHeight,
45 Port,
47 Contains,
49}
50
51impl SizeOrigin {
52 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
79#[serde(rename_all = "snake_case")]
80#[non_exhaustive]
81pub enum DateKind {
82 Date,
84 #[serde(rename = "datetime")]
86 DateTime,
87 Time,
89}
90
91impl DateKind {
92 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
104#[serde(rename_all = "snake_case")]
105#[non_exhaustive]
106pub enum FormatKind {
107 Decimal,
109 IpNetwork,
111 Ipv4Network,
113 Ipv6Network,
115 SocketAddr,
117 Base64,
119 Base64Url,
121 Hex,
123 Image,
125 FromStr,
127}
128
129impl FormatKind {
130 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum FileTypeKind {
151 Extension,
153 MediaType,
155 MagicType,
157}
158
159impl FileTypeKind {
160 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 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
181#[non_exhaustive]
182pub enum StringValidation {
183 Email,
185 Url,
187 UrlStrict,
189 Uri,
191 Uuid,
193 UuidV1,
195 UuidV4,
197 UuidV7,
199 Ip,
201 Slug,
203 Color,
205 Currency,
207 CountryCode,
209 Locale,
211 Cron,
213 Regex,
215 StartsWith {
217 prefix: String,
219 },
220 EndsWith {
222 suffix: String,
224 },
225 Contains {
227 substring: String,
229 },
230 Ipv4,
232 Ipv6,
234 Cidr,
236 Mac,
238 Hex,
240 CreditCard,
242 Phone,
244 PhoneE164,
246 Semver,
248 SemverFull,
250 Jwt,
252 Ascii,
254 Alpha,
256 Alphanumeric,
258 Lowercase,
260 Uppercase,
262 Base64,
264 Base64Url,
266 Json,
268 IsoDate,
270 IsoDatetime,
272 IsoTime,
274 Hostname,
276 Cuid2,
278 Ulid,
280 Nanoid,
282 Emoji,
284}
285
286impl StringValidation {
287 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#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
348#[non_exhaustive]
349pub enum IssueCode {
350 InvalidType {
352 expected: String,
354 received: String,
356 },
357 TooSmall {
360 origin: SizeOrigin,
362 minimum: Value,
364 inclusive: bool,
366 },
367 TooBig {
369 origin: SizeOrigin,
371 maximum: Value,
373 inclusive: bool,
375 },
376 InvalidLength {
378 origin: SizeOrigin,
380 expected: usize,
382 },
383 InvalidString {
385 validation: StringValidation,
387 },
388 InvalidFormat {
390 format: FormatKind,
392 },
393 InvalidDate {
395 kind: DateKind,
397 },
398 InvalidTimezoneOffset {
400 expected: String,
402 },
403 NotInPast {
405 kind: DateKind,
407 },
408 NotInFuture {
410 kind: DateKind,
412 },
413 NotInt,
415 NotFinite,
417 NotMultipleOf {
419 divisor: Value,
421 },
422 NotSafeInteger,
424 InvalidLiteral {
426 expected: Value,
428 },
429 InvalidEnumValue {
431 options: Vec<String>,
433 },
434 InvalidUnion,
436 InvalidDiscriminator {
438 options: Vec<Value>,
440 },
441 NotUnique,
443 MissingContains,
445 DuplicateKey,
447 InvalidMapEntry,
449 MissingField,
451 UnrecognizedField,
453 PathOutsideBase {
455 base: String,
457 },
458 PathNotAbsolute,
460 PathNotRelative,
462 PathNotFound,
464 PathNotFile,
466 PathNotDir,
468 JsonTooDeep {
470 max_depth: usize,
472 },
473 FileTypeNotAllowed {
475 kind: FileTypeKind,
477 actual: String,
479 allowed: Vec<String>,
481 },
482 FileTypeDenied {
484 kind: FileTypeKind,
486 actual: String,
488 },
489 FileTypeUnknown {
491 kind: FileTypeKind,
493 },
494 ChecksumMismatch {
496 algorithm: String,
498 },
499 MissingExif,
501 SerializeError {
503 detail: String,
505 },
506 IoError,
508 ParseError,
510 Custom {
512 code: String,
514 },
515}
516
517pub(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 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 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 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 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 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 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 #[cfg(test)]
712 pub(crate) fn render(&self, template: &str) -> String {
713 self.render_with(template, |_, _| None)
714 }
715
716 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 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
769const 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 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}