1use crate::error::{IssueCode, SizeOrigin, StringValidation};
6use crate::primitives::iso::{is_valid_iso_date, is_valid_iso_datetime, is_valid_iso_time};
7
8use super::ZString;
9use super::formats::*;
10
11#[cfg(feature = "openapi")]
13pub(super) fn regex_escape(s: &str) -> String {
14 let mut out = String::with_capacity(s.len());
15 for c in s.chars() {
16 if "^$\\.*+?()[]{}|/".contains(c) {
17 out.push('\\');
18 }
19 out.push(c);
20 }
21 out
22}
23
24macro_rules! string_formats {
32 ($(
33 $(#[cfg($cfg:meta)])?
34 $variant:ident: $method:ident / $method_msg:ident,
35 $key:literal, $schema_fmt:literal, $check:expr, $doc:literal;
36 )+) => {
37 #[derive(Clone, Copy)]
38 pub(super) enum Format {
39 $( $(#[cfg($cfg)])? $variant, )+
40 }
41
42 impl Format {
43 fn key(self) -> &'static str {
44 match self { $( $(#[cfg($cfg)])? Format::$variant => $key, )+ }
45 }
46
47 #[cfg(feature = "openapi")]
48 pub(super) fn schema_format(self) -> &'static str {
49 match self { $( $(#[cfg($cfg)])? Format::$variant => $schema_fmt, )+ }
50 }
51
52 fn validation(self) -> StringValidation {
53 match self { $( $(#[cfg($cfg)])? Format::$variant => StringValidation::$variant, )+ }
54 }
55
56 fn check(self, s: &str) -> bool {
57 match self { $( $(#[cfg($cfg)])? Format::$variant => ($check)(s), )+ }
58 }
59 }
60
61 impl ZString {
62 $(
63 #[doc = $doc]
64 $(#[cfg($cfg)])?
65 pub fn $method(self) -> Self {
66 self.push(Kind::Format(Format::$variant), None)
67 }
68
69 #[doc = $doc]
70 #[doc = ""]
71 #[doc = "With a custom error message."]
72 $(#[cfg($cfg)])?
73 pub fn $method_msg(self, msg: impl Into<String>) -> Self {
74 self.push(Kind::Format(Format::$variant), Some(msg.into()))
75 }
76 )+
77 }
78 };
79}
80
81string_formats! {
82 Email: email / email_msg, "invalid_email", "email", is_valid_email, "Must be a valid email address.";
83 Url: url / url_msg, "invalid_url", "uri", is_valid_url, "Must be a valid URL (http/https).";
84 #[cfg(feature = "string-advanced")]
85 UrlStrict: url_strict / url_strict_msg, "invalid_url_strict", "url-strict", is_valid_url_strict,
86 "Must be a strict public URL: http/https, real host, not localhost / private / loopback / link-local / unspecified (IPv4-mapped IPv6 included).";
87 #[cfg(feature = "string-advanced")]
88 Uri: uri / uri_msg, "invalid_uri", "uri", is_valid_uri, "Must be a valid URI.";
89 Uuid: uuid / uuid_msg, "invalid_uuid", "uuid", is_valid_uuid, "Must be a valid UUID.";
90 #[cfg(feature = "string-advanced")]
91 UuidV1: uuid_v1 / uuid_v1_msg, "invalid_uuid_v1", "uuid-v1", |s| is_valid_uuid_version(s, 1), "Must be a valid UUID v1.";
92 #[cfg(feature = "string-advanced")]
93 UuidV4: uuid_v4 / uuid_v4_msg, "invalid_uuid_v4", "uuid-v4", |s| is_valid_uuid_version(s, 4), "Must be a valid UUID v4.";
94 #[cfg(feature = "string-advanced")]
95 UuidV7: uuid_v7 / uuid_v7_msg, "invalid_uuid_v7", "uuid-v7", |s| is_valid_uuid_version(s, 7), "Must be a valid UUID v7.";
96 Ip: ip / ip_msg, "invalid_ip", "ip", is_valid_ip, "Must be a valid IP address (IPv4 or IPv6).";
97 Slug: slug / slug_msg, "invalid_slug", "slug", is_valid_slug, "Must be a URL-friendly slug (`[a-z0-9-]`).";
98 Color: color / color_msg, "invalid_color", "color", is_valid_color, "Must be a color (`#RRGGBB`, `#RRGGBBAA`, `rgb(...)`, `hsl(...)`).";
99 Currency: currency_code / currency_code_msg, "invalid_currency", "currency-code", is_valid_currency_code, "Must be a currency code (ISO-4217-like uppercase 3 letters).";
100 CountryCode: country_code / country_code_msg, "invalid_country_code", "country-code", is_valid_country_code, "Must be a country code (ISO-3166 alpha-2).";
101 Locale: locale / locale_msg, "invalid_locale", "locale", is_valid_locale, "Must be a locale (`ll` or `ll-RR`, example: `en`, `en-US`).";
102 Cron: cron / cron_msg, "invalid_cron", "cron", is_valid_cron, "Must be a cron expression (5 or 6 fields).";
103 Ipv4: ipv4 / ipv4_msg, "invalid_ipv4", "ipv4", is_valid_ipv4, "Must be a valid IPv4 address.";
104 Ipv6: ipv6 / ipv6_msg, "invalid_ipv6", "ipv6", is_valid_ipv6, "Must be a valid IPv6 address (IPv4-mapped forms included).";
105 Cidr: cidr / cidr_msg, "invalid_cidr", "cidr", is_valid_cidr, "Must be a valid CIDR block (IPv4/IPv6 with a decimal prefix).";
106 Mac: mac / mac_msg, "invalid_mac", "mac", is_valid_mac, "Must be a valid MAC address (`xx:xx:xx:xx:xx:xx`).";
107 Hex: hex / hex_msg, "invalid_hex", "hex", is_valid_hex, "Must contain only hexadecimal characters.";
108 CreditCard: credit_card / credit_card_msg, "invalid_credit_card", "credit-card", is_valid_credit_card,
109 "Must be a valid credit card number: digits with optional spaces/dashes, 12-19 digits, Luhn check.";
110 Phone: phone / phone_msg, "invalid_phone", "phone", is_valid_phone,
111 "Must be an international phone number: leading `+`, 8-15 digits, optional spaces, dashes and parentheses.";
112 #[cfg(feature = "string-advanced")]
113 PhoneE164: phone_e164_strict / phone_e164_strict_msg, "invalid_phone_e164", "phone-e164", is_valid_phone_e164_strict, "Must be a valid strict E.164 phone number.";
114 Semver: semver / semver_msg, "invalid_semver", "semver", is_valid_semver, "Must be a semantic version (`MAJOR.MINOR.PATCH`, optional `v` prefix).";
115 #[cfg(feature = "string-advanced")]
116 SemverFull: semver_full / semver_full_msg, "invalid_semver_full", "semver-full", is_valid_semver_full, "Must be a full semantic version parsed by the `semver` crate.";
117 Jwt: jwt / jwt_msg, "invalid_jwt", "jwt", is_valid_jwt, "Must be a JWT-like token (`header.payload.signature`, base64url parts).";
118 Ascii: ascii / ascii_msg, "invalid_ascii", "ascii", |s: &str| s.is_ascii(), "Must contain only ASCII characters.";
119 Alpha: alpha / alpha_msg, "invalid_alpha", "alpha", |s: &str| s.chars().all(|c| c.is_ascii_alphabetic()), "Must contain only alphabetic ASCII characters.";
120 Alphanumeric: alphanumeric / alphanumeric_msg, "invalid_alphanumeric", "alphanumeric",
121 |s: &str| s.chars().all(|c| c.is_ascii_alphanumeric()), "Must contain only ASCII alphanumeric characters.";
122 Lowercase: lowercase / lowercase_msg, "invalid_lowercase", "lowercase", |s: &str| s == s.to_lowercase(), "Must already be lowercase.";
123 Uppercase: uppercase / uppercase_msg, "invalid_uppercase", "uppercase", |s: &str| s == s.to_uppercase(), "Must already be uppercase.";
124 Base64: base64 / base64_msg, "invalid_base64", "byte", is_valid_base64, "Must be a valid (padded) Base64 string.";
125 Base64Url: base64url / base64url_msg, "invalid_base64url", "base64url", is_valid_base64url, "Must be a valid Base64URL string (`-`/`_` alphabet, padding optional).";
126 Json: json / json_msg, "invalid_json", "json", is_valid_json, "Must be a string containing one valid JSON document.";
127 IsoDate: iso_date / iso_date_msg, "invalid_iso_date", "date", is_valid_iso_date, "Must be a valid ISO 8601 calendar date (`YYYY-MM-DD`, real day of month).";
128 IsoDatetime: iso_datetime / iso_datetime_msg, "invalid_iso_datetime", "date-time", is_valid_iso_datetime,
129 "Must be a valid ISO 8601 datetime: `YYYY-MM-DDTHH:MM:SS[.frac]` (seconds required, `T`/`t`) with optional `Z`/`z` / `±HH:MM` — the same grammar as `vld::chrono::datetime()` & co.";
130 IsoTime: iso_time / iso_time_msg, "invalid_iso_time", "time", is_valid_iso_time, "Must be a valid ISO 8601 time (`HH:MM[:SS[.frac]]`).";
131 Hostname: hostname / hostname_msg, "invalid_hostname", "hostname", is_valid_hostname, "Must be a valid hostname.";
132 Cuid2: cuid2 / cuid2_msg, "invalid_cuid2", "cuid2", is_valid_cuid2, "Must be a valid CUID2 (2-32 lowercase alphanumerics, starting with a letter).";
133 Ulid: ulid / ulid_msg, "invalid_ulid", "ulid", is_valid_ulid, "Must be a valid ULID (26-char Crockford Base32).";
134 Nanoid: nanoid / nanoid_msg, "invalid_nanoid", "nanoid", is_valid_nanoid, "Must be a valid Nano ID (alphanumeric + `_` + `-`).";
135 Emoji: emoji / emoji_msg, "invalid_emoji", "emoji", is_valid_emoji, "Must be non-empty and consist only of emoji.";
136}
137
138#[derive(Clone)]
139pub(super) enum Kind {
140 Min(usize),
141 Max(usize),
142 Len(usize),
143 NonEmpty,
144 StartsWith(String),
145 EndsWith(String),
146 Contains(String),
147 #[cfg(feature = "regex")]
148 Pattern(crate::rules::Pattern),
149 Format(Format),
150 Rule(std::sync::Arc<dyn crate::rules::Rule<str>>),
151}
152
153#[derive(Clone)]
154pub(super) struct StringCheck {
155 pub(super) kind: Kind,
156 pub(super) msg: Option<String>,
158}
159
160impl Kind {
161 pub(super) fn key(&self) -> &str {
163 match self {
164 Kind::Min(_) => "too_small",
165 Kind::Max(_) => "too_big",
166 Kind::Len(_) => "invalid_length",
167 Kind::NonEmpty => "non_empty",
168 Kind::StartsWith(_) => "invalid_starts_with",
169 Kind::EndsWith(_) => "invalid_ends_with",
170 Kind::Contains(_) => "invalid_contains",
171 #[cfg(feature = "regex")]
172 Kind::Pattern(_) => "invalid_regex",
173 Kind::Format(f) => f.key(),
174 Kind::Rule(r) => r.key(),
175 }
176 }
177
178 pub(super) fn rule_message(&self) -> Option<String> {
180 match self {
181 Kind::Rule(r) => r.message(),
182 _ => None,
183 }
184 }
185
186 pub(super) fn failure(&self, s: &str) -> Option<IssueCode> {
188 let invalid = |validation| Some(IssueCode::InvalidString { validation });
189 let len = || s.chars().count();
190 match self {
191 Kind::Min(n) if len() < *n => Some(too_small(*n)),
192 Kind::Max(n) if len() > *n => Some(IssueCode::TooBig {
193 origin: SizeOrigin::String,
194 maximum: (*n).into(),
195 inclusive: true,
196 }),
197 Kind::Len(n) if len() != *n => Some(IssueCode::InvalidLength {
198 origin: SizeOrigin::String,
199 expected: *n,
200 }),
201 Kind::NonEmpty if s.is_empty() => Some(too_small(1)),
202 Kind::StartsWith(p) if !s.starts_with(p.as_str()) => {
203 invalid(StringValidation::StartsWith { prefix: p.clone() })
204 }
205 Kind::EndsWith(p) if !s.ends_with(p.as_str()) => {
206 invalid(StringValidation::EndsWith { suffix: p.clone() })
207 }
208 Kind::Contains(p) if !s.contains(p.as_str()) => invalid(StringValidation::Contains {
209 substring: p.clone(),
210 }),
211 #[cfg(feature = "regex")]
212 Kind::Pattern(p) if !p.is_match(s) => invalid(StringValidation::Regex),
213 Kind::Format(f) if !f.check(s) => invalid(f.validation()),
214 Kind::Rule(r) if !r.check(s) => Some(IssueCode::Custom {
215 code: r.key().to_string(),
216 }),
217 _ => None,
218 }
219 }
220}
221
222fn too_small(n: usize) -> IssueCode {
223 IssueCode::TooSmall {
224 origin: SizeOrigin::String,
225 minimum: n.into(),
226 inclusive: true,
227 }
228}