use email_address::{EmailAddress, Options};
use crate::form::{FieldError, FormScalar};
pub trait TypedValue: std::fmt::Display + std::str::FromStr {
const NOUN: &'static str;
const INPUT_TYPE: &'static str = "text";
fn parse_input(value: &str) -> Option<Self> {
value.parse().ok()
}
}
macro_rules! typed_whole_number {
($($ty:ty),* $(,)?) => {
$(
impl TypedValue for $ty {
const NOUN: &'static str = "whole number";
}
)*
};
}
typed_whole_number!(
i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize
);
impl TypedValue for bool {
const NOUN: &'static str = "yes/no value";
}
impl TypedValue for f32 {
const NOUN: &'static str = "number";
fn parse_input(value: &str) -> Option<Self> {
value
.parse::<f32>()
.ok()
.filter(|parsed| parsed.is_finite())
}
}
impl TypedValue for f64 {
const NOUN: &'static str = "number";
fn parse_input(value: &str) -> Option<Self> {
value
.parse::<f64>()
.ok()
.filter(|parsed| parsed.is_finite())
}
}
impl TypedValue for uuid::Uuid {
const NOUN: &'static str = "identifier";
}
impl TypedValue for crate::TenantId {
const NOUN: &'static str = "identifier";
}
impl TypedValue for jiff::Timestamp {
const NOUN: &'static str = "timestamp";
const INPUT_TYPE: &'static str = "datetime-local";
fn parse_input(value: &str) -> Option<Self> {
if let Ok(parsed) = value.parse::<jiff::Timestamp>() {
return Some(parsed);
}
normalize_datetime_local(value)?.parse().ok()
}
}
fn normalize_datetime_local(value: &str) -> Option<String> {
let t = value.find('T')?;
let after_t = &value[t + 1..];
if after_t.contains('+') || after_t.contains('-') {
return None;
}
if value.ends_with(['Z', 'z']) {
return None;
}
if value.len() == 16 {
Some(format!("{value}:00Z"))
} else {
Some(format!("{value}Z"))
}
}
pub(crate) fn format_timestamp_input(storage: &str) -> String {
let trimmed = storage.trim();
if trimmed.is_empty() {
return String::new();
}
match trimmed.parse::<jiff::Timestamp>() {
Ok(parsed) => parsed.strftime("%Y-%m-%dT%H:%M").to_string(),
Err(_) => String::new(),
}
}
type ValueParser = fn(&str) -> Result<String, String>;
fn scalar_parser<T: FormScalar>(value: &str) -> Result<String, String> {
T::parse_form(value).map(|parsed| parsed.to_form())
}
#[derive(Clone, Copy, Default)]
pub(crate) struct Rules {
email: bool,
parser: Option<ValueParser>,
}
impl Rules {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn scalar<T: FormScalar>(mut self) -> Self {
self.parser = Some(scalar_parser::<T>);
self
}
pub(crate) fn set_email(&mut self) {
self.email = true;
}
pub(crate) fn is_email(&self) -> bool {
self.email
}
pub(crate) fn validate(
&self,
key: &str,
label: &str,
required: bool,
value: &str,
) -> Vec<FieldError> {
let v = value.trim();
let mut errs = Vec::new();
if required && v.is_empty() {
errs.push(FieldError::unanswered(key, required_error(label)));
}
if self.email && !v.is_empty() && !is_email(v) {
errs.push(FieldError::invalid(
key,
format!("{label} must be a valid email"),
));
}
if !v.is_empty()
&& errs.is_empty()
&& let Some(parser) = &self.parser
&& let Err(message) = parser(v)
{
errs.push(FieldError::invalid(key, message));
}
errs
}
pub(crate) fn normalize(&self, value: &str) -> Result<String, String> {
let v = value.trim();
match &self.parser {
Some(parser) => parser(v),
None => Ok(v.to_string()),
}
}
}
pub(crate) fn required_error(label: &str) -> String {
format!("{label} is required")
}
const EMAIL_MAX_LENGTH: usize = 254;
fn is_email(value: &str) -> bool {
value.len() <= EMAIL_MAX_LENGTH
&& EmailAddress::parse_with_options(
value,
Options::default()
.with_required_tld()
.without_display_text(),
)
.is_ok()
}
#[cfg(test)]
mod tests;