Skip to main content

vld/primitives/string/
checks.rs

1//! The string check table: every format check is declared once in
2//! `string_formats!` (builder methods, `with_messages` key, issue code,
3//! JSON Schema `format`, predicate).
4
5use 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/// Escape a literal for an ECMA-262 (`u` mode) JSON Schema `pattern`.
12#[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
24// ---------------------------------------------------------------------------
25// Checks
26// ---------------------------------------------------------------------------
27
28/// Declares every predicate-based string format exactly once: the enum
29/// variant, its builder pair (`name()` / `name_msg()`), the `with_messages`
30/// key, the [`StringValidation`] code, the JSON Schema `format` and the predicate.
31macro_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    /// Custom message; `None` renders the default for the issue code.
157    pub(super) msg: Option<String>,
158}
159
160impl Kind {
161    /// Stable key identifying the check category (for `with_messages`).
162    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    /// Default message of a [`Rule`](crate::rules::Rule) check.
179    pub(super) fn rule_message(&self) -> Option<String> {
180        match self {
181            Kind::Rule(r) => r.message(),
182            _ => None,
183        }
184    }
185
186    /// `None` when the check passes, otherwise the issue code to report.
187    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}