1use std::sync::OnceLock;
17
18use regex::Regex;
19
20use crate::js;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
26pub(crate) enum PathSeg {
27 Key(String),
28 Index(usize),
29}
30
31impl std::fmt::Display for PathSeg {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 PathSeg::Key(k) => f.write_str(k),
35 PathSeg::Index(i) => write!(f, "[{i}]"),
36 }
37 }
38}
39
40pub(crate) fn format_key_path(path: &[PathSeg]) -> String {
43 let mut s = String::new();
44 for seg in path {
45 match seg {
46 PathSeg::Index(i) => s.push_str(&format!("[{i}]")),
47 PathSeg::Key(k) => {
48 if !s.is_empty() {
49 s.push('.');
50 }
51 s.push_str(k);
52 }
53 }
54 }
55 s
56}
57
58#[derive(Debug, Clone, PartialEq)]
60pub(crate) enum IssueKind {
61 InvalidType { expected: &'static str },
63 InvalidValue { values: Vec<String> },
65 TooSmall,
67 TooBig,
69 InvalidFormat { format: String },
71 UnrecognizedKeys { keys: Vec<String> },
73}
74
75#[derive(Debug, Clone, PartialEq)]
77pub(crate) struct Issue {
78 pub(crate) path: Vec<PathSeg>,
79 pub(crate) kind: IssueKind,
80}
81
82#[derive(Debug, Clone, PartialEq)]
86#[allow(missing_docs)]
87pub enum ConstValue {
88 String(String),
89 Number(f64),
90 Bool(bool),
91 Null,
92}
93
94impl ConstValue {
95 fn matches(&self, v: &serde_json::Value) -> bool {
96 match (self, v) {
97 (ConstValue::String(s), serde_json::Value::String(x)) => s == x,
98 (ConstValue::Number(n), serde_json::Value::Number(x)) => {
99 x.as_f64().is_some_and(|f| f == *n)
100 }
101 (ConstValue::Bool(b), serde_json::Value::Bool(x)) => b == x,
102 (ConstValue::Null, serde_json::Value::Null) => true,
103 _ => false,
104 }
105 }
106
107 pub(crate) fn render(&self) -> String {
109 match self {
110 ConstValue::String(s) => s.clone(),
111 ConstValue::Number(n) => js::num(*n),
112 ConstValue::Bool(b) => b.to_string(),
113 ConstValue::Null => "null".into(),
114 }
115 }
116}
117
118#[derive(Debug, Clone)]
120pub enum Schema {
121 String {
124 min: Option<f64>,
126 max: Option<f64>,
128 pattern: Option<Regex>,
130 },
131 Format {
135 format: StringFormat,
137 min: Option<f64>,
139 max: Option<f64>,
141 pattern: Option<Regex>,
143 },
144 Number {
147 int: bool,
149 min: Option<f64>,
151 max: Option<f64>,
153 },
154 Boolean,
156 Array {
158 of: Box<Schema>,
160 min: Option<f64>,
162 max: Option<f64>,
164 },
165 Object {
168 fields: Vec<(String, Schema)>,
170 strict: bool,
172 },
173 Enum(Vec<String>),
175 Const(ConstValue),
177 Nullable(Box<Schema>),
179 Default(Box<Schema>),
181 Optional(Box<Schema>),
183 Described {
185 inner: Box<Schema>,
187 description: String,
189 },
190}
191
192impl Schema {
193 pub(crate) fn safe_parse(&self, value: Option<&serde_json::Value>) -> Result<(), Vec<Issue>> {
196 let mut issues = Vec::new();
197 check(self, value, &mut Vec::new(), &mut issues);
198 if issues.is_empty() {
199 Ok(())
200 } else {
201 Err(issues)
202 }
203 }
204}
205
206fn unwrap_describing<'a>(mut schema: &'a Schema, best: &mut Option<&'a str>) -> &'a Schema {
211 loop {
212 match schema {
213 Schema::Described { inner, description } => {
214 *best = Some(description);
215 schema = inner;
216 }
217 Schema::Optional(inner) | Schema::Default(inner) | Schema::Nullable(inner) => {
218 schema = inner;
219 }
220 other => return other,
221 }
222 }
223}
224
225pub(crate) fn description_along_path<'a>(schema: &'a Schema, path: &[PathSeg]) -> Option<&'a str> {
230 let mut best = None;
231 let mut node = unwrap_describing(schema, &mut best);
232 for seg in path {
233 let next = match (node, seg) {
234 (Schema::Object { fields, .. }, PathSeg::Key(k)) => {
235 match fields.iter().find(|(key, _)| key == k) {
236 Some((_, field)) => field,
237 None => return best,
238 }
239 }
240 (Schema::Array { of, .. }, PathSeg::Index(_)) => of,
241 _ => return best,
242 };
243 node = unwrap_describing(next, &mut best);
244 }
245 best
246}
247
248pub(crate) fn type_name(v: Option<&serde_json::Value>) -> &'static str {
251 match v {
252 None => "undefined",
253 Some(serde_json::Value::Null) => "null",
254 Some(serde_json::Value::Bool(_)) => "boolean",
255 Some(serde_json::Value::Number(_)) => "number",
256 Some(serde_json::Value::String(_)) => "string",
257 Some(serde_json::Value::Array(_)) => "array",
258 Some(serde_json::Value::Object(_)) => "object",
259 }
260}
261
262fn utf16_len(s: &str) -> usize {
264 s.encode_utf16().count()
265}
266
267fn issue(issues: &mut Vec<Issue>, path: &[PathSeg], kind: IssueKind) {
268 issues.push(Issue {
269 path: path.to_vec(),
270 kind,
271 });
272}
273
274fn check(
277 schema: &Schema,
278 value: Option<&serde_json::Value>,
279 path: &mut Vec<PathSeg>,
280 issues: &mut Vec<Issue>,
281) {
282 match schema {
283 Schema::Optional(inner) | Schema::Default(inner) => {
284 if value.is_none() {
285 return; }
287 check(inner, value, path, issues);
288 }
289 Schema::Nullable(inner) => {
290 if matches!(value, Some(serde_json::Value::Null)) {
291 return;
292 }
293 check(inner, value, path, issues);
294 }
295 Schema::Described { inner, .. } => check(inner, value, path, issues),
296 Schema::String { min, max, pattern } => {
297 check_string(value, *min, *max, pattern.as_ref(), path, issues)
298 }
299 Schema::Format {
300 format,
301 min,
302 max,
303 pattern,
304 } => check_format(value, format, *min, *max, pattern.as_ref(), path, issues),
305 Schema::Number { int, min, max } => check_number(value, *int, *min, *max, path, issues),
306 Schema::Boolean => {
307 if !matches!(value, Some(serde_json::Value::Bool(_))) {
308 issue(
309 issues,
310 path,
311 IssueKind::InvalidType {
312 expected: "boolean",
313 },
314 );
315 }
316 }
317 Schema::Array { of, min, max } => check_array(value, of, *min, *max, path, issues),
318 Schema::Object { fields, strict } => check_object(value, fields, *strict, path, issues),
319 Schema::Enum(options) => {
320 let ok = matches!(value, Some(serde_json::Value::String(s)) if options.contains(s));
321 if !ok {
322 issue(
323 issues,
324 path,
325 IssueKind::InvalidValue {
326 values: options.clone(),
327 },
328 );
329 }
330 }
331 Schema::Const(c) => {
332 let ok = value.is_some_and(|v| c.matches(v));
333 if !ok {
334 issue(
335 issues,
336 path,
337 IssueKind::InvalidValue {
338 values: vec![c.render()],
339 },
340 );
341 }
342 }
343 }
344}
345
346fn check_string(
347 value: Option<&serde_json::Value>,
348 min: Option<f64>,
349 max: Option<f64>,
350 pattern: Option<&Regex>,
351 path: &[PathSeg],
352 issues: &mut Vec<Issue>,
353) {
354 let Some(serde_json::Value::String(s)) = value else {
355 issue(issues, path, IssueKind::InvalidType { expected: "string" });
356 return;
357 };
358 let len = utf16_len(s) as f64;
359 if let Some(min) = min
360 && len < min
361 {
362 issue(issues, path, IssueKind::TooSmall);
363 }
364 if let Some(max) = max
365 && len > max
366 {
367 issue(issues, path, IssueKind::TooBig);
368 }
369 if let Some(re) = pattern
370 && !re.is_match(s)
371 {
372 issue(
373 issues,
374 path,
375 IssueKind::InvalidFormat {
376 format: "regex".into(),
377 },
378 );
379 }
380}
381
382fn check_format(
386 value: Option<&serde_json::Value>,
387 format: &StringFormat,
388 min: Option<f64>,
389 max: Option<f64>,
390 pattern: Option<&Regex>,
391 path: &[PathSeg],
392 issues: &mut Vec<Issue>,
393) {
394 let Some(serde_json::Value::String(s)) = value else {
395 issue(issues, path, IssueKind::InvalidType { expected: "string" });
396 return;
397 };
398 if !format.is_match(s) {
399 issue(
400 issues,
401 path,
402 IssueKind::InvalidFormat {
403 format: format.name().into(),
404 },
405 );
406 }
407 let len = utf16_len(s) as f64;
408 if let Some(min) = min
409 && len < min
410 {
411 issue(issues, path, IssueKind::TooSmall);
412 }
413 if let Some(max) = max
414 && len > max
415 {
416 issue(issues, path, IssueKind::TooBig);
417 }
418 if let Some(re) = pattern
419 && !re.is_match(s)
420 {
421 issue(
422 issues,
423 path,
424 IssueKind::InvalidFormat {
425 format: "regex".into(),
426 },
427 );
428 }
429}
430
431fn check_number(
432 value: Option<&serde_json::Value>,
433 int: bool,
434 min: Option<f64>,
435 max: Option<f64>,
436 path: &[PathSeg],
437 issues: &mut Vec<Issue>,
438) {
439 let n = match value {
440 Some(serde_json::Value::Number(n)) => n.as_f64(),
441 _ => None,
442 };
443 let Some(n) = n else {
444 issue(
445 issues,
446 path,
447 IssueKind::InvalidType {
448 expected: if int { "int" } else { "number" },
449 },
450 );
451 return;
452 };
453 if int {
456 if n.fract() != 0.0 {
457 issue(issues, path, IssueKind::InvalidType { expected: "int" });
458 return;
459 }
460 if n.abs() > 9_007_199_254_740_991.0 {
461 let kind = if n > 0.0 {
462 IssueKind::TooBig
463 } else {
464 IssueKind::TooSmall
465 };
466 issue(issues, path, kind);
467 }
468 }
469 if let Some(min) = min
470 && n < min
471 {
472 issue(issues, path, IssueKind::TooSmall);
473 }
474 if let Some(max) = max
475 && n > max
476 {
477 issue(issues, path, IssueKind::TooBig);
478 }
479}
480
481fn check_array(
482 value: Option<&serde_json::Value>,
483 of: &Schema,
484 min: Option<f64>,
485 max: Option<f64>,
486 path: &mut Vec<PathSeg>,
487 issues: &mut Vec<Issue>,
488) {
489 let Some(serde_json::Value::Array(items)) = value else {
490 issue(issues, path, IssueKind::InvalidType { expected: "array" });
491 return;
492 };
493 let len = items.len() as f64;
494 if let Some(min) = min
495 && len < min
496 {
497 issue(issues, path, IssueKind::TooSmall);
498 }
499 if let Some(max) = max
500 && len > max
501 {
502 issue(issues, path, IssueKind::TooBig);
503 }
504 for (i, item) in items.iter().enumerate() {
505 path.push(PathSeg::Index(i));
506 check(of, Some(item), path, issues);
507 path.pop();
508 }
509}
510
511fn check_object(
512 value: Option<&serde_json::Value>,
513 fields: &[(String, Schema)],
514 strict: bool,
515 path: &mut Vec<PathSeg>,
516 issues: &mut Vec<Issue>,
517) {
518 let Some(serde_json::Value::Object(map)) = value else {
519 issue(issues, path, IssueKind::InvalidType { expected: "object" });
520 return;
521 };
522 for (key, schema) in fields {
525 path.push(PathSeg::Key(key.clone()));
526 check(schema, map.get(key), path, issues);
527 path.pop();
528 }
529 if strict {
530 let unknown: Vec<String> = map
531 .keys()
532 .filter(|k| !fields.iter().any(|(f, _)| f == *k))
533 .cloned()
534 .collect();
535 if !unknown.is_empty() {
536 issue(issues, path, IssueKind::UnrecognizedKeys { keys: unknown });
537 }
538 }
539}
540
541macro_rules! re {
545 ($source:expr $(,)?) => {{
546 static RE: OnceLock<Regex> = OnceLock::new();
547 RE.get_or_init(|| Regex::new(&$source).expect("static format regex compiles"))
548 }};
549}
550
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553#[allow(missing_docs)]
554pub enum StringFormat {
555 Email,
557 Url,
559 Uuid,
561 Hostname,
562 Datetime,
564 Date,
566 Time,
568 Duration,
570 Ipv4,
571 Ipv6,
572 Cidrv4,
573 Cidrv6,
574 Nanoid,
575 Cuid,
576 Cuid2,
577 Ulid,
578 Base64,
579 Emoji,
580 E164,
582}
583
584pub(crate) const STRING_FORMATS: &[(&str, StringFormat)] = &[
587 ("email", StringFormat::Email),
588 ("url", StringFormat::Url),
589 ("uuid", StringFormat::Uuid),
590 ("hostname", StringFormat::Hostname),
591 ("datetime", StringFormat::Datetime),
592 ("date", StringFormat::Date),
593 ("time", StringFormat::Time),
594 ("duration", StringFormat::Duration),
595 ("ipv4", StringFormat::Ipv4),
596 ("ipv6", StringFormat::Ipv6),
597 ("cidrv4", StringFormat::Cidrv4),
598 ("cidrv6", StringFormat::Cidrv6),
599 ("nanoid", StringFormat::Nanoid),
600 ("cuid", StringFormat::Cuid),
601 ("cuid2", StringFormat::Cuid2),
602 ("ulid", StringFormat::Ulid),
603 ("base64", StringFormat::Base64),
604 ("emoji", StringFormat::Emoji),
605 ("e164", StringFormat::E164),
606];
607
608impl StringFormat {
609 pub fn name(&self) -> &'static str {
611 STRING_FORMATS
612 .iter()
613 .find(|(_, f)| f == self)
614 .map(|(n, _)| *n)
615 .expect("every format is named")
616 }
617
618 pub(crate) fn from_name(name: &str) -> Option<Self> {
620 STRING_FORMATS
621 .iter()
622 .find(|(n, _)| *n == name)
623 .map(|(_, f)| *f)
624 }
625
626 pub(crate) fn is_match(&self, s: &str) -> bool {
629 match self {
630 StringFormat::Email => is_email(s),
631 StringFormat::Url => is_url(s),
632 StringFormat::Uuid => re!(
633 r"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
634 )
635 .is_match(s),
636 StringFormat::Hostname => is_hostname(s),
637 StringFormat::Datetime => re!(&format!(
638 "^{DATE_SOURCE}T(?:{TIME_SOURCE}(?:Z))$"
639 ))
640 .is_match(s),
641 StringFormat::Date => re!(&format!("^{DATE_SOURCE}$")).is_match(s),
642 StringFormat::Time => re!(&format!("^{TIME_SOURCE}$")).is_match(s),
643 StringFormat::Duration => is_duration(s),
644 StringFormat::Ipv4 => re!(
645 r"^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$",
646 )
647 .is_match(s),
648 StringFormat::Ipv6 => is_ipv6(s),
649 StringFormat::Cidrv4 => re!(
650 r"^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])/([0-9]|[1-2][0-9]|3[0-2])$",
651 )
652 .is_match(s),
653 StringFormat::Cidrv6 => is_cidrv6(s),
654 StringFormat::Nanoid => re!(r"^[a-zA-Z0-9_-]{21}$").is_match(s),
655 StringFormat::Cuid => re!(r"^[cC][0-9a-z]{6,}$").is_match(s),
656 StringFormat::Cuid2 => re!(r"^[0-9a-z]+$").is_match(s),
657 StringFormat::Ulid => re!(r"^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$").is_match(s),
658 StringFormat::Base64 => re!(
659 r"^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$",
660 )
661 .is_match(s),
662 StringFormat::Emoji => re!(r"^(\p{Extended_Pictographic}|\p{Emoji_Component})+$")
663 .is_match(s),
664 StringFormat::E164 => re!(r"^\+[1-9][0-9]{6,14}$").is_match(s),
665 }
666 }
667}
668
669const DATE_SOURCE: &str = r"(?:(?:[0-9][0-9][2468][048]|[0-9][0-9][13579][26]|[0-9][0-9]0[48]|[02468][048]00|[13579][26]00)-02-29|[0-9]{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])|(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)|(?:02)-(?:0[1-9]|1[0-9]|2[0-8])))";
671
672const TIME_SOURCE: &str = r"(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9](?:\.[0-9]+)?)?";
674
675fn is_ipv6(s: &str) -> bool {
679 s.parse::<std::net::Ipv6Addr>().is_ok()
680}
681
682fn is_cidrv6(s: &str) -> bool {
685 let mut parts = s.split('/');
686 let (Some(address), Some(prefix), None) = (parts.next(), parts.next(), parts.next()) else {
687 return false;
688 };
689 let canonical = prefix
690 .parse::<u32>()
691 .is_ok_and(|n| n <= 128 && n.to_string() == prefix);
692 canonical && is_ipv6(address)
693}
694
695fn is_email(s: &str) -> bool {
698 if s.starts_with('.') || s.contains("..") {
699 return false;
700 }
701 re!(r"^([A-Za-z0-9_'+\-.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9-]*\.)+[A-Za-z]{2,}$")
702 .is_match(s)
703}
704
705fn is_url(s: &str) -> bool {
709 let s = s.trim_matches(|c: char| c <= ' '); let Some(colon) = s.find(':') else {
711 return false;
712 };
713 let scheme = &s[..colon];
714 let mut chars = scheme.chars();
715 let scheme_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
716 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
717 if !scheme_ok {
718 return false;
719 }
720 let rest = &s[colon + 1..];
721 let special = matches!(
722 scheme.to_ascii_lowercase().as_str(),
723 "http" | "https" | "ws" | "wss" | "ftp" | "file"
724 );
725 if !special {
726 return true; }
728 if scheme.eq_ignore_ascii_case("file") {
729 return true; }
731 let after = rest.trim_start_matches(['/', '\\']);
733 let host = after.split(['/', '?', '#']).next().unwrap_or("");
734 let host = host.rsplit('@').next().unwrap_or("");
735 !host.is_empty() && !host.starts_with(':')
736}
737
738fn is_hostname(s: &str) -> bool {
740 let len = s.strip_suffix('.').unwrap_or(s).len();
741 if len == 0 || len > 253 || s.is_empty() {
742 return false;
743 }
744 re!(r"^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[-0-9a-zA-Z]{0,61}[0-9a-zA-Z])?)*\.?$").is_match(s)
745}
746
747fn is_duration(s: &str) -> bool {
751 let Some(body) = s.strip_prefix('P') else {
752 return false;
753 };
754 if re!(r"^[0-9]+W$").is_match(body) {
755 return true;
756 }
757 if body.contains('W') || !re!(r"^(?:[0-9]|T[0-9])").is_match(body) {
759 return false;
760 }
761 re!(r"^([0-9]+Y)?([0-9]+M)?([0-9]+D)?(T([0-9]+H)?([0-9]+M)?([0-9]+([.,][0-9]+)?S)?)?$").is_match(body)
762 && !body.ends_with('T')
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769 use serde_json::json;
770
771 fn kinds(schema: &Schema, v: &serde_json::Value) -> Vec<Issue> {
772 schema.safe_parse(Some(v)).err().unwrap_or_default()
773 }
774
775 #[test]
778 fn string_type_bounds_and_pattern() {
779 let s = Schema::String {
780 min: Some(2.0),
781 max: Some(4.0),
782 pattern: Some(Regex::new("^a").unwrap()),
783 };
784 assert!(s.safe_parse(Some(&json!("abc"))).is_ok());
785 assert_eq!(
786 kinds(&s, &json!(7))[0].kind,
787 IssueKind::InvalidType { expected: "string" }
788 );
789 assert_eq!(kinds(&s, &json!("a"))[0].kind, IssueKind::TooSmall);
790 assert_eq!(kinds(&s, &json!("aaaaa"))[0].kind, IssueKind::TooBig);
791 assert_eq!(
792 kinds(&s, &json!("bc"))[0].kind,
793 IssueKind::InvalidFormat {
794 format: "regex".into()
795 }
796 );
797 }
798
799 #[test]
800 fn enum_and_const_report_invalid_value() {
801 let e = Schema::Enum(vec!["a".into(), "b".into()]);
802 assert!(e.safe_parse(Some(&json!("a"))).is_ok());
803 assert_eq!(
804 kinds(&e, &json!("c"))[0].kind,
805 IssueKind::InvalidValue {
806 values: vec!["a".into(), "b".into()]
807 }
808 );
809 let c = Schema::Const(ConstValue::Number(3.0));
810 assert!(c.safe_parse(Some(&json!(3))).is_ok());
811 assert!(c.safe_parse(Some(&json!(4))).is_err());
812 }
813
814 #[test]
815 fn strict_object_reports_missing_required_and_unknown_keys() {
816 let o = Schema::Object {
817 fields: vec![(
818 "id".into(),
819 Schema::String {
820 min: None,
821 max: None,
822 pattern: None,
823 },
824 )],
825 strict: true,
826 };
827 let issues = kinds(&o, &json!({"extra": 1}));
828 assert_eq!(
829 issues[0],
830 Issue {
831 path: vec![PathSeg::Key("id".into())],
832 kind: IssueKind::InvalidType { expected: "string" }
833 }
834 );
835 assert_eq!(
836 issues[1],
837 Issue {
838 path: vec![],
839 kind: IssueKind::UnrecognizedKeys {
840 keys: vec!["extra".into()]
841 }
842 }
843 );
844 }
845
846 #[test]
847 fn wrappers_admit_missing_null_and_defaulted_values() {
848 let base = || Box::new(Schema::Boolean);
849 assert!(Schema::Optional(base()).safe_parse(None).is_ok());
850 assert!(Schema::Default(base()).safe_parse(None).is_ok());
851 assert!(
852 Schema::Nullable(base())
853 .safe_parse(Some(&json!(null)))
854 .is_ok()
855 );
856 assert_eq!(
858 Schema::Boolean.safe_parse(None).unwrap_err()[0].kind,
859 IssueKind::InvalidType {
860 expected: "boolean"
861 }
862 );
863 }
864
865 #[test]
866 fn array_recurses_with_index_paths() {
867 let a = Schema::Array {
868 of: Box::new(Schema::String {
869 min: None,
870 max: None,
871 pattern: None,
872 }),
873 min: Some(1.0),
874 max: None,
875 };
876 assert!(a.safe_parse(Some(&json!(["x"]))).is_ok());
877 assert_eq!(kinds(&a, &json!([]))[0].kind, IssueKind::TooSmall);
878 let issues = kinds(&a, &json!(["ok", 5]));
879 assert_eq!(issues[0].path, vec![PathSeg::Index(1)]);
880 }
881
882 #[test]
885 fn number_int_is_a_safe_integer_gate() {
886 let n = Schema::Number {
887 int: true,
888 min: Some(0.0),
889 max: Some(10.0),
890 };
891 assert!(n.safe_parse(Some(&json!(5))).is_ok());
892 assert_eq!(
893 kinds(&n, &json!(1.5)),
894 vec![Issue {
895 path: vec![],
896 kind: IssueKind::InvalidType { expected: "int" }
897 }]
898 );
899 assert_eq!(kinds(&n, &json!(-1))[0].kind, IssueKind::TooSmall);
900 assert_eq!(kinds(&n, &json!(11))[0].kind, IssueKind::TooBig);
901 let big = kinds(&n, &json!(9_007_199_254_740_993u64));
902 assert_eq!(
903 big.iter().map(|i| i.kind.clone()).collect::<Vec<_>>(),
904 vec![IssueKind::TooBig, IssueKind::TooBig]
905 );
906 let small = kinds(&n, &json!(-9_007_199_254_740_993i64));
907 assert_eq!(
908 small.iter().map(|i| i.kind.clone()).collect::<Vec<_>>(),
909 vec![IssueKind::TooSmall, IssueKind::TooSmall]
910 );
911 }
912
913 #[test]
915 fn format_digits_are_ascii() {
916 assert!(!StringFormat::Date.is_match("\u{FF12}\u{FF10}\u{FF12}\u{FF14}-01-02"));
917 assert!(!StringFormat::E164.is_match("+1\u{0663}15550132"));
918 }
919
920 #[test]
921 fn const_numbers_render_as_js_does() {
922 assert_eq!(ConstValue::Number(1e21).render(), "1e+21");
923 assert_eq!(ConstValue::Number(9.3e18).render(), "9300000000000000000");
924 assert_eq!(ConstValue::Number(-0.0).render(), "0");
925 }
926
927 #[test]
930 fn date_format_is_leap_year_aware() {
931 let f = StringFormat::Date;
932 assert!(f.is_match("2024-02-29")); assert!(!f.is_match("2023-02-29"));
934 assert!(f.is_match("2023-12-31"));
935 assert!(!f.is_match("2023-13-01"));
936 assert!(!f.is_match("2023-04-31"));
937 }
938
939 #[test]
940 fn datetime_requires_utc_z_and_allows_optional_seconds() {
941 let f = StringFormat::Datetime;
942 assert!(f.is_match("2024-01-02T03:04:05Z"));
943 assert!(f.is_match("2024-01-02T03:04Z")); assert!(f.is_match("2024-01-02T03:04:05.123Z"));
945 assert!(!f.is_match("2024-01-02T03:04:05")); assert!(!f.is_match("2024-01-02T03:04:05+02:00")); assert!(!f.is_match("2024-01-02 03:04:05Z"));
948 }
949
950 #[test]
951 fn email_matches_zods_practical_regex() {
952 let f = StringFormat::Email;
953 assert!(f.is_match("a.b+c@example.co"));
954 assert!(!f.is_match(".lead@example.com")); assert!(!f.is_match("a..b@example.com")); assert!(!f.is_match("a@example"));
957 assert!(!f.is_match("a b@example.com"));
958 }
959
960 #[test]
961 fn url_accepts_whatwg_parsable_urls() {
962 let f = StringFormat::Url;
963 assert!(f.is_match("https://example.com/path?q=1"));
964 assert!(f.is_match("mailto:user@example.com")); assert!(!f.is_match("example.com")); assert!(!f.is_match("http://")); }
968
969 #[test]
970 fn uuid_admits_versions_1_to_8_plus_nil_and_max() {
971 let f = StringFormat::Uuid;
972 assert!(f.is_match("6ba7b810-9dad-11d1-80b4-00c04fd430c8"));
973 assert!(f.is_match("00000000-0000-0000-0000-000000000000"));
974 assert!(!f.is_match("6ba7b810-9dad-01d1-80b4-00c04fd430c8")); assert!(!f.is_match("6ba7b810-9dad-11d1-c0b4-00c04fd430c8")); }
977
978 #[test]
979 fn remaining_formats_spot_checks() {
980 assert!(StringFormat::Time.is_match("23:59:59.999"));
981 assert!(!StringFormat::Time.is_match("24:00"));
982 assert!(StringFormat::Duration.is_match("P3Y6M4DT12H30M5S"));
983 assert!(StringFormat::Duration.is_match("P4W"));
984 assert!(!StringFormat::Duration.is_match("P4WT1H")); assert!(!StringFormat::Duration.is_match("P1YT")); assert!(StringFormat::Ipv4.is_match("192.168.0.1"));
987 assert!(!StringFormat::Ipv4.is_match("256.0.0.1"));
988 assert!(StringFormat::Ipv6.is_match("2001:db8::1"));
989 assert!(StringFormat::Ipv6.is_match("::ffff:192.0.2.1")); assert!(!StringFormat::Ipv6.is_match("2001:db8"));
991 assert!(StringFormat::Cidrv4.is_match("10.0.0.0/8"));
992 assert!(StringFormat::Cidrv6.is_match("2001:db8::/32"));
993 assert!(!StringFormat::Cidrv6.is_match("2001:db8::/032")); assert!(!StringFormat::Cidrv6.is_match("2001:db8::/129"));
995 assert!(StringFormat::Hostname.is_match("sub.example-1.com"));
996 assert!(!StringFormat::Hostname.is_match("-bad.com"));
997 assert!(StringFormat::Nanoid.is_match("V1StGXR8_Z5jdHi6B-myT"));
998 assert!(StringFormat::Cuid.is_match("cjld2cjxh0000qzrmn831i7rn"));
999 assert!(StringFormat::Cuid2.is_match("tz4a98xxat96iws9zmbrgj3a"));
1000 assert!(StringFormat::Ulid.is_match("01ARZ3NDEKTSV4RRFFQ69G5FAV"));
1001 assert!(StringFormat::Base64.is_match("aGVsbG8="));
1002 assert!(!StringFormat::Base64.is_match("aGVsbG8"));
1003 assert!(StringFormat::E164.is_match("+14155550132"));
1004 assert!(!StringFormat::E164.is_match("+04155550132"));
1005 assert!(StringFormat::Emoji.is_match("🎉"));
1006 assert!(!StringFormat::Emoji.is_match("x"));
1007 }
1008
1009 #[test]
1012 fn format_composes_with_bounds_and_pattern() {
1013 let s = Schema::Format {
1014 format: StringFormat::Email,
1015 min: Some(30.0),
1016 max: None,
1017 pattern: Some(Regex::new("^ops-").unwrap()),
1018 };
1019 assert!(
1020 s.safe_parse(Some(&json!("ops-team+alerts@example-corp.com")))
1021 .is_ok()
1022 );
1023 let issues = kinds(&s, &json!("not an email"));
1024 assert_eq!(
1025 issues.iter().map(|i| i.kind.clone()).collect::<Vec<_>>(),
1026 vec![
1027 IssueKind::InvalidFormat {
1028 format: "email".into()
1029 },
1030 IssueKind::TooSmall,
1031 IssueKind::InvalidFormat {
1032 format: "regex".into()
1033 },
1034 ]
1035 );
1036 assert_eq!(
1038 kinds(&s, &json!("ops-abcdefghijklmnop@example.com")),
1039 vec![]
1040 );
1041 }
1042
1043 #[test]
1046 fn described_delegates_and_resolves_along_paths() {
1047 let field = Schema::Described {
1048 inner: Box::new(Schema::Boolean),
1049 description: "the field".into(),
1050 };
1051 assert!(field.safe_parse(Some(&json!(true))).is_ok());
1052 assert!(field.safe_parse(Some(&json!(1))).is_err());
1053
1054 let root = Schema::Described {
1055 inner: Box::new(Schema::Object {
1056 fields: vec![
1057 ("flag".into(), field),
1058 ("bare".into(), Schema::Boolean),
1059 (
1060 "items".into(),
1061 Schema::Array {
1062 of: Box::new(Schema::Described {
1063 inner: Box::new(Schema::Boolean),
1064 description: "each item".into(),
1065 }),
1066 min: None,
1067 max: None,
1068 },
1069 ),
1070 ],
1071 strict: true,
1072 }),
1073 description: "the root".into(),
1074 };
1075 let key = |k: &str| PathSeg::Key(k.to_string());
1076 assert_eq!(
1078 description_along_path(&root, &[key("flag")]),
1079 Some("the field")
1080 );
1081 assert_eq!(
1083 description_along_path(&root, &[key("bare")]),
1084 Some("the root")
1085 );
1086 assert_eq!(
1087 description_along_path(&root, &[key("items"), PathSeg::Index(0)]),
1088 Some("each item")
1089 );
1090 assert_eq!(
1092 description_along_path(&root, &[key("nope")]),
1093 Some("the root")
1094 );
1095 }
1096
1097 #[test]
1098 fn format_key_path_renders_keys_and_indices() {
1099 assert_eq!(format_key_path(&[]), "");
1100 assert_eq!(
1101 format_key_path(&[PathSeg::Key("a".into()), PathSeg::Key("b".into())]),
1102 "a.b"
1103 );
1104 assert_eq!(
1105 format_key_path(&[PathSeg::Key("related".into()), PathSeg::Index(0)]),
1106 "related[0]"
1107 );
1108 }
1109}