Skip to main content

zod_rs/schema/
string.rs

1use crate::schema::Schema;
2use serde_json::Value;
3use std::sync::LazyLock;
4use zod_rs_util::{
5    StringFormat, ValidateResult, ValidationError, ValidationOrigin, ValidationType,
6};
7
8static EMAIL_REGEX: LazyLock<regex::Regex> =
9    LazyLock::new(|| regex::Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap());
10
11#[derive(Debug, Clone)]
12pub struct StringSchema {
13    min_length: Option<usize>,
14    max_length: Option<usize>,
15    starts_with: Option<String>,
16    ends_with: Option<String>,
17    includes: Option<String>,
18    pattern: Option<regex::Regex>,
19    email: bool,
20    url: bool,
21}
22
23impl StringSchema {
24    pub fn new() -> Self {
25        Self {
26            min_length: None,
27            max_length: None,
28            starts_with: None,
29            ends_with: None,
30            includes: None,
31            pattern: None,
32            email: false,
33            url: false,
34        }
35    }
36
37    pub fn min(mut self, min: usize) -> Self {
38        self.min_length = Some(min);
39        self
40    }
41
42    pub fn max(mut self, max: usize) -> Self {
43        self.max_length = Some(max);
44        self
45    }
46
47    pub fn length(self, len: usize) -> Self {
48        self.min(len).max(len)
49    }
50
51    pub fn starts_with(mut self, val: &str) -> Self {
52        self.starts_with = Some(val.into());
53        self
54    }
55
56    pub fn ends_with(mut self, val: &str) -> Self {
57        self.ends_with = Some(val.into());
58        self
59    }
60
61    pub fn includes(mut self, val: &str) -> Self {
62        self.includes = Some(val.into());
63        self
64    }
65
66    /// Sets a regex pattern for validation.
67    ///
68    /// # Panics
69    /// Panics if the pattern is not a valid regex. Use `try_regex()` for fallible version.
70    pub fn regex(mut self, pattern: &str) -> Self {
71        self.pattern = Some(
72            regex::Regex::new(pattern)
73                .unwrap_or_else(|e| panic!("Invalid regex pattern '{}': {}", pattern, e)),
74        );
75        self
76    }
77
78    /// Sets a regex pattern for validation, returning an error if the pattern is invalid.
79    pub fn try_regex(mut self, pattern: &str) -> Result<Self, regex::Error> {
80        self.pattern = Some(regex::Regex::new(pattern)?);
81        Ok(self)
82    }
83
84    pub fn email(mut self) -> Self {
85        self.email = true;
86        self
87    }
88
89    pub fn url(mut self) -> Self {
90        self.url = true;
91        self
92    }
93}
94
95impl Default for StringSchema {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl Schema<String> for StringSchema {
102    fn validate(&self, value: &Value) -> ValidateResult<String> {
103        let string_val = match value.as_str() {
104            Some(s) => s.to_string(),
105            None => {
106                return Err(ValidationError::invalid_type(
107                    ValidationType::String,
108                    ValidationType::from(value),
109                )
110                .into());
111            }
112        };
113
114        if let Some(min) = self.min_length {
115            if string_val.len() < min {
116                return Err(ValidationError::too_small(
117                    ValidationOrigin::String,
118                    min.to_string(),
119                    true,
120                )
121                .into());
122            }
123        }
124
125        if let Some(max) = self.max_length {
126            if string_val.len() > max {
127                return Err(ValidationError::too_big(
128                    ValidationOrigin::String,
129                    max.to_string(),
130                    true,
131                )
132                .into());
133            }
134        }
135
136        if let Some(starts_with) = &self.starts_with {
137            if !string_val.starts_with(starts_with) {
138                return Err(ValidationError::invalid_format(
139                    StringFormat::StartsWith,
140                    Some(starts_with.to_string()),
141                )
142                .into());
143            }
144        }
145
146        if let Some(ends_with) = &self.ends_with {
147            if !string_val.ends_with(ends_with) {
148                return Err(ValidationError::invalid_format(
149                    StringFormat::EndsWith,
150                    Some(ends_with.to_string()),
151                )
152                .into());
153            }
154        }
155
156        if let Some(includes) = &self.includes {
157            if !string_val.contains(includes) {
158                return Err(ValidationError::invalid_format(
159                    StringFormat::Includes,
160                    Some(includes.to_string()),
161                )
162                .into());
163            }
164        }
165
166        if let Some(pattern) = &self.pattern {
167            if !pattern.is_match(&string_val) {
168                return Err(ValidationError::invalid_format(
169                    StringFormat::Regex,
170                    Some(pattern.to_string()),
171                )
172                .into());
173            }
174        }
175
176        if self.email && !is_valid_email(&string_val) {
177            return Err(
178                ValidationError::invalid_format(StringFormat::custom("email"), None).into(),
179            );
180        }
181
182        if self.url && !is_valid_url(&string_val) {
183            return Err(ValidationError::invalid_format(StringFormat::custom("url"), None).into());
184        }
185
186        Ok(string_val)
187    }
188}
189
190fn is_valid_email(email: &str) -> bool {
191    EMAIL_REGEX.is_match(email)
192}
193
194fn is_valid_url(url: &str) -> bool {
195    url.starts_with("http://") || url.starts_with("https://")
196}
197
198pub fn string() -> StringSchema {
199    StringSchema::new()
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use serde_json::json;
206
207    #[test]
208    fn test_string_validation() {
209        let schema = string().min(3).max(10);
210
211        assert!(schema.validate(&json!("hello")).is_ok());
212        assert!(schema.validate(&json!("hi")).is_err());
213        assert!(schema.validate(&json!("this is too long")).is_err());
214        assert!(schema.validate(&json!(123)).is_err());
215    }
216
217    #[test]
218    fn test_string_starts_with() {
219        let schema = string().starts_with("john");
220
221        assert!(schema.validate(&json!("john doe")).is_ok());
222        assert!(schema.validate(&json!("marry jane")).is_err());
223    }
224
225    #[test]
226    fn test_string_ends_with() {
227        let schema = string().ends_with("jane");
228
229        assert!(schema.validate(&json!("john doe")).is_err());
230        assert!(schema.validate(&json!("marry jane")).is_ok());
231    }
232
233    #[test]
234    fn test_string_includes() {
235        let schema = string().includes("25 years old");
236
237        assert!(schema
238            .validate(&json!("I am an 25 years old art director."))
239            .is_ok());
240        assert!(schema
241            .validate(&json!("I AM AN 25 YEARS OLD ART DIRECTOR"))
242            .is_err());
243    }
244
245    // ==================== EDGE CASE TESTS ====================
246
247    // Boundary Conditions
248    #[test]
249    fn test_empty_string_with_min_zero() {
250        let schema = string().min(0);
251        assert!(schema.validate(&json!("")).is_ok());
252    }
253
254    #[test]
255    fn test_string_exactly_at_min_boundary() {
256        let schema = string().min(5);
257        assert!(schema.validate(&json!("hello")).is_ok()); // exactly 5 chars
258        assert!(schema.validate(&json!("hell")).is_err()); // 4 chars
259    }
260
261    #[test]
262    fn test_string_exactly_at_max_boundary() {
263        let schema = string().max(5);
264        assert!(schema.validate(&json!("hello")).is_ok()); // exactly 5 chars
265        assert!(schema.validate(&json!("hello!")).is_err()); // 6 chars
266    }
267
268    #[test]
269    fn test_string_length_exact() {
270        let schema = string().length(5);
271        assert!(schema.validate(&json!("hello")).is_ok());
272        assert!(schema.validate(&json!("hi")).is_err());
273        assert!(schema.validate(&json!("hello!")).is_err());
274    }
275
276    // Unicode and Multi-byte Characters
277    #[test]
278    fn test_unicode_emoji() {
279        let schema = string();
280        assert!(schema.validate(&json!("πŸ¦€")).is_ok());
281        assert_eq!(schema.validate(&json!("πŸ¦€")).unwrap(), "πŸ¦€");
282    }
283
284    #[test]
285    fn test_unicode_chinese() {
286        let schema = string();
287        assert!(schema.validate(&json!("δ½ ε₯½")).is_ok());
288        assert_eq!(schema.validate(&json!("δ½ ε₯½")).unwrap(), "δ½ ε₯½");
289    }
290
291    #[test]
292    fn test_unicode_mixed() {
293        let schema = string();
294        assert!(schema.validate(&json!("Hello πŸ¦€ δΈ–η•Œ")).is_ok());
295    }
296
297    #[test]
298    fn test_unicode_length_bytes_vs_chars() {
299        // Note: Rust's len() counts bytes, not characters
300        // "πŸ¦€" is 4 bytes but 1 character
301        let schema = string().min(1).max(10);
302        // This tests that we're counting bytes (current behavior)
303        assert!(schema.validate(&json!("πŸ¦€")).is_ok());
304    }
305
306    // Pattern Edge Cases
307    #[test]
308    fn test_starts_with_empty_pattern() {
309        let schema = string().starts_with("");
310        assert!(schema.validate(&json!("anything")).is_ok());
311        assert!(schema.validate(&json!("")).is_ok());
312    }
313
314    #[test]
315    fn test_ends_with_empty_pattern() {
316        let schema = string().ends_with("");
317        assert!(schema.validate(&json!("anything")).is_ok());
318    }
319
320    #[test]
321    fn test_includes_empty_pattern() {
322        let schema = string().includes("");
323        assert!(schema.validate(&json!("anything")).is_ok());
324    }
325
326    #[test]
327    fn test_pattern_longer_than_string() {
328        let schema = string().starts_with("very long pattern");
329        assert!(schema.validate(&json!("short")).is_err());
330    }
331
332    #[test]
333    fn test_unicode_pattern_matching() {
334        let schema = string().starts_with("πŸ¦€");
335        assert!(schema.validate(&json!("πŸ¦€ is a crab")).is_ok());
336        assert!(schema.validate(&json!("crab πŸ¦€")).is_err());
337    }
338
339    #[test]
340    fn test_case_sensitivity() {
341        let schema = string().starts_with("Hello");
342        assert!(schema.validate(&json!("Hello World")).is_ok());
343        assert!(schema.validate(&json!("hello World")).is_err());
344    }
345
346    // Regex Edge Cases
347    #[test]
348    fn test_regex_empty_pattern() {
349        let schema = string().regex("");
350        assert!(schema.validate(&json!("anything")).is_ok());
351        assert!(schema.validate(&json!("")).is_ok());
352    }
353
354    #[test]
355    fn test_regex_anchored() {
356        let schema = string().regex("^start");
357        assert!(schema.validate(&json!("start here")).is_ok());
358        assert!(schema.validate(&json!("not start")).is_err());
359    }
360
361    #[test]
362    fn test_regex_end_anchor() {
363        let schema = string().regex("end$");
364        assert!(schema.validate(&json!("the end")).is_ok());
365        assert!(schema.validate(&json!("end here")).is_err());
366    }
367
368    #[test]
369    fn test_regex_special_chars() {
370        let schema = string().regex(r"\d+");
371        assert!(schema.validate(&json!("abc123def")).is_ok());
372        assert!(schema.validate(&json!("no digits")).is_err());
373    }
374
375    #[test]
376    fn test_try_regex_valid() {
377        let schema = string().try_regex(r"\d+").unwrap();
378        assert!(schema.validate(&json!("123")).is_ok());
379    }
380
381    #[test]
382    fn test_try_regex_invalid() {
383        let result = string().try_regex("[invalid");
384        assert!(result.is_err());
385    }
386
387    // Email Validation Edge Cases
388    #[test]
389    fn test_email_valid() {
390        let schema = string().email();
391        assert!(schema.validate(&json!("user@example.com")).is_ok());
392    }
393
394    #[test]
395    fn test_email_missing_at() {
396        let schema = string().email();
397        assert!(schema.validate(&json!("userexample.com")).is_err());
398    }
399
400    #[test]
401    fn test_email_multiple_at() {
402        let schema = string().email();
403        assert!(schema.validate(&json!("user@@example.com")).is_err());
404    }
405
406    #[test]
407    fn test_email_with_spaces() {
408        let schema = string().email();
409        assert!(schema.validate(&json!(" user@example.com")).is_err());
410        assert!(schema.validate(&json!("user@example.com ")).is_err());
411        assert!(schema.validate(&json!("user @example.com")).is_err());
412    }
413
414    #[test]
415    fn test_email_missing_domain() {
416        let schema = string().email();
417        assert!(schema.validate(&json!("user@")).is_err());
418    }
419
420    #[test]
421    fn test_email_missing_tld() {
422        let schema = string().email();
423        assert!(schema.validate(&json!("user@domain")).is_err());
424    }
425
426    // URL Validation Edge Cases
427    #[test]
428    fn test_url_valid_https() {
429        let schema = string().url();
430        assert!(schema.validate(&json!("https://example.com")).is_ok());
431    }
432
433    #[test]
434    fn test_url_valid_http() {
435        let schema = string().url();
436        assert!(schema.validate(&json!("http://example.com")).is_ok());
437    }
438
439    #[test]
440    fn test_url_just_protocol() {
441        let schema = string().url();
442        // Note: current implementation accepts this (minimal validation)
443        assert!(schema.validate(&json!("https://")).is_ok());
444    }
445
446    #[test]
447    fn test_url_missing_protocol() {
448        let schema = string().url();
449        assert!(schema.validate(&json!("example.com")).is_err());
450        assert!(schema.validate(&json!("www.example.com")).is_err());
451    }
452
453    #[test]
454    fn test_url_with_path() {
455        let schema = string().url();
456        assert!(schema.validate(&json!("https://example.com/path/to/page")).is_ok());
457    }
458
459    #[test]
460    fn test_url_with_query() {
461        let schema = string().url();
462        assert!(schema.validate(&json!("https://example.com?foo=bar")).is_ok());
463    }
464
465    #[test]
466    fn test_url_with_fragment() {
467        let schema = string().url();
468        assert!(schema.validate(&json!("https://example.com#section")).is_ok());
469    }
470
471    // Type Rejection
472    #[test]
473    fn test_rejects_null() {
474        let schema = string();
475        assert!(schema.validate(&json!(null)).is_err());
476    }
477
478    #[test]
479    fn test_rejects_boolean() {
480        let schema = string();
481        assert!(schema.validate(&json!(true)).is_err());
482        assert!(schema.validate(&json!(false)).is_err());
483    }
484
485    #[test]
486    fn test_rejects_array() {
487        let schema = string();
488        assert!(schema.validate(&json!(["a", "b"])).is_err());
489    }
490
491    #[test]
492    fn test_rejects_object() {
493        let schema = string();
494        assert!(schema.validate(&json!({"key": "value"})).is_err());
495    }
496
497    // Constraint Conflicts
498    #[test]
499    fn test_impossible_constraint_min_greater_than_max() {
500        let schema = string().min(10).max(5);
501        // All strings will fail validation
502        assert!(schema.validate(&json!("hello")).is_err());
503        assert!(schema.validate(&json!("hi")).is_err());
504        assert!(schema.validate(&json!("hello world")).is_err());
505    }
506
507    // Combined Constraints
508    #[test]
509    fn test_combined_min_max_and_pattern() {
510        let schema = string().min(3).max(10).starts_with("test");
511        assert!(schema.validate(&json!("testing")).is_ok());
512        assert!(schema.validate(&json!("te")).is_err()); // too short
513        assert!(schema.validate(&json!("testing123456")).is_err()); // too long
514        assert!(schema.validate(&json!("hello")).is_err()); // wrong prefix
515    }
516
517    #[test]
518    fn test_email_and_length() {
519        let schema = string().email().max(20);
520        assert!(schema.validate(&json!("a@b.com")).is_ok());
521        assert!(schema.validate(&json!("verylongemail@verylongdomain.com")).is_err());
522    }
523}