Skip to main content

sofie_core/
constraint.rs

1use std::cmp::Ordering::{Greater, Less};
2
3use crate::{FieldError, Value};
4
5/// A bound counts characters for text and compares numbers; the derive picks by [`Measured::TEXT`].
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum Constraint {
8    Required,
9    MinLength(i64),
10    MaxLength(i64),
11    Min(i64),
12    Max(i64),
13    /// Only the database can check it.
14    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}