1use std::cmp::Ordering::{Greater, Less};
2
3use crate::{FieldError, Value};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum Constraint {
8 Required,
9 MinLength(i64),
10 MaxLength(i64),
11 Min(i64),
12 Max(i64),
13 Unique,
15}
16
17pub trait Measured {
18 const TEXT: bool;
19
20 fn size(&self) -> Option<i64>;
21
22 fn is_blank(&self) -> bool {
23 false
24 }
25}
26
27impl Measured for String {
28 const TEXT: bool = true;
29
30 fn size(&self) -> Option<i64> {
31 Some(i64::try_from(self.chars().count()).unwrap_or(i64::MAX))
32 }
33
34 fn is_blank(&self) -> bool {
35 self.trim().is_empty()
36 }
37}
38
39macro_rules! measured_number {
40 ($($number:ty),*) => {$(
41 impl Measured for $number {
42 const TEXT: bool = false;
43
44 fn size(&self) -> Option<i64> {
45 Some(i64::from(*self))
46 }
47 }
48 )*};
49}
50
51measured_number!(i8, i16, i32, i64, u8, u16, u32);
52
53impl<T: Measured> Measured for Option<T> {
54 const TEXT: bool = T::TEXT;
55
56 fn size(&self) -> Option<i64> {
57 self.as_ref().and_then(Measured::size)
58 }
59
60 fn is_blank(&self) -> bool {
61 self.as_ref().is_none_or(Measured::is_blank)
62 }
63}
64
65impl<V: Value<Inner: Measured>> Measured for V {
66 const TEXT: bool = V::Inner::TEXT;
67
68 fn size(&self) -> Option<i64> {
69 self.inner().size()
70 }
71
72 fn is_blank(&self) -> bool {
73 self.inner().is_blank()
74 }
75}
76
77pub struct FieldRules<T> {
78 pub constraints: &'static [Constraint],
79 refuse: fn(&[Constraint], &str, &T) -> Option<FieldError>,
80}
81
82impl<T> Clone for FieldRules<T> {
83 fn clone(&self) -> Self {
84 *self
85 }
86}
87
88impl<T> Copy for FieldRules<T> {}
89
90impl<T> FieldRules<T> {
91 pub const NONE: Self = Self {
92 constraints: &[],
93 refuse: |_, _, _| None,
94 };
95
96 #[must_use]
97 pub fn refusal(self, field: &str, value: &T) -> Option<FieldError> {
98 (self.refuse)(self.constraints, field, value)
99 }
100}
101
102impl<T: Measured> FieldRules<T> {
103 #[must_use]
104 pub const fn new(constraints: &'static [Constraint]) -> Self {
105 Self {
106 constraints,
107 refuse: |constraints, field, value| {
108 constraints
109 .iter()
110 .find_map(|constraint| constraint.refusal(field, value))
111 },
112 }
113 }
114}
115
116impl Constraint {
117 #[must_use]
118 pub fn refusal<T: Measured>(self, field: &str, value: &T) -> Option<FieldError> {
119 let (key, name, unit, bound, refused) = match self {
120 Self::Required => {
121 return value.is_blank().then(|| FieldError::required(field));
122 }
123 Self::Unique => return None,
124 Self::MinLength(min) => ("validation.min_length", "min", " characters", min, Less),
125 Self::MaxLength(max) => ("validation.max_length", "max", " characters", max, Greater),
126 Self::Min(min) => ("validation.min", "min", "", min, Less),
127 Self::Max(max) => ("validation.max", "max", "", max, Greater),
128 };
129 if value.size()?.cmp(&bound) != refused {
130 return None;
131 }
132 let comparison = if refused == Less {
133 "at least"
134 } else {
135 "at most"
136 };
137 let message = format!("must be {comparison} {bound}{unit}");
138 Some(FieldError::new(field, key, message).with(name, bound.to_string()))
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use std::borrow::Cow;
145
146 use super::*;
147
148 fn refused<T: Measured>(
149 constraint: Constraint,
150 value: &T,
151 ) -> Option<(Cow<'static, str>, String)> {
152 constraint
153 .refusal("name", value)
154 .map(|error| (error.key, error.message))
155 }
156
157 #[test]
158 fn bounds_count_characters_of_text_and_compare_numbers() {
159 let long = "ğğğğ".to_owned();
160
161 assert_eq!(refused(Constraint::MaxLength(4), &long), None);
162 assert_eq!(
163 refused(Constraint::MaxLength(3), &long),
164 Some((
165 "validation.max_length".into(),
166 "must be at most 3 characters".to_owned()
167 ))
168 );
169 assert_eq!(
170 refused(Constraint::Min(1), &0_i32),
171 Some(("validation.min".into(), "must be at least 1".to_owned()))
172 );
173 assert_eq!(refused(Constraint::Max(10), &10_u8), None);
174 }
175
176 #[test]
177 fn a_refusal_carries_its_bound_as_an_argument() {
178 let error = Constraint::Max(5).refusal("count", &6_i64).unwrap();
179
180 assert_eq!(error.field, "count");
181 assert_eq!(error.arguments, [(Cow::from("max"), "5".to_owned())]);
182 }
183
184 #[test]
185 fn required_refuses_blank_text_and_absent_values_only() {
186 assert!(refused(Constraint::Required, &" ".to_owned()).is_some());
187 assert!(refused(Constraint::Required, &None::<i64>).is_some());
188 assert!(refused(Constraint::Required, &Some(0_i64)).is_none());
189 assert!(refused(Constraint::Required, &0_i64).is_none());
190 }
191
192 #[test]
193 fn an_absent_optional_value_keeps_every_bound() {
194 assert!(refused(Constraint::MinLength(3), &None::<String>).is_none());
195 assert!(refused(Constraint::Unique, &"taken".to_owned()).is_none());
196 }
197}