Skip to main content

rustlavel_validation/
rule.rs

1//! The rules themselves, and the two ways to declare them.
2//!
3//! A rule set can be written the way it is in Laravel — `"required|email|max:255"`
4//! — or built up with methods: `Rule::required().email().max(255)`. Both land on
5//! the same [`Rules`] value, so nothing downstream has to care which was used.
6//! The string form is what a developer already knows and what a config file or
7//! a generator can emit; the builder form is what the compiler can check, so a
8//! misspelled rule is a compile error instead of a runtime panic.
9
10use rustlavel_core::{Error, Result};
11
12/// One validation rule, with its parameters already parsed.
13///
14/// This is the single internal representation: the string parser and the
15/// builder are two front doors onto the same enum.
16#[derive(Debug, Clone, PartialEq)]
17pub enum Rule {
18    /// The field must be present and not blank.
19    Required,
20    /// An explicit `null` is allowed, and skips the remaining rules.
21    Nullable,
22    String,
23    Integer,
24    Numeric,
25    Boolean,
26    Email,
27    Url,
28    /// Minimum length for a string, value for a number, item count for an array.
29    Min(f64),
30    /// Maximum length for a string, value for a number, item count for an array.
31    Max(f64),
32    /// Inclusive range, measured the same way as [`Rule::Min`].
33    Between(f64, f64),
34    /// Exact length, value, or item count.
35    Size(f64),
36    In(Vec<String>),
37    NotIn(Vec<String>),
38    /// A `<field>_confirmation` field must be present and equal.
39    Confirmed,
40    /// Must equal another field.
41    Same(String),
42    /// Must differ from another field.
43    Different(String),
44    Alpha,
45    AlphaNum,
46    AlphaDash,
47    StartsWith(Vec<String>),
48    EndsWith(Vec<String>),
49    /// A `YYYY-MM-DD` calendar date.
50    Date,
51    Uuid,
52    Array,
53}
54
55impl Rule {
56    /// The name this rule is written as in the string syntax, which is also the
57    /// key used to look up its message and any per-field override.
58    pub fn name(&self) -> &'static str {
59        match self {
60            Rule::Required => "required",
61            Rule::Nullable => "nullable",
62            Rule::String => "string",
63            Rule::Integer => "integer",
64            Rule::Numeric => "numeric",
65            Rule::Boolean => "boolean",
66            Rule::Email => "email",
67            Rule::Url => "url",
68            Rule::Min(_) => "min",
69            Rule::Max(_) => "max",
70            Rule::Between(_, _) => "between",
71            Rule::Size(_) => "size",
72            Rule::In(_) => "in",
73            Rule::NotIn(_) => "not_in",
74            Rule::Confirmed => "confirmed",
75            Rule::Same(_) => "same",
76            Rule::Different(_) => "different",
77            Rule::Alpha => "alpha",
78            Rule::AlphaNum => "alpha_num",
79            Rule::AlphaDash => "alpha_dash",
80            Rule::StartsWith(_) => "starts_with",
81            Rule::EndsWith(_) => "ends_with",
82            Rule::Date => "date",
83            Rule::Uuid => "uuid",
84            Rule::Array => "array",
85        }
86    }
87
88    // --- Builder entry points. Each starts a rule set that keeps chaining. ---
89
90    pub fn required() -> Rules {
91        Rules::new().required()
92    }
93
94    pub fn nullable() -> Rules {
95        Rules::new().nullable()
96    }
97
98    pub fn string() -> Rules {
99        Rules::new().string()
100    }
101
102    pub fn integer() -> Rules {
103        Rules::new().integer()
104    }
105
106    pub fn numeric() -> Rules {
107        Rules::new().numeric()
108    }
109
110    pub fn boolean() -> Rules {
111        Rules::new().boolean()
112    }
113
114    pub fn email() -> Rules {
115        Rules::new().email()
116    }
117
118    pub fn url() -> Rules {
119        Rules::new().url()
120    }
121
122    pub fn min(bound: impl Into<f64>) -> Rules {
123        Rules::new().min(bound)
124    }
125
126    pub fn max(bound: impl Into<f64>) -> Rules {
127        Rules::new().max(bound)
128    }
129
130    pub fn between(low: impl Into<f64>, high: impl Into<f64>) -> Rules {
131        Rules::new().between(low, high)
132    }
133
134    pub fn size(exact: impl Into<f64>) -> Rules {
135        Rules::new().size(exact)
136    }
137
138    /// The builder spelling of `in:a,b,c`; `in` is a Rust keyword.
139    pub fn one_of<S: Into<String>>(values: impl IntoIterator<Item = S>) -> Rules {
140        Rules::new().one_of(values)
141    }
142
143    pub fn not_in<S: Into<String>>(values: impl IntoIterator<Item = S>) -> Rules {
144        Rules::new().not_in(values)
145    }
146
147    pub fn confirmed() -> Rules {
148        Rules::new().confirmed()
149    }
150
151    pub fn same(other: impl Into<String>) -> Rules {
152        Rules::new().same(other)
153    }
154
155    pub fn different(other: impl Into<String>) -> Rules {
156        Rules::new().different(other)
157    }
158
159    pub fn alpha() -> Rules {
160        Rules::new().alpha()
161    }
162
163    pub fn alpha_num() -> Rules {
164        Rules::new().alpha_num()
165    }
166
167    pub fn alpha_dash() -> Rules {
168        Rules::new().alpha_dash()
169    }
170
171    pub fn starts_with<S: Into<String>>(prefixes: impl IntoIterator<Item = S>) -> Rules {
172        Rules::new().starts_with(prefixes)
173    }
174
175    pub fn ends_with<S: Into<String>>(suffixes: impl IntoIterator<Item = S>) -> Rules {
176        Rules::new().ends_with(suffixes)
177    }
178
179    pub fn date() -> Rules {
180        Rules::new().date()
181    }
182
183    pub fn uuid() -> Rules {
184        Rules::new().uuid()
185    }
186
187    pub fn array() -> Rules {
188        Rules::new().array()
189    }
190}
191
192/// An ordered set of rules for one field.
193///
194/// Order is preserved because it is the order the messages come back in, and a
195/// developer who wrote `required|email` expects to be told about the missing
196/// field before the malformed one.
197#[derive(Debug, Clone, Default, PartialEq)]
198pub struct Rules {
199    rules: Vec<Rule>,
200}
201
202impl Rules {
203    pub fn new() -> Self {
204        Rules::default()
205    }
206
207    /// Parse Laravel's pipe/colon syntax: `"required|between:1,10|in:a,b,c"`.
208    ///
209    /// Whitespace around a rule is ignored and empty segments are dropped, so a
210    /// spec split across lines in a config file still parses.
211    pub fn parse(spec: &str) -> Result<Rules> {
212        let mut rules = Rules::new();
213        for segment in spec.split('|') {
214            let segment = segment.trim();
215            if segment.is_empty() {
216                continue;
217            }
218            let (name, parameters) = match segment.split_once(':') {
219                Some((name, rest)) => (name.trim(), Some(rest)),
220                None => (segment, None),
221            };
222            rules.rules.push(parse_rule(name, parameters)?);
223        }
224        Ok(rules)
225    }
226
227    pub fn rules(&self) -> &[Rule] {
228        &self.rules
229    }
230
231    pub fn is_empty(&self) -> bool {
232        self.rules.is_empty()
233    }
234
235    pub fn len(&self) -> usize {
236        self.rules.len()
237    }
238
239    /// Whether a rule with this name is in the set. The validator asks this to
240    /// find `nullable`, and to decide whether `min` counts characters or value.
241    pub fn has(&self, name: &str) -> bool {
242        self.rules.iter().any(|rule| rule.name() == name)
243    }
244
245    /// Append an already-built rule. The chaining methods below all go through here.
246    pub fn push(mut self, rule: Rule) -> Self {
247        self.rules.push(rule);
248        self
249    }
250
251    pub fn required(self) -> Self {
252        self.push(Rule::Required)
253    }
254
255    pub fn nullable(self) -> Self {
256        self.push(Rule::Nullable)
257    }
258
259    pub fn string(self) -> Self {
260        self.push(Rule::String)
261    }
262
263    pub fn integer(self) -> Self {
264        self.push(Rule::Integer)
265    }
266
267    pub fn numeric(self) -> Self {
268        self.push(Rule::Numeric)
269    }
270
271    pub fn boolean(self) -> Self {
272        self.push(Rule::Boolean)
273    }
274
275    pub fn email(self) -> Self {
276        self.push(Rule::Email)
277    }
278
279    pub fn url(self) -> Self {
280        self.push(Rule::Url)
281    }
282
283    pub fn min(self, bound: impl Into<f64>) -> Self {
284        self.push(Rule::Min(bound.into()))
285    }
286
287    pub fn max(self, bound: impl Into<f64>) -> Self {
288        self.push(Rule::Max(bound.into()))
289    }
290
291    pub fn between(self, low: impl Into<f64>, high: impl Into<f64>) -> Self {
292        self.push(Rule::Between(low.into(), high.into()))
293    }
294
295    pub fn size(self, exact: impl Into<f64>) -> Self {
296        self.push(Rule::Size(exact.into()))
297    }
298
299    /// The builder spelling of `in:a,b,c`; `in` is a Rust keyword.
300    pub fn one_of<S: Into<String>>(self, values: impl IntoIterator<Item = S>) -> Self {
301        self.push(Rule::In(collect(values)))
302    }
303
304    pub fn not_in<S: Into<String>>(self, values: impl IntoIterator<Item = S>) -> Self {
305        self.push(Rule::NotIn(collect(values)))
306    }
307
308    pub fn confirmed(self) -> Self {
309        self.push(Rule::Confirmed)
310    }
311
312    pub fn same(self, other: impl Into<String>) -> Self {
313        self.push(Rule::Same(other.into()))
314    }
315
316    pub fn different(self, other: impl Into<String>) -> Self {
317        self.push(Rule::Different(other.into()))
318    }
319
320    pub fn alpha(self) -> Self {
321        self.push(Rule::Alpha)
322    }
323
324    pub fn alpha_num(self) -> Self {
325        self.push(Rule::AlphaNum)
326    }
327
328    pub fn alpha_dash(self) -> Self {
329        self.push(Rule::AlphaDash)
330    }
331
332    pub fn starts_with<S: Into<String>>(self, prefixes: impl IntoIterator<Item = S>) -> Self {
333        self.push(Rule::StartsWith(collect(prefixes)))
334    }
335
336    pub fn ends_with<S: Into<String>>(self, suffixes: impl IntoIterator<Item = S>) -> Self {
337        self.push(Rule::EndsWith(collect(suffixes)))
338    }
339
340    pub fn date(self) -> Self {
341        self.push(Rule::Date)
342    }
343
344    pub fn uuid(self) -> Self {
345        self.push(Rule::Uuid)
346    }
347
348    pub fn array(self) -> Self {
349        self.push(Rule::Array)
350    }
351}
352
353impl std::str::FromStr for Rules {
354    type Err = Error;
355
356    fn from_str(spec: &str) -> Result<Rules> {
357        Rules::parse(spec)
358    }
359}
360
361impl FromIterator<Rule> for Rules {
362    fn from_iter<I: IntoIterator<Item = Rule>>(rules: I) -> Self {
363        Rules { rules: rules.into_iter().collect() }
364    }
365}
366
367/// Anything that can name a rule set when registering a field.
368///
369/// This is what lets `validator.rule("email", "required|email")` and
370/// `validator.rule("email", Rule::required().email())` sit side by side.
371pub trait IntoRules {
372    fn into_rules(self) -> Rules;
373}
374
375impl IntoRules for Rules {
376    fn into_rules(self) -> Rules {
377        self
378    }
379}
380
381impl IntoRules for Rule {
382    fn into_rules(self) -> Rules {
383        Rules::new().push(self)
384    }
385}
386
387/// A malformed spec is a bug in the source, not bad user input, so it panics
388/// with the parser's message rather than turning into a validation error a user
389/// would see. Use [`Rules::parse`] directly when the spec comes from data.
390impl IntoRules for &str {
391    fn into_rules(self) -> Rules {
392        Rules::parse(self).unwrap_or_else(|error| panic!("invalid validation rules `{self}`: {error}"))
393    }
394}
395
396impl IntoRules for String {
397    fn into_rules(self) -> Rules {
398        self.as_str().into_rules()
399    }
400}
401
402fn collect<S: Into<String>>(values: impl IntoIterator<Item = S>) -> Vec<String> {
403    values.into_iter().map(Into::into).collect()
404}
405
406fn parse_rule(name: &str, parameters: Option<&str>) -> Result<Rule> {
407    match name {
408        "required" => Ok(Rule::Required),
409        "nullable" => Ok(Rule::Nullable),
410        "string" => Ok(Rule::String),
411        "integer" => Ok(Rule::Integer),
412        "numeric" => Ok(Rule::Numeric),
413        "boolean" => Ok(Rule::Boolean),
414        "email" => Ok(Rule::Email),
415        "url" => Ok(Rule::Url),
416        "confirmed" => Ok(Rule::Confirmed),
417        "alpha" => Ok(Rule::Alpha),
418        "alpha_num" => Ok(Rule::AlphaNum),
419        "alpha_dash" => Ok(Rule::AlphaDash),
420        "date" => Ok(Rule::Date),
421        "uuid" => Ok(Rule::Uuid),
422        "array" => Ok(Rule::Array),
423        "min" => Ok(Rule::Min(number(name, parameters, "min:3")?)),
424        "max" => Ok(Rule::Max(number(name, parameters, "max:255")?)),
425        "size" => Ok(Rule::Size(number(name, parameters, "size:6")?)),
426        "between" => {
427            let values = list(name, parameters, "between:1,10")?;
428            if values.len() != 2 {
429                return Err(Error::msg(
430                    "validation rule `between` takes exactly two numbers, like `between:1,10`",
431                ));
432            }
433            Ok(Rule::Between(parse_number(name, &values[0])?, parse_number(name, &values[1])?))
434        }
435        "in" => Ok(Rule::In(list(name, parameters, "in:draft,published")?)),
436        "not_in" => Ok(Rule::NotIn(list(name, parameters, "not_in:admin,root")?)),
437        "same" => Ok(Rule::Same(single(name, parameters, "same:password")?)),
438        "different" => Ok(Rule::Different(single(name, parameters, "different:username")?)),
439        "starts_with" => Ok(Rule::StartsWith(list(name, parameters, "starts_with:https")?)),
440        "ends_with" => Ok(Rule::EndsWith(list(name, parameters, "ends_with:.com")?)),
441        other => Err(Error::msg(match suggest(other) {
442            Some(guess) => format!("unknown validation rule `{other}` — did you mean `{guess}`?"),
443            None => format!("unknown validation rule `{other}`"),
444        })),
445    }
446}
447
448/// The comma-separated parameters of a rule, e.g. `a,b,c` in `in:a,b,c`.
449fn list(name: &str, parameters: Option<&str>, example: &str) -> Result<Vec<String>> {
450    let raw = parameters.ok_or_else(|| missing(name, example))?;
451    let values: Vec<String> = raw.split(',').map(|value| value.trim().to_string()).collect();
452    if values.iter().all(String::is_empty) {
453        return Err(missing(name, example));
454    }
455    Ok(values)
456}
457
458fn single(name: &str, parameters: Option<&str>, example: &str) -> Result<String> {
459    let value = parameters.map(str::trim).unwrap_or_default();
460    if value.is_empty() {
461        return Err(missing(name, example));
462    }
463    Ok(value.to_string())
464}
465
466fn number(name: &str, parameters: Option<&str>, example: &str) -> Result<f64> {
467    parse_number(name, &single(name, parameters, example)?)
468}
469
470fn parse_number(name: &str, value: &str) -> Result<f64> {
471    value
472        .parse::<f64>()
473        .map_err(|_| Error::msg(format!("validation rule `{name}` expects a number, got `{value}`")))
474}
475
476fn missing(name: &str, example: &str) -> Error {
477    Error::msg(format!("validation rule `{name}` needs a parameter, like `{example}`"))
478}
479
480/// The closest known rule name, for the "did you mean" hint.
481///
482/// Only near misses are offered — suggesting `size` for `nonsense` would be
483/// worse than saying nothing.
484fn suggest(unknown: &str) -> Option<&'static str> {
485    const NAMES: [&str; 25] = [
486        "required", "nullable", "string", "integer", "numeric", "boolean", "email", "url", "min",
487        "max", "between", "size", "in", "not_in", "confirmed", "same", "different", "alpha",
488        "alpha_num", "alpha_dash", "starts_with", "ends_with", "date", "uuid", "array",
489    ];
490    let limit = if unknown.len() <= 4 { 1 } else { 2 };
491    NAMES
492        .iter()
493        .map(|name| (distance(unknown, name), *name))
494        .filter(|(distance, _)| *distance <= limit)
495        .min_by_key(|(distance, _)| *distance)
496        .map(|(_, name)| name)
497}
498
499/// Levenshtein distance, the two-row variant — enough for word-sized inputs.
500fn distance(left: &str, right: &str) -> usize {
501    let left: Vec<char> = left.chars().collect();
502    let right: Vec<char> = right.chars().collect();
503    let mut previous: Vec<usize> = (0..=right.len()).collect();
504    let mut current = vec![0usize; right.len() + 1];
505
506    for (i, l) in left.iter().enumerate() {
507        current[0] = i + 1;
508        for (j, r) in right.iter().enumerate() {
509            let substitution = previous[j] + usize::from(l != r);
510            current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
511        }
512        std::mem::swap(&mut previous, &mut current);
513    }
514    previous[right.len()]
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn parses_the_laravel_string_syntax() {
523        let rules = Rules::parse("required|email|max:255").unwrap();
524        assert_eq!(rules.rules(), [Rule::Required, Rule::Email, Rule::Max(255.0)]);
525    }
526
527    #[test]
528    fn parses_a_list_parameter() {
529        let rules = Rules::parse("in:draft,published,archived").unwrap();
530        assert_eq!(
531            rules.rules(),
532            [Rule::In(vec!["draft".into(), "published".into(), "archived".into()])]
533        );
534
535        let rules = Rules::parse("not_in:admin,root").unwrap();
536        assert_eq!(rules.rules(), [Rule::NotIn(vec!["admin".into(), "root".into()])]);
537    }
538
539    #[test]
540    fn parses_a_two_number_parameter() {
541        let rules = Rules::parse("between:1,10").unwrap();
542        assert_eq!(rules.rules(), [Rule::Between(1.0, 10.0)]);
543    }
544
545    #[test]
546    fn ignores_whitespace_and_empty_segments() {
547        let rules = Rules::parse(" required | between:1, 10 || string ").unwrap();
548        assert_eq!(rules.rules(), [Rule::Required, Rule::Between(1.0, 10.0), Rule::String]);
549    }
550
551    #[test]
552    fn an_empty_spec_is_an_empty_rule_set() {
553        assert!(Rules::parse("").unwrap().is_empty());
554        assert_eq!(Rules::parse("").unwrap().len(), 0);
555    }
556
557    #[test]
558    fn the_string_syntax_and_the_builder_agree() {
559        assert_eq!(
560            Rules::parse("required|email|max:255").unwrap(),
561            Rule::required().email().max(255)
562        );
563        assert_eq!(
564            Rules::parse("nullable|in:a,b").unwrap(),
565            Rule::nullable().one_of(["a", "b"])
566        );
567        assert_eq!(
568            Rules::parse("starts_with:https|ends_with:.com,.dev").unwrap(),
569            Rule::starts_with(["https"]).ends_with([".com", ".dev"])
570        );
571        assert_eq!(Rules::parse("same:password").unwrap(), Rule::same("password"));
572    }
573
574    #[test]
575    fn an_unknown_rule_is_reported_with_a_suggestion() {
576        let error = Rules::parse("requried").unwrap_err().to_string();
577        assert!(error.contains("unknown validation rule `requried`"), "{error}");
578        assert!(error.contains("did you mean `required`"), "{error}");
579    }
580
581    #[test]
582    fn a_rule_nothing_resembles_is_reported_without_a_guess() {
583        let error = Rules::parse("teleport").unwrap_err().to_string();
584        assert_eq!(error, "unknown validation rule `teleport`");
585    }
586
587    #[test]
588    fn a_rule_missing_its_parameter_shows_an_example() {
589        let error = Rules::parse("min").unwrap_err().to_string();
590        assert_eq!(error, "validation rule `min` needs a parameter, like `min:3`");
591
592        let error = Rules::parse("in:").unwrap_err().to_string();
593        assert!(error.contains("in:draft,published"), "{error}");
594    }
595
596    #[test]
597    fn a_non_numeric_bound_is_rejected() {
598        let error = Rules::parse("max:many").unwrap_err().to_string();
599        assert_eq!(error, "validation rule `max` expects a number, got `many`");
600    }
601
602    #[test]
603    fn between_insists_on_exactly_two_bounds() {
604        assert!(Rules::parse("between:1").unwrap_err().to_string().contains("exactly two"));
605        assert!(Rules::parse("between:1,2,3").unwrap_err().to_string().contains("exactly two"));
606    }
607
608    #[test]
609    fn rule_names_round_trip_through_has() {
610        let rules = Rule::nullable().integer().min(18);
611        assert!(rules.has("nullable"));
612        assert!(rules.has("min"));
613        assert!(!rules.has("required"));
614    }
615
616    #[test]
617    fn a_spec_parses_through_the_from_str_and_into_rules_paths() {
618        let parsed: Rules = "required|string".parse().unwrap();
619        assert_eq!(parsed, "required|string".into_rules());
620        assert_eq!(Rule::Required.into_rules(), Rule::required());
621    }
622
623    #[test]
624    #[should_panic(expected = "invalid validation rules `nope`")]
625    fn a_bad_spec_passed_as_a_string_panics_with_the_parser_message() {
626        let _ = "nope".into_rules();
627    }
628}