use serde::{Deserialize, Serialize};
pub trait Validate {
fn validate(&self) -> Result<(), Vec<ValidationError>>;
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
#[error("{path}: {message}")]
pub struct ValidationError {
pub path: String,
pub constraint: String,
pub message: String,
}
impl ValidationError {
pub fn new(
path: impl Into<String>,
constraint: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
path: path.into(),
constraint: constraint.into(),
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum Constraint {
Min(f64),
Max(f64),
Length {
min: Option<u32>,
max: Option<u32>,
},
Pattern(String),
Email,
Url,
Custom(String),
}
pub mod check {
use super::ValidationError;
fn truncate_for_message(s: &str) -> String {
const LIMIT: usize = 20;
let mut iter = s.chars();
let head: String = iter.by_ref().take(LIMIT).collect();
if iter.next().is_some() {
format!("{head}…")
} else {
head
}
}
pub fn min<T>(path: &str, value: T, min: f64) -> Result<(), ValidationError>
where
T: Into<f64> + Copy,
{
let v: f64 = value.into();
if v < min {
Err(ValidationError::new(
path,
"min",
format!("value {v} is less than minimum {min}"),
))
} else {
Ok(())
}
}
pub fn max<T>(path: &str, value: T, max: f64) -> Result<(), ValidationError>
where
T: Into<f64> + Copy,
{
let v: f64 = value.into();
if v > max {
Err(ValidationError::new(
path,
"max",
format!("value {v} is greater than maximum {max}"),
))
} else {
Ok(())
}
}
pub fn length(
path: &str,
s: &str,
min: Option<u32>,
max: Option<u32>,
) -> Result<(), ValidationError> {
let len = s.chars().count() as u64;
let out_of_range = match (min, max) {
(Some(lo), _) if len < u64::from(lo) => true,
(_, Some(hi)) if len > u64::from(hi) => true,
_ => false,
};
if !out_of_range {
return Ok(());
}
let lo = min.map_or_else(|| "no minimum".to_string(), |n| n.to_string());
let hi = max.map_or_else(|| "no maximum".to_string(), |n| n.to_string());
Err(ValidationError::new(
path,
"length",
format!("length {len} is outside [{lo}, {hi}]"),
))
}
pub fn email(path: &str, s: &str) -> Result<(), ValidationError> {
let bad = || {
ValidationError::new(
path,
"email",
format!("not a valid email: {}", truncate_for_message(s)),
)
};
let at = s.find('@').ok_or_else(bad)?;
if at == 0 {
return Err(bad());
}
let after_at = &s[at + 1..];
let dot = after_at.find('.').ok_or_else(bad)?;
if dot == 0 || dot + 1 >= after_at.len() {
return Err(bad());
}
Ok(())
}
pub fn url(path: &str, s: &str) -> Result<(), ValidationError> {
if s.starts_with("http://") || s.starts_with("https://") {
Ok(())
} else {
Err(ValidationError::new(
path,
"url",
format!("not a valid url: {}", truncate_for_message(s)),
))
}
}
pub fn pattern(path: &str, s: &str, regex_src: &str) -> Result<(), ValidationError> {
let re = match regex::Regex::new(regex_src) {
Ok(re) => re,
Err(e) => {
return Err(ValidationError::new(
path,
"pattern",
format!("invalid regex pattern: {e}"),
));
}
};
if re.is_match(s) {
Ok(())
} else {
Err(ValidationError::new(
path,
"pattern",
format!("does not match pattern /{regex_src}/"),
))
}
}
}
pub fn collect<F>(out: &mut Vec<ValidationError>, f: F)
where
F: FnOnce() -> Result<(), ValidationError>,
{
if let Err(e) = f() {
out.push(e);
}
}
pub fn run<F>(checks: F) -> Result<(), Vec<ValidationError>>
where
F: FnOnce(&mut Vec<ValidationError>),
{
let mut errors = Vec::new();
checks(&mut errors);
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn nested<V>(out: &mut Vec<ValidationError>, path_prefix: &str, value: &V)
where
V: Validate + ?Sized,
{
if let Err(inner) = value.validate() {
for mut e in inner {
e.path = if e.path.is_empty() {
path_prefix.to_string()
} else {
format!("{path_prefix}.{}", e.path)
};
out.push(e);
}
}
}
macro_rules! noop_validate {
($($t:ty),* $(,)?) => {
$(
impl Validate for $t {
fn validate(&self) -> Result<(), Vec<ValidationError>> { Ok(()) }
}
)*
};
}
noop_validate!(
bool,
u8,
u16,
u32,
u64,
u128,
usize,
i8,
i16,
i32,
i64,
i128,
isize,
f32,
f64,
char,
String,
&'static str,
(),
);
impl<T: Validate> Validate for Option<T> {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
match self {
Some(v) => v.validate(),
None => Ok(()),
}
}
}
impl<T: Validate> Validate for Vec<T> {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
for (i, v) in self.iter().enumerate() {
if let Err(mut errs) = v.validate() {
for e in &mut errs {
e.path = if e.path.is_empty() {
format!("[{i}]")
} else {
format!("[{i}].{}", e.path)
};
}
errors.append(&mut errs);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
impl<T: Validate> Validate for Box<T> {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
(**self).validate()
}
}
impl<K, V: Validate, S: std::hash::BuildHasher> Validate for std::collections::HashMap<K, V, S> {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
for v in self.values() {
if let Err(mut errs) = v.validate() {
errors.append(&mut errs);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
macro_rules! tuple_validate {
($($name:ident),+) => {
impl<$($name: Validate),+> Validate for ($($name,)+) {
#[allow(non_snake_case)]
fn validate(&self) -> Result<(), Vec<ValidationError>> {
let ($($name,)+) = self;
let mut errors = Vec::new();
$(
if let Err(mut errs) = $name.validate() { errors.append(&mut errs); }
)+
if errors.is_empty() { Ok(()) } else { Err(errors) }
}
}
};
}
tuple_validate!(A);
tuple_validate!(A, B);
tuple_validate!(A, B, C);
tuple_validate!(A, B, C, D);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_min_below_fails() {
let err = check::min("x", 0.5_f64, 1.0).expect_err("0.5 < 1.0 should fail");
assert_eq!(err.path, "x");
assert_eq!(err.constraint, "min");
}
#[test]
fn check_min_at_boundary_ok() {
check::min("x", 1.0_f64, 1.0).expect("value == min should pass");
}
#[test]
fn check_min_above_ok() {
check::min("x", 2.0_f64, 1.0).expect("value > min should pass");
}
#[test]
fn check_max_above_fails() {
let err = check::max("x", 1.5_f64, 1.0).expect_err("1.5 > 1.0 should fail");
assert_eq!(err.path, "x");
assert_eq!(err.constraint, "max");
}
#[test]
fn check_max_at_boundary_ok() {
check::max("x", 1.0_f64, 1.0).expect("value == max should pass");
}
#[test]
fn check_max_below_ok() {
check::max("x", 0.5_f64, 1.0).expect("value < max should pass");
}
#[test]
fn check_min_accepts_i32() {
let v: i32 = -3;
let err = check::min("age", v, 0.0).expect_err("-3 < 0 should fail");
assert_eq!(err.constraint, "min");
check::min("age", 0_i32, 0.0).expect("0 == 0 passes");
check::min("age", 5_i32, 0.0).expect("5 > 0 passes");
}
#[test]
fn check_min_accepts_u64() {
let v: u32 = 0;
let err = check::min("count", v, 1.0).expect_err("0 < 1 should fail");
assert_eq!(err.constraint, "min");
check::min("count", 1_u32, 1.0).expect("1 == 1 passes");
check::min("count", 100_u8, 1.0).expect("u8 100 > 1 passes");
}
#[test]
fn check_max_accepts_i32() {
let v: i32 = 200;
let err = check::max("age", v, 150.0).expect_err("200 > 150 should fail");
assert_eq!(err.constraint, "max");
check::max("age", 150_i32, 150.0).expect("150 == 150 passes");
check::max("age", -3_i32, 150.0).expect("-3 < 150 passes");
}
#[test]
fn check_max_accepts_u32() {
let v: u32 = 1000;
let err = check::max("count", v, 500.0).expect_err("1000 > 500 should fail");
assert_eq!(err.constraint, "max");
check::max("count", 0_u32, 500.0).expect("0 < 500 passes");
check::max("count", 5_u8, 10.0).expect("u8 5 < 10 passes");
}
#[test]
fn check_min_max_message_includes_value() {
let err = check::min("x", -2_i32, 0.0).expect_err("-2 < 0");
assert!(err.message.contains("-2"), "got {}", err.message);
let err = check::max("x", 999_u32, 10.0).expect_err("999 > 10");
assert!(err.message.contains("999"), "got {}", err.message);
}
fn assert_message_shape(message: &str) {
assert!(message.len() <= 80, "message > 80 chars: {message:?}");
assert!(
!message.ends_with('.'),
"message ends with a period: {message:?}"
);
let first = message.chars().next().expect("non-empty message");
assert!(
!first.is_uppercase(),
"message starts with uppercase: {message:?}"
);
}
#[test]
fn check_min_message_exact_text() {
let err = check::min("x", -2_i32, 0.0).expect_err("-2 < 0");
assert_eq!(err.message, "value -2 is less than minimum 0");
assert_message_shape(&err.message);
assert!(
!err.message.contains("x:"),
"path leaked into message: {}",
err.message
);
}
#[test]
fn check_max_message_exact_text() {
let err = check::max("y", 5_i32, 1.0).expect_err("5 > 1");
assert_eq!(err.message, "value 5 is greater than maximum 1");
assert_message_shape(&err.message);
}
#[test]
fn check_length_message_both_bounds() {
let err =
check::length("name", "hello!", Some(2), Some(5)).expect_err("len 6 outside [2, 5]");
assert_eq!(err.message, "length 6 is outside [2, 5]");
assert_message_shape(&err.message);
}
#[test]
fn check_length_message_no_min() {
let err = check::length("name", "hello!", None, Some(5)).expect_err("len 6 outside [-, 5]");
assert_eq!(err.message, "length 6 is outside [no minimum, 5]");
assert_message_shape(&err.message);
}
#[test]
fn check_length_message_no_max() {
let err = check::length("name", "", Some(1), None).expect_err("len 0 outside [1, -]");
assert_eq!(err.message, "length 0 is outside [1, no maximum]");
assert_message_shape(&err.message);
}
#[test]
fn check_email_message_exact_text() {
let err = check::email("e", "nope").expect_err("not an email");
assert_eq!(err.message, "not a valid email: nope");
assert_message_shape(&err.message);
}
#[test]
fn check_email_message_truncates_long_input() {
let long = "x".repeat(30) + "@nope";
let err = check::email("e", &long).expect_err("malformed");
let head: String = "x".repeat(20);
assert_eq!(err.message, format!("not a valid email: {head}…"));
assert_message_shape(&err.message);
}
#[test]
fn check_url_message_exact_text() {
let err = check::url("u", "ftp://x").expect_err("ftp not allowed");
assert_eq!(err.message, "not a valid url: ftp://x");
assert_message_shape(&err.message);
}
#[test]
fn check_url_message_truncates_long_input() {
let long = "g".repeat(40);
let err = check::url("u", &long).expect_err("not a url");
let head: String = "g".repeat(20);
assert_eq!(err.message, format!("not a valid url: {head}…"));
assert_message_shape(&err.message);
}
#[test]
fn check_pattern_message_exact_text() {
let err = check::pattern("x", "abc", r"^\d+$").expect_err("no digits");
assert_eq!(err.message, r"does not match pattern /^\d+$/");
assert_message_shape(&err.message);
assert!(!err.message.contains("x:"), "got {}", err.message);
}
#[test]
fn check_length_max_only_ok() {
check::length("name", "hi", None, Some(5)).expect("len 2 <= 5");
}
#[test]
fn check_length_max_only_fails() {
let err =
check::length("name", "hello!", None, Some(5)).expect_err("len 6 > 5 should fail");
assert_eq!(err.constraint, "length");
assert_eq!(err.path, "name");
}
#[test]
fn check_length_min_and_max_ok() {
check::length("name", "hey", Some(2), Some(5)).expect("2 <= 3 <= 5");
}
#[test]
fn check_length_below_min_fails() {
let err = check::length("name", "x", Some(2), Some(5)).expect_err("len 1 < 2 should fail");
assert_eq!(err.constraint, "length");
}
#[test]
fn check_length_empty_with_min_fails() {
let err = check::length("name", "", Some(1), None).expect_err("empty string fails min(1)");
assert_eq!(err.constraint, "length");
}
#[test]
fn check_length_empty_no_min_ok() {
check::length("name", "", None, Some(5)).expect("empty allowed when no min");
}
#[test]
fn check_length_empty_no_bounds_ok() {
check::length("name", "", None, None).expect("no bounds always passes");
}
#[test]
fn check_length_counts_chars_not_bytes() {
check::length("name", "é", Some(1), Some(1)).expect("counts chars, not bytes");
}
#[test]
fn check_email_accepts_simple() {
check::email("e", "a@b.co").expect("a@b.co is valid");
}
#[test]
fn check_email_rejects_no_dot() {
check::email("e", "a@b").expect_err("a@b has no dot after @");
}
#[test]
fn check_email_rejects_empty() {
check::email("e", "").expect_err("empty string is not an email");
}
#[test]
fn check_email_rejects_no_domain() {
check::email("e", "a.b@").expect_err("a.b@ has nothing after @");
}
#[test]
fn check_email_rejects_leading_at() {
check::email("e", "@b.co").expect_err("nothing before @");
}
#[test]
fn check_email_error_carries_constraint() {
let err = check::email("user.email", "nope").expect_err("nope is not an email");
assert_eq!(err.path, "user.email");
assert_eq!(err.constraint, "email");
}
#[test]
fn check_url_accepts_https() {
check::url("u", "https://x").expect("https://x is allowed");
}
#[test]
fn check_url_accepts_http() {
check::url("u", "http://x").expect("http://x is allowed");
}
#[test]
fn check_url_rejects_ftp() {
let err = check::url("u", "ftp://x").expect_err("ftp scheme not allowed");
assert_eq!(err.constraint, "url");
assert_eq!(err.path, "u");
}
#[test]
fn check_url_rejects_empty() {
check::url("u", "").expect_err("empty is not a URL");
}
#[test]
fn check_pattern_matches() {
check::pattern("x", "abc123", r"\d+").expect("contains digits");
}
#[test]
fn check_pattern_anchored_full_match() {
check::pattern("x", "12345", r"^\d+$").expect("all digits");
}
#[test]
fn check_pattern_does_not_match() {
let err = check::pattern("x", "abc", r"^\d+$").expect_err("no digits");
assert_eq!(err.constraint, "pattern");
assert_eq!(err.path, "x");
assert!(err.message.contains(r"^\d+$"), "got {}", err.message);
}
#[test]
fn check_pattern_invalid_regex_returns_error_not_panic() {
let err = check::pattern("x", "abc", r"[unclosed")
.expect_err("invalid regex source must surface as ValidationError");
assert_eq!(err.constraint, "pattern");
assert_eq!(err.path, "x");
assert!(
err.message.starts_with("invalid regex pattern:"),
"got {}",
err.message
);
}
#[test]
fn check_pattern_empty_input_against_optional_pattern() {
check::pattern("x", "", r"^$").expect("empty matches ^$");
check::pattern("x", "x", r"^$").expect_err("non-empty does not match ^$");
}
#[test]
fn collect_pushes_on_err() {
let mut errors = Vec::new();
collect(&mut errors, || check::min("x", 0_i32, 1.0));
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].constraint, "min");
assert_eq!(errors[0].path, "x");
}
#[test]
fn collect_skips_on_ok() {
let mut errors = Vec::new();
collect(&mut errors, || check::min("x", 5_i32, 1.0));
assert!(errors.is_empty());
}
#[test]
fn collect_accumulates_multiple_failures() {
let mut errors = Vec::new();
collect(&mut errors, || check::min("x", 0_i32, 1.0));
collect(&mut errors, || check::max("x", 100_i32, 10.0));
collect(&mut errors, || check::min("y", 5_i32, 1.0)); collect(&mut errors, || check::email("e", "nope"));
assert_eq!(errors.len(), 3);
assert_eq!(errors[0].constraint, "min");
assert_eq!(errors[1].constraint, "max");
assert_eq!(errors[2].constraint, "email");
}
#[test]
fn run_returns_ok_when_no_errors_pushed() {
let result = run(|_errors| {});
assert!(result.is_ok());
}
#[test]
fn run_returns_ok_when_all_checks_pass() {
let result = run(|errors| {
collect(errors, || check::min("x", 5_i32, 1.0));
collect(errors, || check::max("x", 5_i32, 10.0));
});
assert!(result.is_ok());
}
#[test]
fn run_returns_err_with_all_collected_failures() {
let result = run(|errors| {
collect(errors, || check::min("x", 0_i32, 1.0));
collect(errors, || check::max("y", 100_i32, 10.0));
});
let errs = result.expect_err("two failures should make Err");
assert_eq!(errs.len(), 2);
assert_eq!(errs[0].path, "x");
assert_eq!(errs[0].constraint, "min");
assert_eq!(errs[1].path, "y");
assert_eq!(errs[1].constraint, "max");
}
#[test]
fn run_does_not_short_circuit() {
let mut counter = 0;
let result = run(|errors| {
counter += 1;
collect(errors, || check::min("x", 0_i32, 1.0));
counter += 1;
collect(errors, || check::max("x", 100_i32, 10.0));
counter += 1;
});
assert_eq!(counter, 3);
assert_eq!(result.expect_err("two failures").len(), 2);
}
struct Inner {
a: i32,
}
impl Validate for Inner {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
run(|errors| {
collect(errors, || check::min("a", self.a, 0.0));
})
}
}
struct Outer {
inner: Inner,
}
impl Validate for Outer {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
run(|errors| {
nested(errors, "inner", &self.inner);
})
}
}
#[test]
fn nested_prefixes_inner_path() {
let outer = Outer {
inner: Inner { a: -1 },
};
let errs = outer.validate().expect_err("a < 0 should fail");
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].path, "inner.a");
assert_eq!(errs[0].constraint, "min");
}
#[test]
fn nested_passes_through_when_inner_ok() {
let outer = Outer {
inner: Inner { a: 5 },
};
outer.validate().expect("inner is valid");
}
struct RootError;
impl Validate for RootError {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
Err(vec![ValidationError::new("", "custom", "root-level fail")])
}
}
#[test]
fn nested_uses_prefix_alone_when_inner_path_empty() {
let mut errors = Vec::new();
nested(&mut errors, "field", &RootError);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].path, "field");
assert_eq!(errors[0].constraint, "custom");
}
#[test]
fn nested_collects_multiple_inner_errors() {
struct Multi;
impl Validate for Multi {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
run(|errors| {
collect(errors, || check::min("a", 0_i32, 1.0));
collect(errors, || check::max("b", 100_i32, 10.0));
})
}
}
let mut errors = Vec::new();
nested(&mut errors, "wrap", &Multi);
assert_eq!(errors.len(), 2);
assert_eq!(errors[0].path, "wrap.a");
assert_eq!(errors[1].path, "wrap.b");
}
#[test]
fn nested_pushes_nothing_when_inner_ok() {
struct AlwaysOk;
impl Validate for AlwaysOk {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
Ok(())
}
}
let mut errors = Vec::new();
nested(&mut errors, "x", &AlwaysOk);
assert!(errors.is_empty());
}
#[test]
fn nested_double_nesting_dotted_path() {
struct Deeper {
outer: Outer,
}
impl Validate for Deeper {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
run(|errors| {
nested(errors, "outer", &self.outer);
})
}
}
let d = Deeper {
outer: Outer {
inner: Inner { a: -1 },
},
};
let errs = d.validate().expect_err("inner a < 0");
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].path, "outer.inner.a");
}
fn roundtrip(c: &Constraint) -> Constraint {
let json = serde_json::to_string(c).expect("serialize Constraint");
serde_json::from_str(&json).expect("deserialize Constraint")
}
#[test]
fn constraint_min_roundtrip() {
let c = Constraint::Min(1.5);
assert_eq!(roundtrip(&c), c);
let json = serde_json::to_string(&c).expect("serialize");
assert!(json.contains("\"kind\":\"min\""), "got {json}");
}
#[test]
fn constraint_max_roundtrip() {
let c = Constraint::Max(10.0);
assert_eq!(roundtrip(&c), c);
let json = serde_json::to_string(&c).expect("serialize");
assert!(json.contains("\"kind\":\"max\""), "got {json}");
}
#[test]
fn constraint_length_roundtrip() {
let c = Constraint::Length {
min: Some(1),
max: Some(64),
};
assert_eq!(roundtrip(&c), c);
let json = serde_json::to_string(&c).expect("serialize");
assert!(json.contains("\"kind\":\"length\""), "got {json}");
}
#[test]
fn constraint_length_max_only_roundtrip() {
let c = Constraint::Length {
min: None,
max: Some(64),
};
assert_eq!(roundtrip(&c), c);
}
#[test]
fn constraint_pattern_roundtrip() {
let c = Constraint::Pattern(r"^\d+$".to_string());
assert_eq!(roundtrip(&c), c);
let json = serde_json::to_string(&c).expect("serialize");
assert!(json.contains("\"kind\":\"pattern\""), "got {json}");
}
#[test]
fn constraint_email_roundtrip() {
let c = Constraint::Email;
assert_eq!(roundtrip(&c), c);
let json = serde_json::to_string(&c).expect("serialize");
assert!(json.contains("\"kind\":\"email\""), "got {json}");
}
#[test]
fn constraint_url_roundtrip() {
let c = Constraint::Url;
assert_eq!(roundtrip(&c), c);
let json = serde_json::to_string(&c).expect("serialize");
assert!(json.contains("\"kind\":\"url\""), "got {json}");
}
#[test]
fn constraint_custom_roundtrip() {
let c = Constraint::Custom("must_be_prime".to_string());
assert_eq!(roundtrip(&c), c);
let json = serde_json::to_string(&c).expect("serialize");
assert!(json.contains("\"kind\":\"custom\""), "got {json}");
}
#[test]
fn validation_error_display_uses_path_and_message() {
let err = ValidationError::new("user.email", "email", "bad");
assert_eq!(format!("{err}"), "user.email: bad");
}
#[test]
fn validation_error_serde_roundtrip() {
let err = ValidationError::new("a.b", "min", "too small");
let json = serde_json::to_string(&err).expect("serialize");
let back: ValidationError = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, err);
}
#[test]
fn validate_for_unit_returns_ok() {
().validate().expect("unit always validates");
}
#[test]
fn validate_for_primitives_all_return_ok() {
true.validate().expect("bool always ok");
42_u32.validate().expect("u32 always ok");
"hello".to_string().validate().expect("String always ok");
}
#[test]
fn validate_for_option_t_calls_inner_when_some() {
struct Field(i32);
impl Validate for Field {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
run(|errors| {
collect(errors, || check::min("v", self.0, 0.0));
})
}
}
let none: Option<Field> = None;
none.validate().expect("None passes");
Some(Field(5))
.validate()
.expect("Some with valid inner passes");
let errs = Some(Field(-1))
.validate()
.expect_err("Some with -1 should fail inner check");
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].constraint, "min");
assert_eq!(errs[0].path, "v");
}
#[test]
fn validate_for_vec_indexes_path() {
struct Field(i32);
impl Validate for Field {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
run(|errors| {
collect(errors, || check::min("v", self.0, 0.0));
})
}
}
struct RootFail;
impl Validate for RootFail {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
Err(vec![ValidationError::new("", "custom", "boom")])
}
}
let v = vec![Field(5), Field(-1), Field(10), Field(-2)];
let errs = v.validate().expect_err("indices 1 and 3 should fail");
assert_eq!(errs.len(), 2);
assert_eq!(errs[0].path, "[1].v");
assert_eq!(errs[0].constraint, "min");
assert_eq!(errs[1].path, "[3].v");
assert_eq!(errs[1].constraint, "min");
let empty: Vec<Field> = Vec::new();
empty.validate().expect("empty Vec passes");
let ok = vec![Field(0), Field(1), Field(2)];
ok.validate().expect("all-valid Vec passes");
let v = vec![RootFail, RootFail];
let errs = v.validate().expect_err("both fail at root");
assert_eq!(errs.len(), 2);
assert_eq!(errs[0].path, "[0]");
assert_eq!(errs[1].path, "[1]");
}
#[test]
fn validate_for_tuple_runs_all_arms() {
struct Field(i32);
impl Validate for Field {
fn validate(&self) -> Result<(), Vec<ValidationError>> {
run(|errors| {
collect(errors, || check::min("v", self.0, 0.0));
})
}
}
let one = (Field(-1),);
let errs = one.validate().expect_err("single-arm tuple fails");
assert_eq!(errs.len(), 1);
let two = (Field(-1), Field(-2));
let errs = two.validate().expect_err("both arms fail");
assert_eq!(errs.len(), 2, "tuple must not short-circuit");
let three = (Field(-1), Field(0), Field(-3));
let errs = three.validate().expect_err("two of three fail");
assert_eq!(errs.len(), 2);
let four = (Field(0), Field(1), Field(2), Field(3));
four.validate().expect("all-valid 4-tuple passes");
let mixed: (u32, String, Field) = (1, "x".into(), Field(5));
mixed.validate().expect("primitives + ok user type pass");
}
}