1use std::sync::Arc;
25
26use chrono::NaiveDate;
27use serde_json::Value;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Outcome {
32 Pass,
34 Fail(String),
36 Skip,
39}
40
41#[derive(Clone)]
43pub enum Rule {
44 Required,
46 Present,
48 NotNull,
50 String,
52 Integer,
54 Numeric,
56 Boolean,
58 Array,
60 Min(f64),
63 Max(f64),
65 Size(f64),
67 Between(f64, f64),
69 Email,
71 Url,
73 Slug,
75 Uuid,
77 Alpha,
79 AlphaNumeric,
81 AlphaDash,
83 In(Vec<String>),
85 NotIn(Vec<String>),
87 StartsWith(String),
89 EndsWith(String),
91 Date,
93 After(NaiveDate),
95 Before(NaiveDate),
97 Confirmed,
99 Same(String),
101 Different(String),
103 Custom {
105 message: String,
107 check: Arc<dyn Fn(&Value) -> bool + Send + Sync>,
109 },
110}
111
112impl std::fmt::Debug for Rule {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 Rule::Custom { message, .. } => write!(f, "Custom({message:?})"),
116 other => f.write_str(other.name()),
117 }
118 }
119}
120
121impl Rule {
122 pub fn name(&self) -> &'static str {
124 match self {
125 Rule::Required => "required",
126 Rule::Present => "present",
127 Rule::NotNull => "not_null",
128 Rule::String => "string",
129 Rule::Integer => "integer",
130 Rule::Numeric => "numeric",
131 Rule::Boolean => "boolean",
132 Rule::Array => "array",
133 Rule::Min(_) => "min",
134 Rule::Max(_) => "max",
135 Rule::Size(_) => "size",
136 Rule::Between(_, _) => "between",
137 Rule::Email => "email",
138 Rule::Url => "url",
139 Rule::Slug => "slug",
140 Rule::Uuid => "uuid",
141 Rule::Alpha => "alpha",
142 Rule::AlphaNumeric => "alpha_num",
143 Rule::AlphaDash => "alpha_dash",
144 Rule::In(_) => "in",
145 Rule::NotIn(_) => "not_in",
146 Rule::StartsWith(_) => "starts_with",
147 Rule::EndsWith(_) => "ends_with",
148 Rule::Date => "date",
149 Rule::After(_) => "after",
150 Rule::Before(_) => "before",
151 Rule::Confirmed => "confirmed",
152 Rule::Same(_) => "same",
153 Rule::Different(_) => "different",
154 Rule::Custom { .. } => "custom",
155 }
156 }
157
158 pub fn applies_to_missing(&self) -> bool {
163 matches!(self, Rule::Required | Rule::Present | Rule::NotNull)
164 }
165
166 pub fn check(&self, field: &str, value: Option<&Value>, all: &Value) -> Outcome {
171 match self {
173 Rule::Required => {
174 return if is_filled(value) {
175 Outcome::Pass
176 } else {
177 Outcome::Fail(format!("The {} field is required.", label(field)))
178 };
179 }
180 Rule::Present => {
181 return if value.is_some() {
182 Outcome::Pass
183 } else {
184 Outcome::Fail(format!("The {} field must be present.", label(field)))
185 };
186 }
187 Rule::NotNull => {
188 return match value {
189 Some(Value::Null) => {
190 Outcome::Fail(format!("The {} field must not be null.", label(field)))
191 }
192 _ => Outcome::Pass,
193 };
194 }
195 _ => {}
196 }
197
198 let Some(value) = value else { return Outcome::Skip };
199 if value.is_null() {
200 return Outcome::Skip;
201 }
202
203 let name = label(field);
204 match self {
205 Rule::Required | Rule::Present | Rule::NotNull => Outcome::Pass,
206
207 Rule::String => check(value.is_string(), format!("The {name} field must be a string.")),
208 Rule::Integer => {
209 check(as_integer(value).is_some(), format!("The {name} field must be an integer."))
210 }
211 Rule::Numeric => {
212 check(as_number(value).is_some(), format!("The {name} field must be a number."))
213 }
214 Rule::Boolean => {
215 check(as_bool(value).is_some(), format!("The {name} field must be true or false."))
216 }
217 Rule::Array => check(value.is_array(), format!("The {name} field must be an array.")),
218
219 Rule::Min(min) => match measure(value) {
220 Measure::Number(n) => check(
221 n >= *min,
222 format!("The {name} field must be at least {}.", trim_float(*min)),
223 ),
224 Measure::Length(len) => check(
225 len as f64 >= *min,
226 format!(
227 "The {name} field must be at least {} {}.",
228 trim_float(*min),
229 unit(value)
230 ),
231 ),
232 },
233 Rule::Max(max) => match measure(value) {
234 Measure::Number(n) => check(
235 n <= *max,
236 format!("The {name} field must not be greater than {}.", trim_float(*max)),
237 ),
238 Measure::Length(len) => check(
239 len as f64 <= *max,
240 format!(
241 "The {name} field must not be greater than {} {}.",
242 trim_float(*max),
243 unit(value)
244 ),
245 ),
246 },
247 Rule::Size(size) => match measure(value) {
248 Measure::Number(n) => check(
249 (n - *size).abs() < f64::EPSILON,
250 format!("The {name} field must be {}.", trim_float(*size)),
251 ),
252 Measure::Length(len) => check(
253 len as f64 == *size,
254 format!("The {name} field must be {} {}.", trim_float(*size), unit(value)),
255 ),
256 },
257 Rule::Between(low, high) => {
258 let actual = match measure(value) {
259 Measure::Number(n) => n,
260 Measure::Length(len) => len as f64,
261 };
262 check(
263 actual >= *low && actual <= *high,
264 format!(
265 "The {name} field must be between {} and {}.",
266 trim_float(*low),
267 trim_float(*high)
268 ),
269 )
270 }
271
272 Rule::Email => check(
273 text(value).is_some_and(|v| is_email(&v)),
274 format!("The {name} field must be a valid email address."),
275 ),
276 Rule::Url => check(
277 text(value).is_some_and(|v| is_url(&v)),
278 format!("The {name} field must be a valid URL."),
279 ),
280 Rule::Slug => check(
281 text(value).is_some_and(|v| {
282 !v.is_empty()
283 && v.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
284 }),
285 format!("The {name} field must be a valid slug."),
286 ),
287 Rule::Uuid => check(
288 text(value).is_some_and(|v| is_uuid(&v)),
289 format!("The {name} field must be a valid UUID."),
290 ),
291 Rule::Alpha => check(
292 text(value)
293 .is_some_and(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_alphabetic())),
294 format!("The {name} field must only contain letters."),
295 ),
296 Rule::AlphaNumeric => check(
297 text(value)
298 .is_some_and(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_alphanumeric())),
299 format!("The {name} field must only contain letters and numbers."),
300 ),
301 Rule::AlphaDash => check(
302 text(value).is_some_and(|v| {
303 !v.is_empty()
304 && v.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
305 }),
306 format!(
307 "The {name} field must only contain letters, numbers, dashes and underscores."
308 ),
309 ),
310
311 Rule::In(allowed) => check(
312 text(value).is_some_and(|v| allowed.contains(&v)),
313 format!("The selected {name} is invalid."),
314 ),
315 Rule::NotIn(denied) => check(
316 text(value).is_some_and(|v| !denied.contains(&v)),
317 format!("The selected {name} is invalid."),
318 ),
319 Rule::StartsWith(prefix) => check(
320 text(value).is_some_and(|v| v.starts_with(prefix.as_str())),
321 format!("The {name} field must start with {prefix}."),
322 ),
323 Rule::EndsWith(suffix) => check(
324 text(value).is_some_and(|v| v.ends_with(suffix.as_str())),
325 format!("The {name} field must end with {suffix}."),
326 ),
327
328 Rule::Date => check(
329 text(value).is_some_and(|v| parse_date(&v).is_some()),
330 format!("The {name} field must be a valid date."),
331 ),
332 Rule::After(bound) => check(
333 text(value).and_then(|v| parse_date(&v)).is_some_and(|d| d > *bound),
334 format!("The {name} field must be a date after {bound}."),
335 ),
336 Rule::Before(bound) => check(
337 text(value).and_then(|v| parse_date(&v)).is_some_and(|d| d < *bound),
338 format!("The {name} field must be a date before {bound}."),
339 ),
340
341 Rule::Confirmed => {
342 let other = format!("{field}_confirmation");
343 check(
344 crate::lookup(all, &other) == Some(value),
345 format!("The {name} field confirmation does not match."),
346 )
347 }
348 Rule::Same(other) => check(
349 crate::lookup(all, other) == Some(value),
350 format!("The {name} field must match {}.", label(other)),
351 ),
352 Rule::Different(other) => check(
353 crate::lookup(all, other) != Some(value),
354 format!("The {name} field must be different from {}.", label(other)),
355 ),
356
357 Rule::Custom { message, check: predicate } => check(predicate(value), message.clone()),
358 }
359 }
360}
361
362fn check(passed: bool, message: String) -> Outcome {
363 if passed {
364 Outcome::Pass
365 } else {
366 Outcome::Fail(message)
367 }
368}
369
370fn is_filled(value: Option<&Value>) -> bool {
372 match value {
373 None | Some(Value::Null) => false,
374 Some(Value::String(s)) => !s.trim().is_empty(),
375 Some(Value::Array(items)) => !items.is_empty(),
376 Some(Value::Object(map)) => !map.is_empty(),
377 Some(_) => true,
378 }
379}
380
381enum Measure {
384 Number(f64),
385 Length(usize),
386}
387
388fn measure(value: &Value) -> Measure {
389 match value {
390 Value::Number(n) => Measure::Number(n.as_f64().unwrap_or(0.0)),
391 Value::String(s) => match s.parse::<f64>() {
392 Ok(n) => Measure::Number(n),
395 Err(_) => Measure::Length(s.chars().count()),
396 },
397 Value::Array(items) => Measure::Length(items.len()),
398 Value::Object(map) => Measure::Length(map.len()),
399 Value::Bool(_) | Value::Null => Measure::Length(0),
400 }
401}
402
403fn unit(value: &Value) -> &'static str {
404 match value {
405 Value::Array(_) | Value::Object(_) => "items",
406 _ => "characters",
407 }
408}
409
410fn trim_float(value: f64) -> String {
411 if value.fract() == 0.0 {
412 format!("{}", value as i64)
413 } else {
414 format!("{value}")
415 }
416}
417
418fn label(field: &str) -> String {
420 field.replace(['_', '.'], " ")
421}
422
423fn text(value: &Value) -> Option<String> {
424 match value {
425 Value::String(s) => Some(s.clone()),
426 Value::Number(n) => Some(n.to_string()),
427 Value::Bool(b) => Some(b.to_string()),
428 _ => None,
429 }
430}
431
432fn as_number(value: &Value) -> Option<f64> {
433 match value {
434 Value::Number(n) => n.as_f64(),
435 Value::String(s) => s.trim().parse().ok(),
436 _ => None,
437 }
438}
439
440fn as_integer(value: &Value) -> Option<i64> {
441 match value {
442 Value::Number(n) => n.as_i64(),
443 Value::String(s) => s.trim().parse().ok(),
444 _ => None,
445 }
446}
447
448fn as_bool(value: &Value) -> Option<bool> {
449 match value {
450 Value::Bool(b) => Some(*b),
451 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
452 "true" | "1" | "on" | "yes" => Some(true),
453 "false" | "0" | "off" | "no" => Some(false),
454 _ => None,
455 },
456 Value::Number(n) => n
457 .as_i64()
458 .map(|i| i == 0 || i == 1)
459 .and_then(|ok| ok.then(|| n.as_i64().expect("just read") != 0)),
460 _ => None,
461 }
462}
463
464fn is_email(value: &str) -> bool {
472 let Some((local, domain)) = value.split_once('@') else {
473 return false;
474 };
475 if local.is_empty() || domain.is_empty() || value.contains(' ') {
476 return false;
477 }
478 if domain.contains('@') || domain.starts_with('.') || domain.ends_with('.') {
479 return false;
480 }
481 match domain.split_once('.') {
482 Some((head, tail)) => !head.is_empty() && !tail.is_empty() && !tail.contains(' '),
483 None => false,
484 }
485}
486
487fn is_url(value: &str) -> bool {
488 let rest = value.strip_prefix("https://").or_else(|| value.strip_prefix("http://"));
489 match rest {
490 Some(rest) => !rest.is_empty() && !rest.starts_with('/') && !rest.contains(' '),
491 None => false,
492 }
493}
494
495fn is_uuid(value: &str) -> bool {
496 let groups = [8, 4, 4, 4, 12];
497 let mut parts = value.split('-');
498 for expected in groups {
499 let Some(part) = parts.next() else { return false };
500 if part.len() != expected || !part.bytes().all(|b| b.is_ascii_hexdigit()) {
501 return false;
502 }
503 }
504 parts.next().is_none()
505}
506
507fn parse_date(value: &str) -> Option<NaiveDate> {
508 NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").ok()
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use serde_json::json;
515
516 fn run(rule: &Rule, value: Value) -> Outcome {
517 let all = json!({});
518 rule.check("field", Some(&value), &all)
519 }
520
521 fn passes(rule: &Rule, value: Value) -> bool {
522 run(rule, value) == Outcome::Pass
523 }
524
525 fn message(rule: &Rule, field: &str, value: Value) -> String {
526 match rule.check(field, Some(&value), &json!({})) {
527 Outcome::Fail(message) => message,
528 other => panic!("expected a failure, got {other:?}"),
529 }
530 }
531
532 #[test]
533 fn required_rejects_absent_null_and_blank() {
534 let all = json!({});
535 assert!(matches!(Rule::Required.check("f", None, &all), Outcome::Fail(_)));
536 assert!(matches!(Rule::Required.check("f", Some(&json!(null)), &all), Outcome::Fail(_)));
537 assert!(matches!(Rule::Required.check("f", Some(&json!("")), &all), Outcome::Fail(_)));
538 assert!(matches!(Rule::Required.check("f", Some(&json!(" ")), &all), Outcome::Fail(_)));
539 assert!(matches!(Rule::Required.check("f", Some(&json!([])), &all), Outcome::Fail(_)));
540 assert_eq!(Rule::Required.check("f", Some(&json!("x")), &all), Outcome::Pass);
541 assert_eq!(Rule::Required.check("f", Some(&json!(0)), &all), Outcome::Pass);
542 assert_eq!(Rule::Required.check("f", Some(&json!(false)), &all), Outcome::Pass);
543 }
544
545 #[test]
546 fn present_only_asks_whether_the_key_exists() {
547 let all = json!({});
548 assert!(matches!(Rule::Present.check("f", None, &all), Outcome::Fail(_)));
549 assert_eq!(Rule::Present.check("f", Some(&json!(null)), &all), Outcome::Pass);
550 assert_eq!(Rule::Present.check("f", Some(&json!("")), &all), Outcome::Pass);
551 }
552
553 #[test]
554 fn other_rules_skip_absent_and_null_values() {
555 let all = json!({});
556 assert_eq!(Rule::Email.check("f", None, &all), Outcome::Skip);
557 assert_eq!(Rule::Email.check("f", Some(&json!(null)), &all), Outcome::Skip);
558 assert_eq!(Rule::Integer.check("f", None, &all), Outcome::Skip);
559 }
560
561 #[test]
562 fn type_rules() {
563 assert!(passes(&Rule::String, json!("x")));
564 assert!(!passes(&Rule::String, json!(1)));
565
566 assert!(passes(&Rule::Integer, json!(5)));
567 assert!(passes(&Rule::Integer, json!("5")), "a form sends numbers as strings");
568 assert!(!passes(&Rule::Integer, json!("5.5")));
569 assert!(!passes(&Rule::Integer, json!("abc")));
570
571 assert!(passes(&Rule::Numeric, json!("5.5")));
572 assert!(passes(&Rule::Numeric, json!(5)));
573 assert!(!passes(&Rule::Numeric, json!("abc")));
574
575 assert!(passes(&Rule::Boolean, json!(true)));
576 assert!(passes(&Rule::Boolean, json!("on")));
577 assert!(passes(&Rule::Boolean, json!("0")));
578 assert!(!passes(&Rule::Boolean, json!("maybe")));
579
580 assert!(passes(&Rule::Array, json!([1])));
581 assert!(!passes(&Rule::Array, json!("x")));
582 }
583
584 #[test]
585 fn size_rules_measure_numbers_by_value_and_text_by_length() {
586 assert!(passes(&Rule::Min(3.0), json!("abc")));
587 assert!(!passes(&Rule::Min(3.0), json!("ab")));
588
589 assert!(passes(&Rule::Min(18.0), json!("21")));
591 assert!(!passes(&Rule::Min(18.0), json!("17")));
592
593 assert!(passes(&Rule::Max(2.0), json!([1, 2])));
594 assert!(!passes(&Rule::Max(2.0), json!([1, 2, 3])));
595
596 assert!(passes(&Rule::Size(4.0), json!("abcd")));
597 assert!(passes(&Rule::Between(1.0, 3.0), json!("ab")));
598 assert!(!passes(&Rule::Between(1.0, 3.0), json!("abcd")));
599 }
600
601 #[test]
602 fn size_messages_name_the_right_unit() {
603 assert!(message(&Rule::Max(2.0), "tags", json!([1, 2, 3])).contains("2 items"));
604 assert!(message(&Rule::Max(2.0), "name", json!("abc")).contains("2 characters"));
605 }
606
607 #[test]
608 fn email_accepts_the_ordinary_and_rejects_the_obviously_broken() {
609 for good in ["a@b.co", "first.last+tag@sub.example.com", "x@y.z"] {
610 assert!(passes(&Rule::Email, json!(good)), "{good} should pass");
611 }
612 for bad in ["", "plain", "@b.co", "a@", "a@b", "a b@c.co", "a@b@c.co", "a@.co", "a@b."] {
613 assert!(!passes(&Rule::Email, json!(bad)), "{bad} should fail");
614 }
615 }
616
617 #[test]
618 fn url_requires_a_scheme_and_a_host() {
619 assert!(passes(&Rule::Url, json!("https://example.com")));
620 assert!(passes(&Rule::Url, json!("http://example.com/a?b=1")));
621 assert!(!passes(&Rule::Url, json!("example.com")));
622 assert!(!passes(&Rule::Url, json!("https://")));
623 assert!(!passes(&Rule::Url, json!("ftp://example.com")));
624 assert!(!passes(&Rule::Url, json!("https:// example.com")));
625 }
626
627 #[test]
628 fn character_class_rules() {
629 assert!(passes(&Rule::Alpha, json!("abc")));
630 assert!(!passes(&Rule::Alpha, json!("a1")));
631 assert!(passes(&Rule::AlphaNumeric, json!("a1")));
632 assert!(!passes(&Rule::AlphaNumeric, json!("a-1")));
633 assert!(passes(&Rule::AlphaDash, json!("a-1_b")));
634 assert!(!passes(&Rule::AlphaDash, json!("a b")));
635 assert!(passes(&Rule::Slug, json!("hello-world")));
636 assert!(!passes(&Rule::Slug, json!("hello world")));
637 assert!(passes(&Rule::Uuid, json!("123e4567-e89b-12d3-a456-426614174000")));
638 assert!(!passes(&Rule::Uuid, json!("nope")));
639 }
640
641 #[test]
642 fn membership_rules() {
643 let allowed = Rule::In(vec!["draft".into(), "live".into()]);
644 assert!(passes(&allowed, json!("draft")));
645 assert!(!passes(&allowed, json!("deleted")));
646
647 let denied = Rule::NotIn(vec!["admin".into()]);
648 assert!(passes(&denied, json!("user")));
649 assert!(!passes(&denied, json!("admin")));
650
651 assert!(passes(&Rule::StartsWith("pk_".into()), json!("pk_123")));
652 assert!(!passes(&Rule::StartsWith("pk_".into()), json!("sk_123")));
653 assert!(passes(&Rule::EndsWith(".png".into()), json!("a.png")));
654 }
655
656 #[test]
657 fn date_rules() {
658 assert!(passes(&Rule::Date, json!("2026-07-25")));
659 assert!(!passes(&Rule::Date, json!("25/07/2026")));
660 assert!(!passes(&Rule::Date, json!("2026-13-01")));
661
662 let bound = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
663 assert!(passes(&Rule::After(bound), json!("2026-07-25")));
664 assert!(!passes(&Rule::After(bound), json!("2025-12-31")));
665 assert!(!passes(&Rule::After(bound), json!("2026-01-01")), "after is exclusive");
666 assert!(passes(&Rule::Before(bound), json!("2025-12-31")));
667 }
668
669 #[test]
670 fn cross_field_rules_read_the_whole_input() {
671 let all = json!({ "password": "secret", "password_confirmation": "secret", "other": "x" });
672
673 assert_eq!(Rule::Confirmed.check("password", Some(&json!("secret")), &all), Outcome::Pass);
674
675 let mismatched = json!({ "password_confirmation": "different" });
676 assert!(matches!(
677 Rule::Confirmed.check("password", Some(&json!("secret")), &mismatched),
678 Outcome::Fail(_)
679 ));
680
681 assert_eq!(
682 Rule::Same("password".into()).check("f", Some(&json!("secret")), &all),
683 Outcome::Pass
684 );
685 assert_eq!(
686 Rule::Different("other".into()).check("f", Some(&json!("y")), &all),
687 Outcome::Pass
688 );
689 assert!(matches!(
690 Rule::Different("other".into()).check("f", Some(&json!("x")), &all),
691 Outcome::Fail(_)
692 ));
693 }
694
695 #[test]
696 fn confirmed_fails_when_the_confirmation_is_absent() {
697 let all = json!({ "password": "secret" });
698 assert!(matches!(
699 Rule::Confirmed.check("password", Some(&json!("secret")), &all),
700 Outcome::Fail(_)
701 ));
702 }
703
704 #[test]
705 fn a_custom_rule_supplies_its_own_message() {
706 let rule = Rule::Custom {
707 message: "The code must start with X.".into(),
708 check: Arc::new(|value| value.as_str().is_some_and(|s| s.starts_with('X'))),
709 };
710 assert!(passes(&rule, json!("X1")));
711 assert_eq!(message(&rule, "code", json!("Y1")), "The code must start with X.");
712 }
713
714 #[test]
715 fn messages_humanise_the_field_name() {
716 assert_eq!(
717 message(&Rule::Required, "email_address", json!(null)),
718 "The email address field is required."
719 );
720 }
721}