this/core/
field.rs

1//! Field value types and validation
2
3use chrono::{DateTime, Utc};
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::sync::OnceLock;
7use uuid::Uuid;
8
9/// A polymorphic field value that can hold different types
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11#[serde(untagged)]
12pub enum FieldValue {
13    String(String),
14    Integer(i64),
15    Float(f64),
16    Boolean(bool),
17    Uuid(Uuid),
18    DateTime(DateTime<Utc>),
19    Null,
20}
21
22impl FieldValue {
23    /// Get the value as a string if possible
24    pub fn as_string(&self) -> Option<&str> {
25        match self {
26            FieldValue::String(s) => Some(s),
27            _ => None,
28        }
29    }
30
31    /// Get the value as an integer if possible
32    pub fn as_integer(&self) -> Option<i64> {
33        match self {
34            FieldValue::Integer(i) => Some(*i),
35            _ => None,
36        }
37    }
38
39    /// Get the value as a UUID if possible
40    pub fn as_uuid(&self) -> Option<Uuid> {
41        match self {
42            FieldValue::Uuid(u) => Some(*u),
43            _ => None,
44        }
45    }
46
47    /// Check if the value is null
48    pub fn is_null(&self) -> bool {
49        matches!(self, FieldValue::Null)
50    }
51}
52
53/// Field format validators for automatic validation
54#[derive(Debug, Clone)]
55pub enum FieldFormat {
56    Email,
57    Uuid,
58    Url,
59    Phone,
60    Custom(Regex),
61}
62
63impl FieldFormat {
64    /// Validate a field value against this format
65    pub fn validate(&self, value: &FieldValue) -> bool {
66        let string_value = match value.as_string() {
67            Some(s) => s,
68            None => return false,
69        };
70
71        match self {
72            FieldFormat::Email => Self::is_valid_email(string_value),
73            FieldFormat::Uuid => Uuid::parse_str(string_value).is_ok(),
74            FieldFormat::Url => Self::is_valid_url(string_value),
75            FieldFormat::Phone => Self::is_valid_phone(string_value),
76            FieldFormat::Custom(regex) => regex.is_match(string_value),
77        }
78    }
79
80    fn is_valid_email(email: &str) -> bool {
81        static EMAIL_REGEX: OnceLock<Regex> = OnceLock::new();
82        let regex = EMAIL_REGEX.get_or_init(|| {
83            Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap()
84        });
85        regex.is_match(email)
86    }
87
88    fn is_valid_url(url: &str) -> bool {
89        static URL_REGEX: OnceLock<Regex> = OnceLock::new();
90        let regex = URL_REGEX.get_or_init(|| Regex::new(r"^https?://[^\s/$.?#].[^\s]*$").unwrap());
91        regex.is_match(url)
92    }
93
94    fn is_valid_phone(phone: &str) -> bool {
95        static PHONE_REGEX: OnceLock<Regex> = OnceLock::new();
96        let regex = PHONE_REGEX.get_or_init(|| {
97            // At least 8 digits, max 15 (E.164 standard)
98            Regex::new(r"^\+?[1-9]\d{7,14}$").unwrap()
99        });
100        regex.is_match(phone)
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn test_field_value_string() {
110        let value = FieldValue::String("test".to_string());
111        assert_eq!(value.as_string(), Some("test"));
112        assert_eq!(value.as_integer(), None);
113        assert!(!value.is_null());
114    }
115
116    #[test]
117    fn test_field_value_integer() {
118        let value = FieldValue::Integer(42);
119        assert_eq!(value.as_integer(), Some(42));
120        assert_eq!(value.as_string(), None);
121    }
122
123    #[test]
124    fn test_field_value_null() {
125        let value = FieldValue::Null;
126        assert!(value.is_null());
127        assert_eq!(value.as_string(), None);
128    }
129
130    #[test]
131    fn test_email_validation() {
132        let format = FieldFormat::Email;
133
134        assert!(format.validate(&FieldValue::String("test@example.com".to_string())));
135        assert!(format.validate(&FieldValue::String(
136            "user.name+tag@example.co.uk".to_string()
137        )));
138        assert!(!format.validate(&FieldValue::String("invalid-email".to_string())));
139        assert!(!format.validate(&FieldValue::String("@example.com".to_string())));
140    }
141
142    #[test]
143    fn test_uuid_validation() {
144        let format = FieldFormat::Uuid;
145        let valid_uuid = Uuid::new_v4().to_string();
146
147        assert!(format.validate(&FieldValue::String(valid_uuid)));
148        assert!(!format.validate(&FieldValue::String("not-a-uuid".to_string())));
149    }
150
151    #[test]
152    fn test_url_validation() {
153        let format = FieldFormat::Url;
154
155        assert!(format.validate(&FieldValue::String("https://example.com".to_string())));
156        assert!(format.validate(&FieldValue::String(
157            "http://test.com/path?query=1".to_string()
158        )));
159        assert!(!format.validate(&FieldValue::String("not a url".to_string())));
160    }
161
162    #[test]
163    fn test_phone_validation() {
164        let format = FieldFormat::Phone;
165
166        assert!(format.validate(&FieldValue::String("+33612345678".to_string())));
167        assert!(format.validate(&FieldValue::String("33612345678".to_string())));
168        assert!(!format.validate(&FieldValue::String("123".to_string())));
169    }
170
171    #[test]
172    fn test_custom_regex_validation() {
173        let format = FieldFormat::Custom(Regex::new(r"^[A-Z]{3}\d{3}$").unwrap());
174
175        assert!(format.validate(&FieldValue::String("ABC123".to_string())));
176        assert!(!format.validate(&FieldValue::String("abc123".to_string())));
177        assert!(!format.validate(&FieldValue::String("ABCD123".to_string())));
178    }
179}