Skip to main content

siderite_validation/
rules.rs

1//! Reusable pure field validators.
2//!
3//! Each function returns `Ok(())` or a [`FieldError`] with a stable `code`.
4
5use std::fmt::Display;
6use std::net::IpAddr;
7use std::str::FromStr;
8
9use regex::Regex;
10
11use crate::error::FieldError;
12
13/// Ensure `value` contains at least `min` Unicode scalar values (characters, not bytes).
14pub fn min_length(value: &str, min: usize) -> Result<(), FieldError> {
15    let len = value.chars().count();
16    if len < min {
17        Err(FieldError::new(
18            "string_too_short",
19            format!("ensure this value has at least {min} characters"),
20        ))
21    } else {
22        Ok(())
23    }
24}
25
26/// Ensure `value` contains at most `max` Unicode scalar values (characters, not bytes).
27pub fn max_length(value: &str, max: usize) -> Result<(), FieldError> {
28    let len = value.chars().count();
29    if len > max {
30        Err(FieldError::new(
31            "string_too_long",
32            format!("ensure this value has at most {max} characters"),
33        ))
34    } else {
35        Ok(())
36    }
37}
38
39/// Ensure `value` matches `regex`.
40///
41/// Matching uses [`Regex::is_match`], so callers who want a full-string match
42/// should supply an anchored pattern.
43pub fn pattern(value: &str, regex: &Regex) -> Result<(), FieldError> {
44    if regex.is_match(value) {
45        Ok(())
46    } else {
47        Err(FieldError::new(
48            "pattern_mismatch",
49            format!("string does not match pattern `{regex}`"),
50        ))
51    }
52}
53
54/// Pragmatic email check used by [`crate::types::Email`].
55///
56/// Accepts a value that has exactly one `@`, a non-empty local part, a domain
57/// containing a `.`, and no whitespace. This is **not** a full RFC 5322
58/// validator.
59pub fn email(value: &str) -> Result<(), FieldError> {
60    if is_pragmatic_email(value) {
61        Ok(())
62    } else {
63        Err(FieldError::new("invalid_email", "invalid email address"))
64    }
65}
66
67fn is_pragmatic_email(value: &str) -> bool {
68    if value.chars().any(char::is_whitespace) {
69        return false;
70    }
71    let mut parts = value.split('@');
72    match (parts.next(), parts.next(), parts.next()) {
73        (Some(local), Some(domain), None) => !local.is_empty() && domain.contains('.'),
74        _ => false,
75    }
76}
77
78/// Ensure `value > limit`.
79pub fn gt<T>(value: &T, limit: &T) -> Result<(), FieldError>
80where
81    T: PartialOrd + Display,
82{
83    if value > limit {
84        Ok(())
85    } else {
86        Err(FieldError::new(
87            "greater_than",
88            format!("ensure this value is greater than {limit}"),
89        ))
90    }
91}
92
93/// Ensure `value >= limit`.
94pub fn ge<T>(value: &T, limit: &T) -> Result<(), FieldError>
95where
96    T: PartialOrd + Display,
97{
98    if value >= limit {
99        Ok(())
100    } else {
101        Err(FieldError::new(
102            "greater_than_equal",
103            format!("ensure this value is greater than or equal to {limit}"),
104        ))
105    }
106}
107
108/// Ensure `value < limit`.
109pub fn lt<T>(value: &T, limit: &T) -> Result<(), FieldError>
110where
111    T: PartialOrd + Display,
112{
113    if value < limit {
114        Ok(())
115    } else {
116        Err(FieldError::new(
117            "less_than",
118            format!("ensure this value is less than {limit}"),
119        ))
120    }
121}
122
123/// Ensure `value <= limit`.
124pub fn le<T>(value: &T, limit: &T) -> Result<(), FieldError>
125where
126    T: PartialOrd + Display,
127{
128    if value <= limit {
129        Ok(())
130    } else {
131        Err(FieldError::new(
132            "less_than_equal",
133            format!("ensure this value is less than or equal to {limit}"),
134        ))
135    }
136}
137
138/// Ensure `value` is a multiple of `multiple`.
139///
140/// Returns an error when `multiple` is `0` (the check is undefined and must
141/// not panic).
142pub fn multiple_of(value: i64, multiple: i64) -> Result<(), FieldError> {
143    let is_multiple = match multiple {
144        0 => {
145            return Err(FieldError::new("multiple_of", "multiple must not be zero"));
146        }
147        // Every integer is a multiple of ±1; avoid `i64::MIN % -1` overflow.
148        1 | -1 => true,
149        _ => value % multiple == 0,
150    };
151    if is_multiple {
152        Ok(())
153    } else {
154        Err(FieldError::new(
155            "multiple_of",
156            format!("ensure this value is a multiple of {multiple}"),
157        ))
158    }
159}
160
161/// Parse `value` as a URL.
162///
163/// Accepts any scheme the [`url`] crate accepts (including `http`, `https`,
164/// `ftp`, `file`, …). Relative references without a scheme are rejected.
165pub(crate) fn parse_url(value: &str) -> Result<url::Url, FieldError> {
166    url::Url::parse(value).map_err(|_| FieldError::new("url_parsing", "input is not a valid URL"))
167}
168
169/// Ensure `value` is a valid absolute URL.
170pub fn url(value: &str) -> Result<(), FieldError> {
171    parse_url(value).map(|_| ())
172}
173
174/// Parse `value` as a UUID in hyphenated (`8-4-4-4-12`) or simple (32 hex
175/// digits) form. Matching is case-insensitive.
176///
177/// URN (`urn:uuid:…`) and braced (`{…}`) forms accepted by the `uuid` crate
178/// are **rejected** so the public contract stays hyphenated/simple only.
179pub(crate) fn parse_uuid(value: &str) -> Result<uuid::Uuid, FieldError> {
180    let err = || FieldError::new("uuid_parsing", "input is not a valid UUID");
181    match value.len() {
182        32 | 36 => uuid::Uuid::try_parse(value).map_err(|_| err()),
183        _ => Err(err()),
184    }
185}
186
187/// Ensure `value` is a hyphenated or simple UUID string.
188pub fn uuid(value: &str) -> Result<(), FieldError> {
189    parse_uuid(value).map(|_| ())
190}
191
192/// Parse `value` as an IPv4 or IPv6 address.
193pub(crate) fn parse_ip(value: &str) -> Result<IpAddr, FieldError> {
194    IpAddr::from_str(value)
195        .map_err(|_| FieldError::new("ip_any_parsing", "input is not a valid IP address"))
196}
197
198/// Ensure `value` is an IPv4 or IPv6 address.
199pub fn ip(value: &str) -> Result<(), FieldError> {
200    parse_ip(value).map(|_| ())
201}
202
203/// Parse `value` as an IPv4 address.
204pub(crate) fn parse_ipv4(value: &str) -> Result<std::net::Ipv4Addr, FieldError> {
205    std::net::Ipv4Addr::from_str(value)
206        .map_err(|_| FieldError::new("ip_v4_parsing", "input is not a valid IPv4 address"))
207}
208
209/// Parse `value` as an IPv6 address.
210pub(crate) fn parse_ipv6(value: &str) -> Result<std::net::Ipv6Addr, FieldError> {
211    std::net::Ipv6Addr::from_str(value)
212        .map_err(|_| FieldError::new("ip_v6_parsing", "input is not a valid IPv6 address"))
213}
214
215fn decimal_digit_counts(value: rust_decimal::Decimal) -> (u32, u32) {
216    let scale = value.scale();
217    let mantissa = value.mantissa().unsigned_abs();
218    let coefficient_digits = if mantissa == 0 {
219        1
220    } else {
221        mantissa.ilog10() + 1
222    };
223    let decimals = scale;
224    let digits = coefficient_digits.max(scale);
225    (decimals, digits)
226}
227
228/// Ensure `value` has at most `max_digits` significant digits and at most
229/// `max_places` digits after the decimal point.
230///
231/// Trailing zeros are ignored, matching Pydantic: `1.20` satisfies
232/// `max_places = 1`. Unlike Pydantic, integer digits are **not** additionally
233/// capped at `max_digits - max_places`, so `Decimal<28, 28>` remains usable
234/// for values with an integer part.
235pub fn decimal_digits(
236    value: rust_decimal::Decimal,
237    max_digits: u32,
238    max_places: u32,
239) -> Result<(), FieldError> {
240    let (decimals, digits) = decimal_digit_counts(value);
241    let (norm_decimals, norm_digits) = decimal_digit_counts(value.normalize());
242    if decimals > max_places && norm_decimals > max_places {
243        return Err(FieldError::new(
244            "decimal_max_places",
245            format!("ensure this value has at most {max_places} decimal places"),
246        ));
247    }
248    if digits > max_digits && norm_digits > max_digits {
249        return Err(FieldError::new(
250            "decimal_max_digits",
251            format!("ensure this value has at most {max_digits} digits in total"),
252        ));
253    }
254    // Pydantic: at most `max_digits - decimal_places` digits before the point.
255    let max_whole = max_digits.saturating_sub(max_places);
256    if norm_digits.saturating_sub(norm_decimals) > max_whole {
257        return Err(FieldError::new(
258            "decimal_whole_digits",
259            format!("ensure this value has at most {max_whole} digits before the decimal point"),
260        ));
261    }
262    Ok(())
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn decimal_whole_digits_are_capped() {
271        let d = |s: &str| s.parse::<rust_decimal::Decimal>().unwrap();
272        assert!(decimal_digits(d("123.45"), 5, 2).is_ok());
273        assert_eq!(
274            decimal_digits(d("1234.5"), 5, 2).unwrap_err().code,
275            "decimal_whole_digits"
276        );
277        assert!(decimal_digits(d("1230.00"), 6, 2).is_ok());
278    }
279
280    #[test]
281    fn min_length_counts_characters_not_bytes() {
282        assert_eq!("é".len(), 2);
283        assert_eq!("é".chars().count(), 1);
284        assert!(min_length("é", 1).is_ok());
285        let err = min_length("é", 2).unwrap_err();
286        assert_eq!(err.code, "string_too_short");
287        assert!(min_length("éé", 2).is_ok());
288        assert!(min_length("", 0).is_ok());
289    }
290
291    #[test]
292    fn max_length_counts_characters_not_bytes() {
293        assert!(max_length("é", 1).is_ok());
294        let err = max_length("éé", 1).unwrap_err();
295        assert_eq!(err.code, "string_too_long");
296        assert!(max_length("😀", 1).is_ok());
297        assert_eq!("😀".len(), 4);
298    }
299
300    #[test]
301    fn pattern_success_and_failure() {
302        let re = Regex::new(r"^[a-z]+$").unwrap();
303        assert!(pattern("abc", &re).is_ok());
304        let err = pattern("Abc", &re).unwrap_err();
305        assert_eq!(err.code, "pattern_mismatch");
306    }
307
308    #[test]
309    fn email_pragmatic_rules() {
310        assert!(email("user@example.com").is_ok());
311        for invalid in [
312            "not-an-email",
313            "@example.com",
314            "user@",
315            "user@localhost",
316            "user@@example.com",
317            "user@example.com extra",
318            "user @example.com",
319            "",
320        ] {
321            let err = email(invalid).unwrap_err();
322            assert_eq!(err.code, "invalid_email", "{invalid}");
323            assert_eq!(err.message, "invalid email address");
324        }
325    }
326
327    #[test]
328    fn comparisons() {
329        assert!(gt(&5, &3).is_ok());
330        assert_eq!(gt(&3, &3).unwrap_err().code, "greater_than");
331        assert!(ge(&3, &3).is_ok());
332        assert_eq!(ge(&2, &3).unwrap_err().code, "greater_than_equal");
333        assert!(lt(&2, &3).is_ok());
334        assert_eq!(lt(&3, &3).unwrap_err().code, "less_than");
335        assert!(le(&3, &3).is_ok());
336        assert_eq!(le(&4, &3).unwrap_err().code, "less_than_equal");
337        assert!(gt(&1.5, &1.0).is_ok());
338    }
339
340    #[test]
341    fn multiple_of_rules() {
342        assert!(multiple_of(10, 5).is_ok());
343        assert!(multiple_of(0, 5).is_ok());
344        assert!(multiple_of(-6, 3).is_ok());
345        assert_eq!(multiple_of(10, 3).unwrap_err().code, "multiple_of");
346        assert_eq!(multiple_of(10, 0).unwrap_err().code, "multiple_of");
347        assert!(multiple_of(i64::MIN, -1).is_ok());
348        assert!(multiple_of(7, 1).is_ok());
349    }
350
351    #[test]
352    fn url_accepts_any_scheme() {
353        assert!(url("https://example.com/path").is_ok());
354        assert!(url("ftp://files.example.com").is_ok());
355        assert_eq!(url("not a url").unwrap_err().code, "url_parsing");
356        assert_eq!(url("example.com").unwrap_err().code, "url_parsing");
357    }
358
359    #[test]
360    fn uuid_hyphenated_and_simple() {
361        assert!(uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
362        assert!(uuid("550E8400-E29B-41D4-A716-446655440000").is_ok());
363        assert!(uuid("550e8400e29b41d4a716446655440000").is_ok());
364        assert_eq!(uuid("not-a-uuid").unwrap_err().code, "uuid_parsing");
365        assert_eq!(
366            uuid("urn:uuid:550e8400-e29b-41d4-a716-446655440000")
367                .unwrap_err()
368                .code,
369            "uuid_parsing"
370        );
371    }
372
373    #[test]
374    fn ip_v4_and_v6() {
375        assert!(ip("127.0.0.1").is_ok());
376        assert!(ip("::1").is_ok());
377        assert_eq!(ip("not-an-ip").unwrap_err().code, "ip_any_parsing");
378        assert_eq!(parse_ipv4("::1").unwrap_err().code, "ip_v4_parsing");
379        assert_eq!(parse_ipv6("127.0.0.1").unwrap_err().code, "ip_v6_parsing");
380    }
381
382    #[test]
383    fn decimal_digits_trailing_zeros_and_limits() {
384        use rust_decimal::Decimal;
385        use std::str::FromStr;
386
387        let one_point_two_zero = Decimal::from_str("1.20").unwrap();
388        assert!(decimal_digits(one_point_two_zero, 5, 1).is_ok());
389        let too_many_places = Decimal::from_str("1.234").unwrap();
390        assert_eq!(
391            decimal_digits(too_many_places, 5, 2).unwrap_err().code,
392            "decimal_max_places"
393        );
394        let too_many_digits = Decimal::from_str("12345.6").unwrap();
395        assert_eq!(
396            decimal_digits(too_many_digits, 5, 2).unwrap_err().code,
397            "decimal_max_digits"
398        );
399        let integer_part = Decimal::from_str("123").unwrap();
400        assert_eq!(
401            decimal_digits(integer_part, 28, 28).unwrap_err().code,
402            "decimal_whole_digits"
403        );
404        assert!(decimal_digits(integer_part, u32::MAX, 28).is_ok());
405    }
406}