use crate::{Rule, RuleContext, ValidationError};
#[cfg(feature = "regex")]
use once_cell::sync::Lazy;
#[cfg(feature = "regex")]
static EMAIL_REGEX: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r"^[^@\s]+@[^@\s]+\.[^@\s]+$").unwrap());
#[cfg(feature = "regex")]
static URL_REGEX: Lazy<regex::Regex> = Lazy::new(|| {
regex::Regex::new(
r"^https?://[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(/.*)?$"
).unwrap()
});
#[cfg(feature = "regex")]
pub fn email() -> Rule<str> {
Rule::new(|value: &str, ctx: &RuleContext| {
if EMAIL_REGEX.is_match(value) {
ValidationError::default()
} else {
ValidationError::single(ctx.full_path(), "invalid_email", "Invalid email format")
}
})
}
pub fn non_empty() -> Rule<str> {
Rule::new(|value: &str, ctx: &RuleContext| {
if value.is_empty() {
ValidationError::single(ctx.full_path(), "non_empty", "Must not be empty")
} else {
ValidationError::default()
}
})
}
pub fn min_len(min: usize) -> Rule<str> {
Rule::new(move |value: &str, ctx: &RuleContext| {
if value.len() < min {
let mut err = ValidationError::single(
ctx.full_path(),
"min_length",
format!("Must be at least {} characters", min),
);
err.violations[0].meta.insert("min", min.to_string());
err
} else {
ValidationError::default()
}
})
}
pub fn max_len(max: usize) -> Rule<str> {
Rule::new(move |value: &str, ctx: &RuleContext| {
if value.len() > max {
let mut err = ValidationError::single(
ctx.full_path(),
"max_length",
format!("Must be at most {} characters", max),
);
err.violations[0].meta.insert("max", max.to_string());
err
} else {
ValidationError::default()
}
})
}
pub fn length(min: usize, max: usize) -> Rule<str> {
min_len(min).and(max_len(max))
}
#[cfg(feature = "regex")]
pub fn url() -> Rule<str> {
Rule::new(|value: &str, ctx: &RuleContext| {
if URL_REGEX.is_match(value) {
ValidationError::default()
} else {
ValidationError::single(ctx.full_path(), "invalid_url", "Invalid URL format")
}
})
}
pub fn alphanumeric() -> Rule<str> {
Rule::new(|value: &str, ctx: &RuleContext| {
if value.chars().all(|c| c.is_alphanumeric()) {
ValidationError::default()
} else {
ValidationError::single(
ctx.full_path(),
"not_alphanumeric",
"Must contain only letters and numbers",
)
}
})
}
pub fn alpha_only() -> Rule<str> {
Rule::new(|value: &str, ctx: &RuleContext| {
if value.chars().all(|c| c.is_alphabetic()) {
ValidationError::default()
} else {
ValidationError::single(ctx.full_path(), "not_alpha", "Must contain only letters")
}
})
}
pub fn numeric_string() -> Rule<str> {
Rule::new(|value: &str, ctx: &RuleContext| {
if value.chars().all(|c| c.is_numeric()) {
ValidationError::default()
} else {
ValidationError::single(ctx.full_path(), "not_numeric", "Must contain only numbers")
}
})
}
pub fn contains(substring: &'static str) -> Rule<str> {
Rule::new(move |value: &str, ctx: &RuleContext| {
if value.contains(substring) {
ValidationError::default()
} else {
let mut err = ValidationError::single(
ctx.full_path(),
"missing_substring",
format!("Must contain '{}'", substring),
);
err.violations[0]
.meta
.insert("substring", substring.to_string());
err
}
})
}
pub fn starts_with(prefix: &'static str) -> Rule<str> {
Rule::new(move |value: &str, ctx: &RuleContext| {
if value.starts_with(prefix) {
ValidationError::default()
} else {
let mut err = ValidationError::single(
ctx.full_path(),
"invalid_prefix",
format!("Must start with '{}'", prefix),
);
err.violations[0].meta.insert("prefix", prefix.to_string());
err
}
})
}
pub fn ends_with(suffix: &'static str) -> Rule<str> {
Rule::new(move |value: &str, ctx: &RuleContext| {
if value.ends_with(suffix) {
ValidationError::default()
} else {
let mut err = ValidationError::single(
ctx.full_path(),
"invalid_suffix",
format!("Must end with '{}'", suffix),
);
err.violations[0].meta.insert("suffix", suffix.to_string());
err
}
})
}
#[cfg(feature = "regex")]
pub fn matches_regex(pattern: &'static str) -> Rule<str> {
let re = regex::Regex::new(pattern).expect("Invalid regex pattern");
Rule::new(move |value: &str, ctx: &RuleContext| {
if re.is_match(value) {
ValidationError::default()
} else {
let mut err = ValidationError::single(
ctx.full_path(),
"pattern_mismatch",
"Does not match required pattern",
);
err.violations[0]
.meta
.insert("pattern", pattern.to_string());
err
}
})
}
#[cfg(feature = "regex")]
pub fn try_matches_regex(pattern: &'static str) -> Result<Rule<str>, regex::Error> {
let re = regex::Regex::new(pattern)?;
Ok(Rule::new(move |value: &str, ctx: &RuleContext| {
if re.is_match(value) {
ValidationError::default()
} else {
let mut err = ValidationError::single(
ctx.full_path(),
"pattern_mismatch",
"Does not match required pattern",
);
err.violations[0]
.meta
.insert("pattern", pattern.to_string());
err
}
}))
}
pub fn non_blank() -> Rule<str> {
Rule::new(|value: &str, ctx: &RuleContext| {
if value.trim().is_empty() {
ValidationError::single(ctx.full_path(), "blank", "Must not be blank")
} else {
ValidationError::default()
}
})
}
pub fn no_whitespace() -> Rule<str> {
Rule::new(|value: &str, ctx: &RuleContext| {
if value.chars().any(|c| c.is_whitespace()) {
ValidationError::single(
ctx.full_path(),
"contains_whitespace",
"Must not contain whitespace",
)
} else {
ValidationError::default()
}
})
}
pub fn ascii() -> Rule<str> {
Rule::new(|value: &str, ctx: &RuleContext| {
if value.is_ascii() {
ValidationError::default()
} else {
ValidationError::single(
ctx.full_path(),
"not_ascii",
"Must contain only ASCII characters",
)
}
})
}
pub fn len_chars(min: usize, max: usize) -> Rule<str> {
Rule::new(move |value: &str, ctx: &RuleContext| {
let char_count = value.chars().count();
if char_count < min {
let mut err = ValidationError::single(
ctx.full_path(),
"min_chars",
format!("Must be at least {} characters", min),
);
err.violations[0].meta.insert("min", min.to_string());
err.violations[0]
.meta
.insert("actual", char_count.to_string());
err
} else if char_count > max {
let mut err = ValidationError::single(
ctx.full_path(),
"max_chars",
format!("Must be at most {} characters", max),
);
err.violations[0].meta.insert("max", max.to_string());
err.violations[0]
.meta
.insert("actual", char_count.to_string());
err
} else {
ValidationError::default()
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(feature = "regex")]
fn test_email_valid() {
let rule = email();
assert!(rule.apply("user@example.com").is_empty());
assert!(rule.apply("test.user@domain.co.uk").is_empty());
}
#[test]
#[cfg(feature = "regex")]
fn test_email_invalid() {
let rule = email();
let result = rule.apply("not-an-email");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "invalid_email");
let result = rule.apply("missing-domain@");
assert!(!result.is_empty());
let result = rule.apply("@missing-user.com");
assert!(!result.is_empty());
}
#[test]
fn test_non_empty_valid() {
let rule = non_empty();
assert!(rule.apply("hello").is_empty());
assert!(rule.apply(" ").is_empty());
}
#[test]
fn test_non_empty_invalid() {
let rule = non_empty();
let result = rule.apply("");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "non_empty");
}
#[test]
fn test_min_len_valid() {
let rule = min_len(5);
assert!(rule.apply("hello").is_empty());
assert!(rule.apply("hello world").is_empty());
}
#[test]
fn test_min_len_invalid() {
let rule = min_len(5);
let result = rule.apply("hi");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "min_length");
assert_eq!(result.violations[0].meta.get("min"), Some("5"));
}
#[test]
fn test_max_len_valid() {
let rule = max_len(10);
assert!(rule.apply("hello").is_empty());
assert!(rule.apply("").is_empty());
}
#[test]
fn test_max_len_invalid() {
let rule = max_len(5);
let result = rule.apply("hello world");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "max_length");
assert_eq!(result.violations[0].meta.get("max"), Some("5"));
}
#[test]
fn test_length_valid() {
let rule = length(3, 10);
assert!(rule.apply("hello").is_empty());
assert!(rule.apply("hi!").is_empty());
}
#[test]
fn test_length_too_short() {
let rule = length(3, 10);
let result = rule.apply("hi");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "min_length");
}
#[test]
fn test_length_too_long() {
let rule = length(3, 10);
let result = rule.apply("hello world!");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "max_length");
}
#[test]
#[cfg(feature = "regex")]
fn test_url_valid() {
let rule = url();
assert!(rule.apply("https://example.com").is_empty());
assert!(rule.apply("http://example.com").is_empty());
assert!(rule.apply("https://example.com/path").is_empty());
assert!(rule.apply("http://test.example.com").is_empty());
}
#[test]
#[cfg(feature = "regex")]
fn test_url_invalid() {
let rule = url();
let result = rule.apply("not a url");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "invalid_url");
let result = rule.apply("example.com");
assert!(!result.is_empty());
let result = rule.apply("ftp://example.com");
assert!(!result.is_empty());
}
#[test]
fn test_alphanumeric_valid() {
let rule = alphanumeric();
assert!(rule.apply("abc123").is_empty());
assert!(rule.apply("HelloWorld123").is_empty());
assert!(rule.apply("123").is_empty());
assert!(rule.apply("abc").is_empty());
}
#[test]
fn test_alphanumeric_invalid() {
let rule = alphanumeric();
let result = rule.apply("hello world");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "not_alphanumeric");
let result = rule.apply("hello-world");
assert!(!result.is_empty());
let result = rule.apply("hello_world");
assert!(!result.is_empty());
}
#[test]
fn test_alpha_only_valid() {
let rule = alpha_only();
assert!(rule.apply("hello").is_empty());
assert!(rule.apply("HelloWorld").is_empty());
assert!(rule.apply("ABC").is_empty());
}
#[test]
fn test_alpha_only_invalid() {
let rule = alpha_only();
let result = rule.apply("hello123");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "not_alpha");
let result = rule.apply("hello world");
assert!(!result.is_empty());
}
#[test]
fn test_numeric_string_valid() {
let rule = numeric_string();
assert!(rule.apply("123456").is_empty());
assert!(rule.apply("0").is_empty());
}
#[test]
fn test_numeric_string_invalid() {
let rule = numeric_string();
let result = rule.apply("123abc");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "not_numeric");
let result = rule.apply("12.34");
assert!(!result.is_empty());
}
#[test]
fn test_contains_valid() {
let rule = contains("example");
assert!(rule.apply("user@example.com").is_empty());
assert!(rule.apply("example").is_empty());
assert!(rule.apply("this is an example").is_empty());
}
#[test]
fn test_contains_invalid() {
let rule = contains("example");
let result = rule.apply("user@test.com");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "missing_substring");
assert_eq!(result.violations[0].meta.get("substring"), Some("example"));
}
#[test]
fn test_starts_with_valid() {
let rule = starts_with("https://");
assert!(rule.apply("https://example.com").is_empty());
assert!(rule.apply("https://").is_empty());
}
#[test]
fn test_starts_with_invalid() {
let rule = starts_with("https://");
let result = rule.apply("http://example.com");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "invalid_prefix");
}
#[test]
fn test_ends_with_valid() {
let rule = ends_with(".com");
assert!(rule.apply("example.com").is_empty());
assert!(rule.apply(".com").is_empty());
}
#[test]
fn test_ends_with_invalid() {
let rule = ends_with(".com");
let result = rule.apply("example.org");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "invalid_suffix");
}
#[cfg(feature = "regex")]
#[test]
fn test_matches_regex_valid() {
let rule = matches_regex(r"^\d{3}-\d{4}$");
assert!(rule.apply("123-4567").is_empty());
}
#[cfg(feature = "regex")]
#[test]
fn test_matches_regex_invalid() {
let rule = matches_regex(r"^\d{3}-\d{4}$");
let result = rule.apply("1234567");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "pattern_mismatch");
}
#[cfg(feature = "regex")]
#[test]
fn test_try_matches_regex_valid_pattern() {
let rule = try_matches_regex(r"^\d{3}-\d{4}$").unwrap();
assert!(rule.apply("123-4567").is_empty());
assert!(!rule.apply("1234567").is_empty());
}
#[cfg(feature = "regex")]
#[test]
fn test_try_matches_regex_invalid_pattern() {
let result = try_matches_regex(r"[invalid");
assert!(result.is_err());
}
#[test]
fn test_non_blank_valid() {
let rule = non_blank();
assert!(rule.apply("hello").is_empty());
assert!(rule.apply(" hello ").is_empty()); assert!(rule.apply("a").is_empty());
}
#[test]
fn test_non_blank_invalid() {
let rule = non_blank();
let result = rule.apply("");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "blank");
let result = rule.apply(" ");
assert!(!result.is_empty());
let result = rule.apply("\t\n");
assert!(!result.is_empty());
}
#[test]
fn test_no_whitespace_valid() {
let rule = no_whitespace();
assert!(rule.apply("hello").is_empty());
assert!(rule.apply("HelloWorld123").is_empty());
assert!(rule.apply("hello-world").is_empty());
assert!(rule.apply("hello_world").is_empty());
}
#[test]
fn test_no_whitespace_invalid() {
let rule = no_whitespace();
let result = rule.apply("hello world");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "contains_whitespace");
let result = rule.apply("hello\tworld");
assert!(!result.is_empty());
let result = rule.apply("hello\n");
assert!(!result.is_empty());
let result = rule.apply(" ");
assert!(!result.is_empty());
}
#[test]
fn test_ascii_valid() {
let rule = ascii();
assert!(rule.apply("hello").is_empty());
assert!(rule.apply("Hello123!").is_empty());
assert!(rule.apply("").is_empty()); assert!(rule.apply("abc-def_123").is_empty());
}
#[test]
fn test_ascii_invalid() {
let rule = ascii();
let result = rule.apply("hello🚀");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "not_ascii");
let result = rule.apply("café");
assert!(!result.is_empty());
let result = rule.apply("hello世界");
assert!(!result.is_empty());
}
#[test]
fn test_len_chars_valid() {
let rule = len_chars(3, 10);
assert!(rule.apply("hello").is_empty()); assert!(rule.apply("abc").is_empty()); assert!(rule.apply("abcdefghij").is_empty());
assert!(rule.apply("café").is_empty()); assert!(rule.apply("hello🚀").is_empty()); }
#[test]
fn test_len_chars_too_short() {
let rule = len_chars(3, 10);
let result = rule.apply("hi");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "min_chars");
assert_eq!(result.violations[0].meta.get("min"), Some("3"));
assert_eq!(result.violations[0].meta.get("actual"), Some("2"));
let result = rule.apply("");
assert!(!result.is_empty());
}
#[test]
fn test_len_chars_too_long() {
let rule = len_chars(3, 10);
let result = rule.apply("hello world!");
assert!(!result.is_empty());
assert_eq!(result.violations[0].code, "max_chars");
assert_eq!(result.violations[0].meta.get("max"), Some("10"));
assert_eq!(result.violations[0].meta.get("actual"), Some("12"));
}
#[test]
fn test_len_chars_vs_length() {
let emoji_string = "🚀🚀🚀";
let char_rule = len_chars(1, 5);
assert!(char_rule.apply(emoji_string).is_empty());
let byte_rule = length(1, 5);
assert!(!byte_rule.apply(emoji_string).is_empty()); }
}