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 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 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 #[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()); assert!(schema.validate(&json!("hell")).is_err()); }
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()); assert!(schema.validate(&json!("hello!")).is_err()); }
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 #[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 let schema = string().min(1).max(10);
302 assert!(schema.validate(&json!("π¦")).is_ok());
304 }
305
306 #[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 #[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 #[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 #[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 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 #[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 #[test]
499 fn test_impossible_constraint_min_greater_than_max() {
500 let schema = string().min(10).max(5);
501 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 #[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()); assert!(schema.validate(&json!("testing123456")).is_err()); assert!(schema.validate(&json!("hello")).is_err()); }
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}