1use rustlavel_core::{Error, Result};
11
12#[derive(Debug, Clone, PartialEq)]
17pub enum Rule {
18 Required,
20 Nullable,
22 String,
23 Integer,
24 Numeric,
25 Boolean,
26 Email,
27 Url,
28 Min(f64),
30 Max(f64),
32 Between(f64, f64),
34 Size(f64),
36 In(Vec<String>),
37 NotIn(Vec<String>),
38 Confirmed,
40 Same(String),
42 Different(String),
44 Alpha,
45 AlphaNum,
46 AlphaDash,
47 StartsWith(Vec<String>),
48 EndsWith(Vec<String>),
49 Date,
51 Uuid,
52 Array,
53}
54
55impl Rule {
56 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 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 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#[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 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 pub fn has(&self, name: &str) -> bool {
242 self.rules.iter().any(|rule| rule.name() == name)
243 }
244
245 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 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
367pub 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
387impl 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
448fn 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
480fn 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
499fn 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}