use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::LazyLock;
static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}$",
)
.unwrap()
});
static PHONE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\+?[\d\s\-\(\)]{7,20}$").unwrap());
static DATE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$").unwrap());
static TIME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$").unwrap());
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FieldRules {
#[serde(default)]
pub required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub min: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub field_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub match_field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unique: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FormRules {
pub fields: HashMap<String, FieldRules>,
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub errors: HashMap<String, String>,
pub is_valid: bool,
}
pub fn validate_form(data: &HashMap<String, String>, rules: &FormRules) -> ValidationResult {
let mut errors = HashMap::new();
for (field_name, field_rules) in &rules.fields {
let value = data.get(field_name).map(|s| s.as_str()).unwrap_or("");
let custom_msg = field_rules.error_message.as_deref();
if field_rules.required && value.is_empty() {
errors.insert(
field_name.clone(),
custom_msg
.unwrap_or(&format!("{} is required", field_name))
.to_string(),
);
continue; }
if value.is_empty() {
continue;
}
if let Some(min) = field_rules.min {
if value.len() < min {
errors.insert(
field_name.clone(),
custom_msg
.unwrap_or(&format!(
"{} must be at least {} characters",
field_name, min
))
.to_string(),
);
continue;
}
}
if let Some(max) = field_rules.max {
if value.len() > max {
errors.insert(
field_name.clone(),
custom_msg
.unwrap_or(&format!(
"{} must be at most {} characters",
field_name, max
))
.to_string(),
);
continue;
}
}
if let Some(ref field_type) = field_rules.field_type {
let valid = match field_type.as_str() {
"email" => EMAIL_RE.is_match(value),
"url" => url::Url::parse(value).is_ok(),
"number" => value.parse::<f64>().is_ok(),
"phone" => PHONE_RE.is_match(value),
"date" => DATE_RE.is_match(value),
"time" => TIME_RE.is_match(value),
_ => true,
};
if !valid {
errors.insert(
field_name.clone(),
custom_msg
.unwrap_or(&format!("{} must be a valid {}", field_name, field_type))
.to_string(),
);
continue;
}
}
if let Some(ref pattern) = field_rules.pattern {
if pattern.len() > 512 {
errors.insert(
field_name.clone(),
custom_msg
.unwrap_or(&format!("{}: validation pattern too long", field_name))
.to_string(),
);
continue;
}
if let Ok(re) = regex::RegexBuilder::new(pattern)
.size_limit(1 << 20) .build()
{
if !re.is_match(value) {
errors.insert(
field_name.clone(),
custom_msg
.unwrap_or(&format!(
"{} does not match the required format",
field_name
))
.to_string(),
);
continue;
}
}
}
if let Some(ref match_name) = field_rules.match_field {
let other_value = data.get(match_name).map(|s| s.as_str()).unwrap_or("");
if value != other_value {
errors.insert(
field_name.clone(),
custom_msg
.unwrap_or(&format!("{} must match {}", field_name, match_name))
.to_string(),
);
}
}
}
let is_valid = errors.is_empty();
ValidationResult { errors, is_valid }
}
pub fn parse_form_rules(form_html: &str) -> FormRules {
let mut fields = HashMap::new();
let input_re =
regex::Regex::new(r#"<(?:input|textarea|select)\s[^>]*?name\s*=\s*"([^"]+)"[^>]*>"#)
.unwrap();
for cap in input_re.captures_iter(form_html) {
let field_name = cap[1].to_string();
let tag_html = cap[0].to_string();
let mut rules = FieldRules::default();
let mut has_rules = false;
if tag_html.contains("w-required") {
rules.required = true;
has_rules = true;
}
if let Some(min_cap) = regex::Regex::new(r#"w-min\s*=\s*"(\d+)""#)
.ok()
.and_then(|re| re.captures(&tag_html))
{
rules.min = min_cap[1].parse().ok();
has_rules = true;
}
if let Some(max_cap) = regex::Regex::new(r#"w-max\s*=\s*"(\d+)""#)
.ok()
.and_then(|re| re.captures(&tag_html))
{
rules.max = max_cap[1].parse().ok();
has_rules = true;
}
if let Some(type_cap) = regex::Regex::new(r#"w-type\s*=\s*"([^"]+)""#)
.ok()
.and_then(|re| re.captures(&tag_html))
{
rules.field_type = Some(type_cap[1].to_string());
has_rules = true;
}
if let Some(pattern_cap) = regex::Regex::new(r#"w-pattern\s*=\s*"([^"]+)""#)
.ok()
.and_then(|re| re.captures(&tag_html))
{
rules.pattern = Some(pattern_cap[1].to_string());
has_rules = true;
}
if let Some(match_cap) = regex::Regex::new(r#"w-match\s*=\s*"([^"]+)""#)
.ok()
.and_then(|re| re.captures(&tag_html))
{
rules.match_field = Some(match_cap[1].to_string());
has_rules = true;
}
if let Some(unique_cap) = regex::Regex::new(r#"w-unique\s*=\s*"([^"]+)""#)
.ok()
.and_then(|re| re.captures(&tag_html))
{
rules.unique = Some(unique_cap[1].to_string());
has_rules = true;
}
if let Some(error_cap) = regex::Regex::new(r#"w-error\s*=\s*"([^"]+)""#)
.ok()
.and_then(|re| re.captures(&tag_html))
{
rules.error_message = Some(error_cap[1].to_string());
}
if has_rules {
fields.insert(field_name, rules);
}
}
FormRules { fields }
}
pub fn encode_rules(rules: &FormRules, secret: &str) -> Option<String> {
use jsonwebtoken::{EncodingKey, Header, encode};
encode(
&Header::default(),
rules,
&EncodingKey::from_secret(secret.as_bytes()),
)
.ok()
}
pub fn decode_rules(token: &str, secret: &str) -> Option<FormRules> {
use jsonwebtoken::{DecodingKey, Validation, decode};
let mut validation = Validation::default();
validation.required_spec_claims.clear();
validation.validate_exp = false;
decode::<FormRules>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.ok()
.map(|data| data.claims)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_required_present() {
let rules = FormRules {
fields: HashMap::from([(
"name".to_string(),
FieldRules {
required: true,
..Default::default()
},
)]),
};
let data = HashMap::from([("name".to_string(), "Alice".to_string())]);
let result = validate_form(&data, &rules);
assert!(result.is_valid);
}
#[test]
fn test_validate_required_missing() {
let rules = FormRules {
fields: HashMap::from([(
"name".to_string(),
FieldRules {
required: true,
..Default::default()
},
)]),
};
let data = HashMap::new();
let result = validate_form(&data, &rules);
assert!(!result.is_valid);
assert!(result.errors.contains_key("name"));
}
#[test]
fn test_validate_min_length() {
let rules = FormRules {
fields: HashMap::from([(
"password".to_string(),
FieldRules {
min: Some(8),
..Default::default()
},
)]),
};
let data = HashMap::from([("password".to_string(), "short".to_string())]);
let result = validate_form(&data, &rules);
assert!(!result.is_valid);
assert!(
result
.errors
.get("password")
.unwrap()
.contains("at least 8")
);
}
#[test]
fn test_validate_max_length() {
let rules = FormRules {
fields: HashMap::from([(
"bio".to_string(),
FieldRules {
max: Some(5),
..Default::default()
},
)]),
};
let data = HashMap::from([("bio".to_string(), "too long text".to_string())]);
let result = validate_form(&data, &rules);
assert!(!result.is_valid);
}
#[test]
fn test_validate_email_type() {
let rules = FormRules {
fields: HashMap::from([(
"email".to_string(),
FieldRules {
field_type: Some("email".to_string()),
..Default::default()
},
)]),
};
let valid = HashMap::from([("email".to_string(), "user@example.com".to_string())]);
assert!(validate_form(&valid, &rules).is_valid);
let invalid = HashMap::from([("email".to_string(), "notanemail".to_string())]);
assert!(!validate_form(&invalid, &rules).is_valid);
}
#[test]
fn test_validate_url_type() {
let rules = FormRules {
fields: HashMap::from([(
"website".to_string(),
FieldRules {
field_type: Some("url".to_string()),
..Default::default()
},
)]),
};
let valid = HashMap::from([("website".to_string(), "https://example.com".to_string())]);
assert!(validate_form(&valid, &rules).is_valid);
let invalid = HashMap::from([("website".to_string(), "not-a-url".to_string())]);
assert!(!validate_form(&invalid, &rules).is_valid);
}
#[test]
fn test_validate_number_type() {
let rules = FormRules {
fields: HashMap::from([(
"age".to_string(),
FieldRules {
field_type: Some("number".to_string()),
..Default::default()
},
)]),
};
let valid = HashMap::from([("age".to_string(), "25".to_string())]);
assert!(validate_form(&valid, &rules).is_valid);
let invalid = HashMap::from([("age".to_string(), "abc".to_string())]);
assert!(!validate_form(&invalid, &rules).is_valid);
}
#[test]
fn test_validate_pattern() {
let rules = FormRules {
fields: HashMap::from([(
"zip".to_string(),
FieldRules {
pattern: Some(r"^\d{5}$".to_string()),
..Default::default()
},
)]),
};
let valid = HashMap::from([("zip".to_string(), "12345".to_string())]);
assert!(validate_form(&valid, &rules).is_valid);
let invalid = HashMap::from([("zip".to_string(), "1234".to_string())]);
assert!(!validate_form(&invalid, &rules).is_valid);
}
#[test]
fn test_validate_match_field() {
let rules = FormRules {
fields: HashMap::from([(
"confirm_password".to_string(),
FieldRules {
match_field: Some("password".to_string()),
..Default::default()
},
)]),
};
let valid = HashMap::from([
("password".to_string(), "secret".to_string()),
("confirm_password".to_string(), "secret".to_string()),
]);
assert!(validate_form(&valid, &rules).is_valid);
let invalid = HashMap::from([
("password".to_string(), "secret".to_string()),
("confirm_password".to_string(), "different".to_string()),
]);
assert!(!validate_form(&invalid, &rules).is_valid);
}
#[test]
fn test_validate_custom_error_message() {
let rules = FormRules {
fields: HashMap::from([(
"name".to_string(),
FieldRules {
required: true,
error_message: Some("Please enter your name".to_string()),
..Default::default()
},
)]),
};
let data = HashMap::new();
let result = validate_form(&data, &rules);
assert_eq!(result.errors.get("name").unwrap(), "Please enter your name");
}
#[test]
fn test_validate_empty_non_required_skips() {
let rules = FormRules {
fields: HashMap::from([(
"bio".to_string(),
FieldRules {
min: Some(10),
..Default::default()
},
)]),
};
let data = HashMap::from([("bio".to_string(), "".to_string())]);
let result = validate_form(&data, &rules);
assert!(result.is_valid); }
#[test]
fn test_parse_form_rules_basic() {
let html = r#"<form w-validate>
<input type="text" name="username" w-required w-min="3" w-max="20">
<input type="email" name="email" w-required w-type="email">
<input type="submit" value="Submit">
</form>"#;
let rules = parse_form_rules(html);
assert_eq!(rules.fields.len(), 2);
let username = rules.fields.get("username").unwrap();
assert!(username.required);
assert_eq!(username.min, Some(3));
assert_eq!(username.max, Some(20));
let email = rules.fields.get("email").unwrap();
assert!(email.required);
assert_eq!(email.field_type.as_deref(), Some("email"));
}
#[test]
fn test_parse_form_rules_with_pattern() {
let html = r#"<input name="zip" w-pattern="^\d{5}$" w-error="Invalid zip code">"#;
let rules = parse_form_rules(html);
let zip = rules.fields.get("zip").unwrap();
assert_eq!(zip.pattern.as_deref(), Some(r"^\d{5}$"));
assert_eq!(zip.error_message.as_deref(), Some("Invalid zip code"));
}
#[test]
fn test_parse_form_rules_with_match() {
let html = r#"<input name="confirm" w-match="password" w-required>"#;
let rules = parse_form_rules(html);
let confirm = rules.fields.get("confirm").unwrap();
assert!(confirm.required);
assert_eq!(confirm.match_field.as_deref(), Some("password"));
}
#[test]
fn test_encode_decode_rules_roundtrip() {
let rules = FormRules {
fields: HashMap::from([
(
"name".to_string(),
FieldRules {
required: true,
min: Some(2),
..Default::default()
},
),
(
"email".to_string(),
FieldRules {
required: true,
field_type: Some("email".to_string()),
..Default::default()
},
),
]),
};
let secret = "test-secret-key";
let token = encode_rules(&rules, secret).expect("encoding should work");
let decoded = decode_rules(&token, secret).expect("decoding should work");
assert_eq!(decoded.fields.len(), 2);
assert!(decoded.fields.get("name").unwrap().required);
assert_eq!(decoded.fields.get("name").unwrap().min, Some(2));
assert_eq!(
decoded.fields.get("email").unwrap().field_type.as_deref(),
Some("email")
);
}
#[test]
fn test_decode_rules_wrong_secret() {
let rules = FormRules {
fields: HashMap::from([(
"name".to_string(),
FieldRules {
required: true,
..Default::default()
},
)]),
};
let token = encode_rules(&rules, "secret1").unwrap();
let result = decode_rules(&token, "secret2");
assert!(result.is_none());
}
#[test]
fn test_decode_rules_invalid_token() {
let result = decode_rules("not.a.valid.token", "secret");
assert!(result.is_none());
}
#[test]
fn test_validate_phone_type() {
let rules = FormRules {
fields: HashMap::from([(
"phone".to_string(),
FieldRules {
field_type: Some("phone".to_string()),
..Default::default()
},
)]),
};
for phone in &[
"+1 555-123-4567",
"(555) 123-4567",
"5551234567",
"+44 20 7946 0958",
] {
let data = HashMap::from([("phone".to_string(), phone.to_string())]);
assert!(
validate_form(&data, &rules).is_valid,
"Expected valid: {}",
phone
);
}
for phone in &["abc", "12", "+1"] {
let data = HashMap::from([("phone".to_string(), phone.to_string())]);
assert!(
!validate_form(&data, &rules).is_valid,
"Expected invalid: {}",
phone
);
}
}
#[test]
fn test_validate_date_type() {
let rules = FormRules {
fields: HashMap::from([(
"date".to_string(),
FieldRules {
field_type: Some("date".to_string()),
..Default::default()
},
)]),
};
for date in &["2024-01-15", "2024-12-31", "2000-06-01"] {
let data = HashMap::from([("date".to_string(), date.to_string())]);
assert!(
validate_form(&data, &rules).is_valid,
"Expected valid: {}",
date
);
}
for date in &["2024-13-01", "2024-00-15", "24-01-15", "not-a-date"] {
let data = HashMap::from([("date".to_string(), date.to_string())]);
assert!(
!validate_form(&data, &rules).is_valid,
"Expected invalid: {}",
date
);
}
}
#[test]
fn test_validate_time_type() {
let rules = FormRules {
fields: HashMap::from([(
"time".to_string(),
FieldRules {
field_type: Some("time".to_string()),
..Default::default()
},
)]),
};
for time in &["00:00", "23:59", "12:30", "09:15:30"] {
let data = HashMap::from([("time".to_string(), time.to_string())]);
assert!(
validate_form(&data, &rules).is_valid,
"Expected valid: {}",
time
);
}
for time in &["24:00", "12:60", "abc", "9:5"] {
let data = HashMap::from([("time".to_string(), time.to_string())]);
assert!(
!validate_form(&data, &rules).is_valid,
"Expected invalid: {}",
time
);
}
}
#[test]
fn test_validate_email_rfc5322() {
let rules = FormRules {
fields: HashMap::from([(
"email".to_string(),
FieldRules {
field_type: Some("email".to_string()),
..Default::default()
},
)]),
};
for email in &["user@example.com", "user+tag@sub.domain.co", "a@b.io"] {
let data = HashMap::from([("email".to_string(), email.to_string())]);
assert!(
validate_form(&data, &rules).is_valid,
"Expected valid: {}",
email
);
}
for email in &["notanemail", "@no-local.com", "user@", "user@.com"] {
let data = HashMap::from([("email".to_string(), email.to_string())]);
assert!(
!validate_form(&data, &rules).is_valid,
"Expected invalid: {}",
email
);
}
}
#[test]
fn test_validate_url_proper() {
let rules = FormRules {
fields: HashMap::from([(
"website".to_string(),
FieldRules {
field_type: Some("url".to_string()),
..Default::default()
},
)]),
};
for url in &[
"https://example.com",
"http://localhost:3000",
"ftp://files.example.com/doc.txt",
] {
let data = HashMap::from([("website".to_string(), url.to_string())]);
assert!(
validate_form(&data, &rules).is_valid,
"Expected valid: {}",
url
);
}
for url in &["not-a-url", "example.com", "://missing-scheme"] {
let data = HashMap::from([("website".to_string(), url.to_string())]);
assert!(
!validate_form(&data, &rules).is_valid,
"Expected invalid: {}",
url
);
}
}
#[test]
fn test_oversized_pattern_rejected() {
let long_pattern = "a".repeat(600);
let rules = FormRules {
fields: HashMap::from([(
"code".to_string(),
FieldRules {
pattern: Some(long_pattern),
..Default::default()
},
)]),
};
let data = HashMap::from([("code".to_string(), "abc".to_string())]);
let result = validate_form(&data, &rules);
assert!(!result.is_valid, "Oversized pattern should be rejected");
assert!(
result
.errors
.values()
.any(|e| e.contains("pattern too long")),
"Error message should mention pattern too long"
);
}
#[test]
fn test_normal_pattern_still_works() {
let rules = FormRules {
fields: HashMap::from([(
"code".to_string(),
FieldRules {
pattern: Some(r"^[A-Z]{3}\d{3}$".to_string()),
..Default::default()
},
)]),
};
let data = HashMap::from([("code".to_string(), "ABC123".to_string())]);
assert!(validate_form(&data, &rules).is_valid);
let data = HashMap::from([("code".to_string(), "abc".to_string())]);
assert!(!validate_form(&data, &rules).is_valid);
}
}