use serde_json::Value;
use std::borrow::Cow;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SizeOrigin {
String,
Number,
Decimal,
Array,
Tuple,
Set,
Record,
Object,
Bytes,
Date,
#[serde(rename = "datetime")]
DateTime,
Time,
Duration,
File,
ImageWidth,
ImageHeight,
Port,
Contains,
}
impl SizeOrigin {
pub fn key(&self) -> &'static str {
match self {
SizeOrigin::String => "string",
SizeOrigin::Number => "number",
SizeOrigin::Decimal => "decimal",
SizeOrigin::Array => "array",
SizeOrigin::Tuple => "tuple",
SizeOrigin::Set => "set",
SizeOrigin::Record => "record",
SizeOrigin::Object => "object",
SizeOrigin::Bytes => "bytes",
SizeOrigin::Date => "date",
SizeOrigin::DateTime => "datetime",
SizeOrigin::Time => "time",
SizeOrigin::Duration => "duration",
SizeOrigin::File => "file",
SizeOrigin::ImageWidth => "image_width",
SizeOrigin::ImageHeight => "image_height",
SizeOrigin::Port => "port",
SizeOrigin::Contains => "contains",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DateKind {
Date,
#[serde(rename = "datetime")]
DateTime,
Time,
}
impl DateKind {
pub fn key(&self) -> &'static str {
match self {
DateKind::Date => "date",
DateKind::DateTime => "datetime",
DateKind::Time => "time",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum FormatKind {
Decimal,
IpNetwork,
Ipv4Network,
Ipv6Network,
SocketAddr,
Base64,
Base64Url,
Hex,
Image,
FromStr,
}
impl FormatKind {
pub fn key(&self) -> &'static str {
match self {
FormatKind::Decimal => "decimal",
FormatKind::IpNetwork => "ip_network",
FormatKind::Ipv4Network => "ipv4_network",
FormatKind::Ipv6Network => "ipv6_network",
FormatKind::SocketAddr => "socket_addr",
FormatKind::Base64 => "base64",
FormatKind::Base64Url => "base64url",
FormatKind::Hex => "hex",
FormatKind::Image => "image",
FormatKind::FromStr => "from_str",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileTypeKind {
Extension,
MediaType,
MagicType,
}
impl FileTypeKind {
pub fn key(&self) -> &'static str {
match self {
FileTypeKind::Extension => "extension",
FileTypeKind::MediaType => "media_type",
FileTypeKind::MagicType => "magic_type",
}
}
pub fn label(&self) -> &'static str {
match self {
FileTypeKind::Extension => "extension",
FileTypeKind::MediaType => "media type",
FileTypeKind::MagicType => "magic type",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum StringValidation {
Email,
Url,
UrlStrict,
Uri,
Uuid,
UuidV1,
UuidV4,
UuidV7,
Ip,
Slug,
Color,
Currency,
CountryCode,
Locale,
Cron,
Regex,
StartsWith { prefix: String },
EndsWith { suffix: String },
Contains { substring: String },
Ipv4,
Ipv6,
Cidr,
Mac,
Hex,
CreditCard,
Phone,
PhoneE164,
Semver,
SemverFull,
Jwt,
Ascii,
Alpha,
Alphanumeric,
Lowercase,
Uppercase,
Base64,
Base64Url,
Json,
IsoDate,
IsoDatetime,
IsoTime,
Hostname,
Cuid2,
Ulid,
Nanoid,
Emoji,
}
impl StringValidation {
pub fn key(&self) -> &'static str {
use StringValidation::*;
match self {
Email => "email",
Url => "url",
UrlStrict => "url_strict",
Uri => "uri",
Uuid => "uuid",
UuidV1 => "uuid_v1",
UuidV4 => "uuid_v4",
UuidV7 => "uuid_v7",
Ip => "ip",
Slug => "slug",
Color => "color",
Currency => "currency",
CountryCode => "country_code",
Locale => "locale",
Cron => "cron",
Regex => "regex",
StartsWith { .. } => "starts_with",
EndsWith { .. } => "ends_with",
Contains { .. } => "contains",
Ipv4 => "ipv4",
Ipv6 => "ipv6",
Cidr => "cidr",
Mac => "mac",
Hex => "hex",
CreditCard => "credit_card",
Phone => "phone",
PhoneE164 => "phone_e164",
Semver => "semver",
SemverFull => "semver_full",
Jwt => "jwt",
Ascii => "ascii",
Alpha => "alpha",
Alphanumeric => "alphanumeric",
Lowercase => "lowercase",
Uppercase => "uppercase",
Base64 => "base64",
Base64Url => "base64url",
Json => "json",
IsoDate => "iso_date",
IsoDatetime => "iso_datetime",
IsoTime => "iso_time",
Hostname => "hostname",
Cuid2 => "cuid2",
Ulid => "ulid",
Nanoid => "nanoid",
Emoji => "emoji",
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum IssueCode {
InvalidType { expected: String, received: String },
TooSmall {
origin: SizeOrigin,
minimum: Value,
inclusive: bool,
},
TooBig {
origin: SizeOrigin,
maximum: Value,
inclusive: bool,
},
InvalidLength { origin: SizeOrigin, expected: usize },
InvalidString { validation: StringValidation },
InvalidFormat { format: FormatKind },
InvalidDate { kind: DateKind },
InvalidTimezoneOffset { expected: String },
NotInPast { kind: DateKind },
NotInFuture { kind: DateKind },
NotInt,
NotFinite,
NotMultipleOf { divisor: Value },
NotSafeInteger,
InvalidLiteral { expected: Value },
InvalidEnumValue { options: Vec<String> },
InvalidUnion,
InvalidDiscriminator { options: Vec<Value> },
NotUnique,
MissingContains,
DuplicateKey,
InvalidMapEntry,
MissingField,
UnrecognizedField,
PathOutsideBase { base: String },
PathNotAbsolute,
PathNotRelative,
PathNotFound,
PathNotFile,
PathNotDir,
JsonTooDeep { max_depth: usize },
FileTypeNotAllowed {
kind: FileTypeKind,
actual: String,
allowed: Vec<String>,
},
FileTypeDenied { kind: FileTypeKind, actual: String },
FileTypeUnknown { kind: FileTypeKind },
ChecksumMismatch { algorithm: String },
MissingExif,
SerializeError { detail: String },
IoError,
ParseError,
Custom { code: String },
}
pub(crate) fn display_value(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => match n.as_f64() {
Some(f) if n.is_f64() && f.fract() == 0.0 && f.abs() < 1e15 => (f as i64).to_string(),
_ => n.to_string(),
},
other => other.to_string(),
}
}
fn join<T>(items: &[T], f: impl Fn(&T) -> String) -> String {
items.iter().map(f).collect::<Vec<_>>().join(", ")
}
impl IssueCode {
pub fn key(&self) -> &str {
use IssueCode::*;
match self {
InvalidType { .. } => "invalid_type",
TooSmall { .. } => "too_small",
TooBig { .. } => "too_big",
InvalidLength { .. } => "invalid_length",
InvalidString { .. } => "invalid_string",
InvalidFormat { .. } => "invalid_format",
InvalidDate { .. } => "invalid_date",
InvalidTimezoneOffset { .. } => "invalid_timezone_offset",
NotInPast { .. } => "not_in_past",
NotInFuture { .. } => "not_in_future",
NotInt => "not_int",
NotFinite => "not_finite",
NotMultipleOf { .. } => "not_multiple_of",
NotSafeInteger => "not_safe_integer",
InvalidLiteral { .. } => "invalid_literal",
InvalidEnumValue { .. } => "invalid_enum_value",
InvalidUnion => "invalid_union",
InvalidDiscriminator { .. } => "invalid_discriminator",
NotUnique => "not_unique",
MissingContains => "missing_contains",
DuplicateKey => "duplicate_key",
InvalidMapEntry => "invalid_map_entry",
MissingField => "missing_field",
UnrecognizedField => "unrecognized_field",
PathOutsideBase { .. } => "path_outside_base",
PathNotAbsolute => "path_not_absolute",
PathNotRelative => "path_not_relative",
PathNotFound => "path_not_found",
PathNotFile => "path_not_file",
PathNotDir => "path_not_dir",
JsonTooDeep { .. } => "json_too_deep",
FileTypeNotAllowed { .. } => "file_type_not_allowed",
FileTypeDenied { .. } => "file_type_denied",
FileTypeUnknown { .. } => "file_type_unknown",
ChecksumMismatch { .. } => "checksum_mismatch",
MissingExif => "missing_exif",
SerializeError { .. } => "serialize_error",
IoError => "io_error",
ParseError => "parse_error",
Custom { code } => code,
}
}
fn template_key(&self) -> (&str, Option<&'static str>) {
use IssueCode::*;
match self {
TooSmall {
origin, inclusive, ..
} => (
if *inclusive {
"too_small"
} else {
"too_small_exclusive"
},
Some(origin.key()),
),
TooBig {
origin, inclusive, ..
} => (
if *inclusive {
"too_big"
} else {
"too_big_exclusive"
},
Some(origin.key()),
),
InvalidLength { origin, .. } => ("invalid_length", Some(origin.key())),
InvalidString { validation } => ("invalid_string", Some(validation.key())),
InvalidFormat { format } => ("invalid_format", Some(format.key())),
InvalidDate { kind } => ("invalid_date", Some(kind.key())),
NotInPast { kind } => ("not_in_past", Some(kind.key())),
NotInFuture { kind } => ("not_in_future", Some(kind.key())),
other => (other.key(), None),
}
}
pub fn message_keys(&self) -> Vec<String> {
let (base, sub) = self.template_key();
let mut keys = Vec::with_capacity(2);
if let Some(sub) = sub {
keys.push(format!("{}.{}", base, sub));
}
keys.push(base.to_string());
if let IssueCode::Custom { .. } = self {
keys.push("custom".to_string());
}
keys
}
pub fn plural_count(&self) -> Option<u64> {
match self {
IssueCode::TooSmall { minimum, .. } => minimum.as_u64(),
IssueCode::TooBig { maximum, .. } => maximum.as_u64(),
IssueCode::InvalidLength { expected, .. } => Some(*expected as u64),
_ => None,
}
}
fn param(&self, name: &str) -> Option<Cow<'_, str>> {
use IssueCode::*;
let b = |v: &'static str| Some(Cow::Borrowed(v));
match (self, name) {
(InvalidType { expected, .. }, "expected") => Some(Cow::Borrowed(expected)),
(InvalidType { received, .. }, "received") => Some(Cow::Borrowed(received)),
(TooSmall { origin, .. }, "origin")
| (TooBig { origin, .. }, "origin")
| (InvalidLength { origin, .. }, "origin") => b(origin.key()),
(TooSmall { minimum, .. }, "minimum") => Some(Cow::Owned(display_value(minimum))),
(TooBig { maximum, .. }, "maximum") => Some(Cow::Owned(display_value(maximum))),
(TooSmall { inclusive, .. }, "inclusive") | (TooBig { inclusive, .. }, "inclusive") => {
b(if *inclusive { "true" } else { "false" })
}
(InvalidLength { expected, .. }, "expected") => Some(Cow::Owned(expected.to_string())),
(InvalidString { validation }, "validation") => b(validation.key()),
(InvalidString { validation }, _) => match (validation, name) {
(StringValidation::StartsWith { prefix }, "prefix") => Some(Cow::Borrowed(prefix)),
(StringValidation::EndsWith { suffix }, "suffix") => Some(Cow::Borrowed(suffix)),
(StringValidation::Contains { substring }, "substring") => {
Some(Cow::Borrowed(substring))
}
_ => None,
},
(InvalidFormat { format }, "format") => b(format.key()),
(InvalidDate { kind }, "kind")
| (NotInPast { kind }, "kind")
| (NotInFuture { kind }, "kind") => b(kind.key()),
(InvalidTimezoneOffset { expected }, "expected") => Some(Cow::Borrowed(expected)),
(NotMultipleOf { divisor }, "divisor") => Some(Cow::Owned(display_value(divisor))),
(InvalidLiteral { expected }, "expected") => Some(Cow::Owned(expected.to_string())),
(InvalidEnumValue { options }, "options") => {
Some(Cow::Owned(join(options, |o| format!("\"{}\"", o))))
}
(InvalidDiscriminator { options }, "options") => {
Some(Cow::Owned(join(options, |o| o.to_string())))
}
(PathOutsideBase { base }, "base") => Some(Cow::Borrowed(base)),
(JsonTooDeep { max_depth }, "max_depth") => Some(Cow::Owned(max_depth.to_string())),
(FileTypeNotAllowed { kind, .. }, "kind")
| (FileTypeDenied { kind, .. }, "kind")
| (FileTypeUnknown { kind }, "kind") => b(kind.label()),
(FileTypeNotAllowed { actual, .. }, "actual")
| (FileTypeDenied { actual, .. }, "actual") => Some(Cow::Borrowed(actual)),
(FileTypeNotAllowed { allowed, .. }, "allowed") => Some(Cow::Owned(allowed.join(", "))),
(ChecksumMismatch { algorithm }, "algorithm") => Some(Cow::Borrowed(algorithm)),
(SerializeError { detail }, "detail") => Some(Cow::Borrowed(detail)),
(Custom { code }, "code") => Some(Cow::Borrowed(code)),
_ => None,
}
}
pub fn params(&self) -> Vec<(&'static str, String)> {
PARAM_NAMES
.iter()
.filter_map(|name| self.param(name).map(|v| (*name, v.into_owned())))
.collect()
}
#[cfg(test)]
pub(crate) fn render(&self, template: &str) -> String {
self.render_with(template, |_, _| None)
}
pub(crate) fn render_with(
&self,
template: &str,
map: impl Fn(&str, &str) -> Option<String>,
) -> String {
let mut out = String::with_capacity(template.len() + 16);
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let Some(close) = after.find('}') else {
out.push_str(&rest[open..]);
return out;
};
let name = &after[..close];
match self.param(name) {
Some(v) => out.push_str(&map(name, &v).unwrap_or_else(|| v.into_owned())),
None => out.push_str(&rest[open..open + close + 2]),
}
rest = &after[close + 1..];
}
out.push_str(rest);
out
}
pub fn default_message(&self) -> String {
let (base, sub) = match self {
IssueCode::Custom { .. } => ("custom", None),
other => other.template_key(),
};
let one = sub
.filter(|_| self.plural_count() == Some(1))
.and_then(|s| super::i18n::english_template_exact(base, &format!("{s}_one")));
let template = one
.or_else(|| super::i18n::english_template_for(base, sub))
.unwrap_or("{code}");
self.render_with(template, |name, value| match (self, name) {
(IssueCode::InvalidType { .. }, "expected" | "received") => {
super::i18n::english_template_for("type", Some(value)).map(str::to_string)
}
_ => None,
})
}
}
const PARAM_NAMES: &[&str] = &[
"expected",
"received",
"origin",
"minimum",
"maximum",
"inclusive",
"validation",
"prefix",
"suffix",
"substring",
"format",
"kind",
"divisor",
"options",
"base",
"max_depth",
"actual",
"allowed",
"algorithm",
"detail",
"code",
];
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn samples() -> Vec<IssueCode> {
use IssueCode::*;
let all = vec![
InvalidType {
expected: "string".into(),
received: "number".into(),
},
TooSmall {
origin: SizeOrigin::String,
minimum: json!(1),
inclusive: true,
},
TooBig {
origin: SizeOrigin::Number,
maximum: json!(1),
inclusive: false,
},
InvalidLength {
origin: SizeOrigin::Array,
expected: 2,
},
InvalidString {
validation: StringValidation::StartsWith { prefix: "a".into() },
},
InvalidFormat {
format: FormatKind::Hex,
},
InvalidDate {
kind: DateKind::Date,
},
InvalidTimezoneOffset {
expected: "+03:00".into(),
},
NotInPast {
kind: DateKind::Date,
},
NotInFuture {
kind: DateKind::DateTime,
},
NotInt,
NotFinite,
NotMultipleOf { divisor: json!(2) },
NotSafeInteger,
InvalidLiteral { expected: json!(1) },
InvalidEnumValue {
options: vec!["a".into()],
},
InvalidUnion,
InvalidDiscriminator {
options: vec![json!("a")],
},
NotUnique,
MissingContains,
DuplicateKey,
InvalidMapEntry,
MissingField,
UnrecognizedField,
PathOutsideBase { base: "/b".into() },
PathNotAbsolute,
PathNotRelative,
PathNotFound,
PathNotFile,
PathNotDir,
JsonTooDeep { max_depth: 1 },
FileTypeNotAllowed {
kind: FileTypeKind::Extension,
actual: "exe".into(),
allowed: vec!["png".into()],
},
FileTypeDenied {
kind: FileTypeKind::MediaType,
actual: "x".into(),
},
FileTypeUnknown {
kind: FileTypeKind::MagicType,
},
ChecksumMismatch {
algorithm: "MD5".into(),
},
MissingExif,
SerializeError { detail: "x".into() },
IoError,
ParseError,
Custom {
code: "mine".into(),
},
];
for c in &all {
match c {
InvalidType { .. }
| TooSmall { .. }
| TooBig { .. }
| InvalidLength { .. }
| InvalidString { .. }
| InvalidFormat { .. }
| InvalidDate { .. }
| InvalidTimezoneOffset { .. }
| NotInPast { .. }
| NotInFuture { .. }
| NotInt
| NotFinite
| NotMultipleOf { .. }
| NotSafeInteger
| InvalidLiteral { .. }
| InvalidEnumValue { .. }
| InvalidUnion
| InvalidDiscriminator { .. }
| NotUnique
| MissingContains
| DuplicateKey
| InvalidMapEntry
| MissingField
| UnrecognizedField
| PathOutsideBase { .. }
| PathNotAbsolute
| PathNotRelative
| PathNotFound
| PathNotFile
| PathNotDir
| JsonTooDeep { .. }
| FileTypeNotAllowed { .. }
| FileTypeDenied { .. }
| FileTypeUnknown { .. }
| ChecksumMismatch { .. }
| MissingExif
| SerializeError { .. }
| IoError
| ParseError
| Custom { .. } => {}
}
}
all
}
#[test]
fn every_code_has_an_english_template() {
for code in samples() {
let generic = code.message_keys().pop().unwrap();
assert!(
crate::error::i18n::english_template(&generic).is_some(),
"missing English template for `{}`",
generic
);
}
for key in ["too_small_exclusive", "too_big_exclusive"] {
assert!(crate::error::i18n::english_template(key).is_some());
}
}
#[test]
fn templates_only_use_known_params() {
for code in samples() {
for key in code.message_keys() {
if let Some(t) = crate::error::i18n::english_template(&key) {
let filled = code.render(t);
assert!(!filled.contains('{'), "{}: {}", key, filled);
}
}
}
}
#[test]
fn every_size_origin_has_templates() {
use SizeOrigin::*;
for o in [
String,
Number,
Decimal,
Array,
Set,
Record,
Object,
Bytes,
Date,
DateTime,
Time,
Duration,
File,
ImageWidth,
ImageHeight,
Port,
Contains,
] {
for base in ["too_small", "too_big"] {
let key = format!("{}.{}", base, o.key());
assert!(
crate::error::i18n::english_template(&key).is_some(),
"{key}"
);
}
}
}
}