1use std::fmt::Display;
6use std::net::IpAddr;
7use std::str::FromStr;
8
9use regex::Regex;
10
11use crate::error::FieldError;
12
13pub 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
26pub 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
39pub 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
54pub 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
78pub 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
93pub 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
108pub 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
123pub 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
138pub 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 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
161pub(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
169pub fn url(value: &str) -> Result<(), FieldError> {
171 parse_url(value).map(|_| ())
172}
173
174pub(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
187pub fn uuid(value: &str) -> Result<(), FieldError> {
189 parse_uuid(value).map(|_| ())
190}
191
192pub(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
198pub fn ip(value: &str) -> Result<(), FieldError> {
200 parse_ip(value).map(|_| ())
201}
202
203pub(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
209pub(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
228pub 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 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}