1use indexmap::IndexMap;
43use regex::Regex;
44use std::sync::LazyLock;
45
46use crate::document::{self, Scalar as DocScalar};
47use crate::error::SchemaError;
48
49pub(crate) static DATE_RE: LazyLock<Regex> =
56 LazyLock::new(|| Regex::new(r"^(?P<y>\d{4})-(?P<mo>\d{2})-(?P<da>\d{2})$").unwrap());
57
58pub(crate) static TIME_RE: LazyLock<Regex> = LazyLock::new(|| {
60 Regex::new(
61 r"^(?P<h>\d{2}):(?P<m>\d{2})(:(?P<s>\d{2})(\.(?P<f>\d{1,6}))?)?(?P<off>[+\-]\d{2}:\d{2})?$",
62 )
63 .unwrap()
64});
65
66pub(crate) static DATETIME_RE: LazyLock<Regex> = LazyLock::new(|| {
68 Regex::new(
69 r"^(?P<y>\d{4})-(?P<mo>\d{2})-(?P<da>\d{2})T(?P<h>\d{2}):(?P<m>\d{2})(:(?P<s>\d{2})(\.(?P<f>\d{1,6}))?)?(?P<off>[+\-]\d{2}:\d{2})?$",
70 )
71 .unwrap()
72});
73
74pub(crate) fn is_leap_year(y: u32) -> bool {
75 (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
76}
77
78pub(crate) fn days_in_month(y: u32, m: u32) -> u32 {
79 match m {
80 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
81 4 | 6 | 9 | 11 => 30,
82 2 => {
83 if is_leap_year(y) {
84 29
85 } else {
86 28
87 }
88 }
89 _ => 0,
90 }
91}
92
93pub(crate) fn valid_ymd(y: u32, m: u32, d: u32) -> bool {
96 (1..=9999).contains(&y) && (1..=12).contains(&m) && d >= 1 && d <= days_in_month(y, m)
97}
98
99pub(crate) fn valid_hms(h: u32, m: u32, s: u32) -> bool {
101 h <= 23 && m <= 59 && s <= 59
102}
103
104fn valid_offset(off: Option<regex::Match<'_>>) -> bool {
107 match off {
108 None => true,
109 Some(m) => {
110 let text = m.as_str();
111 let oh: u32 = text[1..3].parse().unwrap_or(u32::MAX);
113 let om: u32 = text[4..6].parse().unwrap_or(u32::MAX);
114 oh <= 23 && om <= 59
115 }
116 }
117}
118
119fn mandatory_u32(caps: ®ex::Captures<'_>, name: &str) -> u32 {
128 caps.name(name)
129 .expect("group is mandatory in the pattern")
130 .as_str()
131 .parse()
132 .expect("group is all-digits per the pattern")
133}
134
135pub(crate) fn is_iso_date(s: &str) -> bool {
139 let Some(caps) = DATE_RE.captures(s) else {
140 return false;
141 };
142 valid_ymd(
143 mandatory_u32(&caps, "y"),
144 mandatory_u32(&caps, "mo"),
145 mandatory_u32(&caps, "da"),
146 )
147}
148
149pub(crate) fn is_iso_time(s: &str) -> bool {
152 let Some(caps) = TIME_RE.captures(s) else {
153 return false;
154 };
155 is_valid_time_captures(&caps)
156}
157
158fn is_valid_time_captures(caps: ®ex::Captures<'_>) -> bool {
159 let h = mandatory_u32(caps, "h");
160 let m = mandatory_u32(caps, "m");
161 let s = caps.name("s").map_or(0, |c| c.as_str().parse().unwrap());
165 valid_hms(h, m, s) && valid_offset(caps.name("off"))
166}
167
168pub(crate) fn is_iso_datetime(s: &str) -> bool {
177 let Some(caps) = DATETIME_RE.captures(s) else {
178 return false;
179 };
180 let ok_date = valid_ymd(
181 mandatory_u32(&caps, "y"),
182 mandatory_u32(&caps, "mo"),
183 mandatory_u32(&caps, "da"),
184 );
185 ok_date && is_valid_time_captures(&caps)
186}
187
188pub(crate) fn canonicalize_iso_time(s: &str) -> String {
200 let caps = TIME_RE
201 .captures(s)
202 .expect("caller must validate with is_iso_time first");
203 canonicalize_time_captures(&caps)
204}
205
206pub(crate) fn canonicalize_iso_datetime(s: &str) -> String {
215 let caps = DATETIME_RE
216 .captures(s)
217 .expect("caller must validate with is_iso_datetime first");
218 let y = caps.name("y").expect("mandatory group").as_str();
219 let mo = caps.name("mo").expect("mandatory group").as_str();
220 let da = caps.name("da").expect("mandatory group").as_str();
221 format!("{y}-{mo}-{da}T{}", canonicalize_time_captures(&caps))
222}
223
224fn canonicalize_time_captures(caps: ®ex::Captures<'_>) -> String {
228 let h = caps.name("h").expect("mandatory group").as_str();
229 let m = caps.name("m").expect("mandatory group").as_str();
230 let s = caps.name("s").map_or("00", |c| c.as_str());
231 let mut out = format!("{h}:{m}:{s}");
232 if let Some(f) = caps.name("f") {
233 out.push('.');
234 let digits = f.as_str();
235 out.push_str(digits);
236 for _ in digits.len()..6 {
237 out.push('0');
238 }
239 }
240 if let Some(off) = caps.name("off") {
241 out.push_str(off.as_str());
242 }
243 out
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
252pub enum ScalarKind {
253 String,
255 Integer,
257 Number,
259 Boolean,
261 Date,
263 Time,
265 Datetime,
267}
268
269impl ScalarKind {
270 pub const ALL: [ScalarKind; 7] = [
272 ScalarKind::String,
273 ScalarKind::Integer,
274 ScalarKind::Number,
275 ScalarKind::Boolean,
276 ScalarKind::Date,
277 ScalarKind::Time,
278 ScalarKind::Datetime,
279 ];
280
281 pub fn as_str(&self) -> &'static str {
283 match self {
284 ScalarKind::String => "string",
285 ScalarKind::Integer => "integer",
286 ScalarKind::Number => "number",
287 ScalarKind::Boolean => "boolean",
288 ScalarKind::Date => "date",
289 ScalarKind::Time => "time",
290 ScalarKind::Datetime => "datetime",
291 }
292 }
293
294 pub fn parse(name: &str) -> Result<ScalarKind, SchemaError> {
297 ScalarKind::ALL
298 .into_iter()
299 .find(|k| k.as_str() == name)
300 .ok_or_else(|| {
301 let names: Vec<&str> = ScalarKind::ALL.iter().map(|k| k.as_str()).collect();
302 SchemaError::new(
303 name,
304 "schema.unknown-type",
305 format!("unknown scalar {name:?}; expected one of {names:?}"),
306 )
307 })
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
313pub struct Scalar {
314 kind: ScalarKind,
315 nullable: bool,
316}
317
318impl Scalar {
319 pub const fn new(kind: ScalarKind, nullable: bool) -> Self {
321 Scalar { kind, nullable }
322 }
323
324 pub fn named(name: &str, nullable: bool) -> Result<Self, SchemaError> {
327 Ok(Scalar::new(ScalarKind::parse(name)?, nullable))
328 }
329
330 pub fn kind(&self) -> ScalarKind {
332 self.kind
333 }
334
335 pub fn is_nullable(&self) -> bool {
337 self.nullable
338 }
339}
340
341impl std::fmt::Display for Scalar {
342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343 write!(
344 f,
345 "{}{}",
346 self.kind.as_str(),
347 if self.nullable { "?" } else { "" }
348 )
349 }
350}
351
352pub const STRING: Scalar = Scalar::new(ScalarKind::String, false);
354pub const INTEGER: Scalar = Scalar::new(ScalarKind::Integer, false);
356pub const NUMBER: Scalar = Scalar::new(ScalarKind::Number, false);
358pub const BOOLEAN: Scalar = Scalar::new(ScalarKind::Boolean, false);
360pub const DATE: Scalar = Scalar::new(ScalarKind::Date, false);
362pub const TIME: Scalar = Scalar::new(ScalarKind::Time, false);
364pub const DATETIME: Scalar = Scalar::new(ScalarKind::Datetime, false);
366
367pub fn nullable(scalar: Scalar) -> Scalar {
369 Scalar::new(scalar.kind, true)
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Hash)]
378pub struct Ref {
379 pub name: String,
381}
382
383impl Ref {
384 pub fn new(name: impl Into<String>) -> Self {
386 Ref { name: name.into() }
387 }
388}
389
390impl std::fmt::Display for Ref {
391 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392 write!(f, "ref({})", self.name)
393 }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Hash)]
403pub enum FieldType {
404 Scalar(Scalar),
406 Ref(Ref),
408 Any,
410}
411
412impl From<Scalar> for FieldType {
413 fn from(s: Scalar) -> Self {
414 FieldType::Scalar(s)
415 }
416}
417
418impl From<Ref> for FieldType {
419 fn from(r: Ref) -> Self {
420 FieldType::Ref(r)
421 }
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Hash)]
431pub struct Field {
432 pub label: String,
434 pub ty: FieldType,
436 pub min: usize,
438 pub max: Option<usize>,
440}
441
442impl Field {
443 pub fn new(
445 label: impl Into<String>,
446 ty: impl Into<FieldType>,
447 min: usize,
448 max: Option<usize>,
449 ) -> Result<Self, SchemaError> {
450 let label = label.into();
451 if let Some(max) = max
452 && max < min
453 {
454 return Err(SchemaError::new(
455 label.clone(),
456 "schema.invalid-cardinality",
457 format!("field {label:?} has an invalid cardinality [{min},{max}]"),
458 ));
459 }
460 Ok(Field {
461 label,
462 ty: ty.into(),
463 min,
464 max,
465 })
466 }
467
468 pub fn required(
470 label: impl Into<String>,
471 ty: impl Into<FieldType>,
472 ) -> Result<Self, SchemaError> {
473 Field::new(label, ty, 1, Some(1))
474 }
475
476 pub fn cardinality_str(&self) -> String {
478 match (self.min, self.max) {
479 (1, Some(1)) => "exactly 1".to_string(),
480 (0, Some(1)) => "0 or 1".to_string(),
481 (min, None) => format!("at least {min}"),
482 (min, Some(max)) => format!("between {min} and {max}"),
483 }
484 }
485}
486
487#[derive(Debug, Clone)]
500pub struct Record {
501 fields: Vec<Field>,
502 by_label: IndexMap<String, usize>,
503}
504
505impl PartialEq for Record {
506 fn eq(&self, other: &Self) -> bool {
507 if self.fields.len() != other.fields.len() {
508 return false;
509 }
510 let mut a: Vec<&Field> = self.fields.iter().collect();
514 let mut b: Vec<&Field> = other.fields.iter().collect();
515 a.sort_by(|x, y| x.label.cmp(&y.label));
516 b.sort_by(|x, y| x.label.cmp(&y.label));
517 a == b
518 }
519}
520
521impl Eq for Record {}
522
523impl Record {
524 pub fn new(fields: Vec<Field>) -> Result<Self, SchemaError> {
526 let mut by_label = IndexMap::with_capacity(fields.len());
527 for (i, f) in fields.iter().enumerate() {
528 if by_label.insert(f.label.clone(), i).is_some() {
529 return Err(SchemaError::new(
530 &f.label,
531 "schema.duplicate-field",
532 format!("duplicate field label {:?} in a record", f.label),
533 ));
534 }
535 }
536 Ok(Record { fields, by_label })
537 }
538
539 pub fn fields(&self) -> &[Field] {
541 &self.fields
542 }
543
544 pub fn field(&self, label: &str) -> Option<&Field> {
546 self.by_label.get(label).map(|&i| &self.fields[i])
547 }
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
557pub enum ErrorCode {
558 UnexpectedField,
560 Cardinality,
562 TypeMismatch,
564 NullNotAllowed,
566 ShapeMismatch,
568}
569
570#[derive(Debug, Clone, Copy, PartialEq, Eq)]
578pub enum ErrorFamily {
579 Validate,
581 Materialize,
583}
584
585impl ErrorCode {
586 pub fn as_str(&self, family: ErrorFamily) -> &'static str {
595 use ErrorFamily::{Materialize, Validate};
596 match (self, family) {
597 (ErrorCode::UnexpectedField, Validate) => "validate.unexpected-field",
598 (ErrorCode::UnexpectedField, Materialize) => "materialize.unexpected-field",
599 (ErrorCode::Cardinality, Validate) => "validate.cardinality",
600 (ErrorCode::Cardinality, Materialize) => "materialize.cardinality",
601 (ErrorCode::TypeMismatch, Validate) => "validate.type-mismatch",
602 (ErrorCode::TypeMismatch, Materialize) => "materialize.inexact-conversion",
603 (ErrorCode::NullNotAllowed, Validate) => "validate.null-not-allowed",
604 (ErrorCode::NullNotAllowed, Materialize) => "materialize.null-not-allowed",
605 (ErrorCode::ShapeMismatch, Validate) => "validate.shape-mismatch",
606 (ErrorCode::ShapeMismatch, Materialize) => "materialize.shape-mismatch",
607 }
608 }
609}
610
611#[derive(Debug, Clone, PartialEq, Eq)]
613pub struct ValidationError {
614 pub path: String,
616 pub message: String,
618 pub code: ErrorCode,
620}
621
622#[derive(Debug, Clone, Default, PartialEq, Eq)]
625pub struct ValidationResult {
626 errors: Vec<ValidationError>,
627}
628
629impl ValidationResult {
630 pub fn new() -> Self {
632 ValidationResult::default()
633 }
634
635 pub fn ok(&self) -> bool {
637 self.errors.is_empty()
638 }
639
640 pub fn errors(&self) -> &[ValidationError] {
642 &self.errors
643 }
644
645 pub(crate) fn add(
651 &mut self,
652 path: impl Into<String>,
653 message: impl Into<String>,
654 code: ErrorCode,
655 ) {
656 self.errors.push(ValidationError {
657 path: path.into(),
658 message: message.into(),
659 code,
660 });
661 }
662}
663
664impl std::fmt::Display for ValidationResult {
665 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
666 if self.ok() {
667 return write!(f, "valid");
668 }
669 writeln!(f, "invalid:")?;
670 for (i, e) in self.errors.iter().enumerate() {
671 if i > 0 {
672 writeln!(f)?;
673 }
674 write!(f, " at {}: {}", e.path, e.message)?;
675 }
676 Ok(())
677 }
678}
679
680pub fn matches_kind(value: &DocScalar, kind: ScalarKind) -> bool {
703 match kind {
704 ScalarKind::String => matches!(value, DocScalar::Str(_)),
705 ScalarKind::Boolean => matches!(value, DocScalar::Bool(_)),
706 ScalarKind::Integer => matches!(value, DocScalar::Int(_)),
707 ScalarKind::Number => matches!(value, DocScalar::Int(_) | DocScalar::Float(_)),
708 ScalarKind::Date => {
709 matches!(value, DocScalar::Date(_))
710 || matches!(value, DocScalar::Str(s) if is_iso_date(s))
711 }
712 ScalarKind::Time => {
713 matches!(value, DocScalar::Time(_))
714 || matches!(value, DocScalar::Str(s) if is_iso_time(s))
715 }
716 ScalarKind::Datetime => {
721 matches!(value, DocScalar::Datetime(_))
722 || matches!(value, DocScalar::Str(s) if is_iso_datetime(s) && !is_iso_date(s))
723 }
724 }
725}
726
727pub(crate) fn value_kind_name(v: &DocScalar) -> &'static str {
731 match v {
732 DocScalar::Null => "null",
733 DocScalar::Bool(_) => "boolean",
734 DocScalar::Int(_) => "integer",
735 DocScalar::Float(_) => "number",
736 DocScalar::Str(_) => "string",
737 DocScalar::Date(_) => "date",
738 DocScalar::Time(_) => "time",
739 DocScalar::Datetime(_) => "datetime",
740 }
741}
742
743pub enum Resolved<'a> {
749 Record(&'a Record),
751 Scalar(Scalar),
753 Any,
755}
756
757#[derive(Debug, Clone, PartialEq, Eq)]
759pub struct Schema {
760 root: Ref,
761 env: IndexMap<String, Record>,
762}
763
764impl Schema {
765 pub fn new(root: Ref, env: IndexMap<String, Record>) -> Result<Self, SchemaError> {
774 Self::check_reserved_names(&env)?;
775 let schema = Schema { root, env };
776 schema.check_refs()?;
777 Ok(schema)
778 }
779
780 fn check_reserved_names(env: &IndexMap<String, Record>) -> Result<(), SchemaError> {
781 for name in env.keys() {
782 if name == "any" {
783 return Err(SchemaError::new(
784 "any",
785 "schema.reserved-name",
786 format!(
787 "'any' is a reserved type name and cannot be used as a record name (record {name:?})"
788 ),
789 ));
790 }
791 if ScalarKind::ALL.iter().any(|k| k.as_str() == name) {
792 return Err(SchemaError::new(
793 name,
794 "schema.reserved-name",
795 format!(
796 "{name:?} is a reserved scalar name; a record cannot be defined with this name, or it could never be referenced (a bare name in a type position always means the builtin scalar)"
797 ),
798 ));
799 }
800 }
801 Ok(())
802 }
803
804 pub fn root(&self) -> &Ref {
806 &self.root
807 }
808
809 pub fn env(&self) -> &IndexMap<String, Record> {
811 &self.env
812 }
813
814 fn check_refs(&self) -> Result<(), SchemaError> {
815 if !self.env.contains_key(&self.root.name) {
816 return Err(SchemaError::new(
817 "$",
818 "schema.unknown-type",
819 format!("unknown type {:?}", self.root.name),
820 ));
821 }
822 for (rec_name, rec) in &self.env {
823 for f in rec.fields() {
824 if let FieldType::Ref(r) = &f.ty
825 && !self.env.contains_key(&r.name)
826 {
827 return Err(SchemaError::new(
828 format!("{rec_name}.{}", f.label),
829 "schema.unknown-type",
830 format!("unknown type {:?}", r.name),
831 ));
832 }
833 }
834 }
835 Ok(())
836 }
837
838 pub fn resolve(&self, ty: &FieldType) -> Resolved<'_> {
842 match ty {
843 FieldType::Scalar(s) => Resolved::Scalar(*s),
844 FieldType::Any => Resolved::Any,
845 FieldType::Ref(r) => Resolved::Record(
846 self.env
847 .get(&r.name)
848 .expect("check_refs guarantees every Ref resolves"),
849 ),
850 }
851 }
852
853 pub fn validate(&self, cursor: &document::Cursor<'_>) -> ValidationResult {
857 let mut res = ValidationResult::new();
858 let mut path = cursor.path.clone();
865 self.conform(
866 cursor,
867 &FieldType::Ref(self.root.clone()),
868 &mut res,
869 &mut path,
870 );
871 res
872 }
873
874 pub fn accepts(&self, cursor: &document::Cursor<'_>) -> bool {
876 self.validate(cursor).ok()
877 }
878
879 fn conform(
880 &self,
881 cursor: &document::Cursor<'_>,
882 ty: &FieldType,
883 res: &mut ValidationResult,
884 path: &mut String,
885 ) {
886 match self.resolve(ty) {
887 Resolved::Any => {}
891 Resolved::Scalar(s) => self.conform_scalar(cursor, s, res, path),
892 Resolved::Record(r) => self.conform_record(cursor, r, res, path),
893 }
894 }
895
896 fn conform_scalar(
897 &self,
898 cursor: &document::Cursor<'_>,
899 s: Scalar,
900 res: &mut ValidationResult,
901 path: &str,
902 ) {
903 if !cursor.is_leaf() {
904 res.add(
905 path,
906 format!("expected a {} value, got an object", s.kind().as_str()),
907 ErrorCode::ShapeMismatch,
908 );
909 return;
910 }
911 let v = cursor
912 .value()
913 .expect("is_leaf() true implies value() succeeds");
914 if matches!(v, DocScalar::Null) {
915 if !s.is_nullable() {
916 res.add(path, "null not allowed here", ErrorCode::NullNotAllowed);
917 }
918 return;
919 }
920 if !matches_kind(v, s.kind()) {
921 res.add(
922 path,
923 format!(
924 "expected {}, got {} ({})",
925 s.kind().as_str(),
926 value_kind_name(v),
927 v
928 ),
929 ErrorCode::TypeMismatch,
930 );
931 }
932 }
933
934 fn conform_record(
944 &self,
945 cursor: &document::Cursor<'_>,
946 rec: &Record,
947 res: &mut ValidationResult,
948 path: &mut String,
949 ) {
950 if cursor.is_leaf() {
951 res.add(
952 path.as_str(),
953 "expected an object, got a value",
954 ErrorCode::ShapeMismatch,
955 );
956 return;
957 }
958 let edges = cursor
959 .raw_edges()
960 .expect("is_leaf() false implies raw_edges() succeeds");
961 let mut counts: IndexMap<&str, usize> = IndexMap::new();
962 for (label, i, child_id) in &edges {
963 *counts.entry(*label).or_insert(0) += 1;
964 let base = path.len();
965 crate::report::push_child_path(path, label, *i);
966 match rec.field(label) {
967 None => res.add(
968 path.as_str(),
969 "unexpected field",
970 ErrorCode::UnexpectedField,
971 ),
972 Some(f) => {
973 let child = cursor.seek(*child_id);
974 self.conform(&child, &f.ty, res, path);
975 }
976 }
977 path.truncate(base);
978 }
979 for f in rec.fields() {
980 let c = counts.get(f.label.as_str()).copied().unwrap_or(0);
981 if c < f.min || f.max.is_some_and(|max| c > max) {
982 res.add(
983 path.as_str(),
984 format!(
985 "field {:?} occurs {} time(s), expected {}",
986 f.label,
987 c,
988 f.cardinality_str()
989 ),
990 ErrorCode::Cardinality,
991 );
992 }
993 }
994 }
995}
996
997#[cfg(test)]
998mod tests {
999 use super::*;
1000 use crate::document::{Doc, Value};
1001 use indexmap::IndexMap as Map;
1002
1003 #[test]
1004 fn record_partial_eq_treats_field_order_as_insignificant_but_field_count_as_significant() {
1005 let r1 = Record::new(vec![
1006 Field::required("x", STRING).unwrap(),
1007 Field::required("y", STRING).unwrap(),
1008 ])
1009 .unwrap();
1010 let r2 = Record::new(vec![
1011 Field::required("y", STRING).unwrap(),
1012 Field::required("x", STRING).unwrap(),
1013 ])
1014 .unwrap();
1015 assert_eq!(r1, r2, "declaration order must not affect Record equality");
1016
1017 let r3 = Record::new(vec![Field::required("x", STRING).unwrap()]).unwrap();
1018 assert_ne!(
1019 r1, r3,
1020 "a different field count must never compare equal, regardless of order"
1021 );
1022 }
1023
1024 fn obj(pairs: &[(&str, Value)]) -> Value {
1025 let mut m = Map::new();
1026 for (k, v) in pairs {
1027 m.insert((*k).to_string(), v.clone());
1028 }
1029 Value::Object(m)
1030 }
1031
1032 #[test]
1035 fn scalar_construction_and_equality() {
1036 let a = Scalar::new(ScalarKind::String, false);
1037 let b = Scalar::new(ScalarKind::String, false);
1038 assert_eq!(a, b);
1039 assert_ne!(a, Scalar::new(ScalarKind::String, true));
1040 assert_eq!(a, STRING);
1041 }
1042
1043 #[test]
1044 fn scalar_nullable_flag() {
1045 assert!(!STRING.is_nullable());
1046 let n = nullable(STRING);
1047 assert!(n.is_nullable());
1048 assert_eq!(n.kind(), ScalarKind::String);
1049 }
1050
1051 #[test]
1052 fn scalar_named_accepts_every_known_name() {
1053 for k in ScalarKind::ALL {
1054 assert_eq!(Scalar::named(k.as_str(), false).unwrap().kind(), k);
1055 }
1056 }
1057
1058 #[test]
1059 fn scalar_named_rejects_unknown_name() {
1060 let err = Scalar::named("bogus", false).unwrap_err();
1061 assert!(err.to_string().contains("unknown scalar"));
1062 assert!(err.to_string().contains("bogus"));
1063 }
1064
1065 #[test]
1066 fn scalar_display_shows_nullable_suffix() {
1067 assert_eq!(STRING.to_string(), "string");
1068 assert_eq!(nullable(STRING).to_string(), "string?");
1069 }
1070
1071 #[test]
1074 fn field_rejects_max_less_than_min() {
1075 let err = Field::new("a", STRING, 2, Some(1)).unwrap_err();
1076 assert!(err.to_string().contains("invalid cardinality"));
1077 assert!(err.to_string().contains("[2,1]"));
1078 }
1079
1080 #[test]
1081 fn field_accepts_max_equal_to_min_and_unbounded_max() {
1082 assert!(Field::new("a", STRING, 1, Some(1)).is_ok());
1083 assert!(Field::new("a", STRING, 0, None).is_ok());
1084 }
1085
1086 #[test]
1087 fn field_cardinality_str_matches_python_phrasing() {
1088 assert_eq!(
1089 Field::new("a", STRING, 1, Some(1))
1090 .unwrap()
1091 .cardinality_str(),
1092 "exactly 1"
1093 );
1094 assert_eq!(
1095 Field::new("a", STRING, 0, Some(1))
1096 .unwrap()
1097 .cardinality_str(),
1098 "0 or 1"
1099 );
1100 assert_eq!(
1101 Field::new("a", STRING, 1, None).unwrap().cardinality_str(),
1102 "at least 1"
1103 );
1104 assert_eq!(
1105 Field::new("a", STRING, 2, Some(5))
1106 .unwrap()
1107 .cardinality_str(),
1108 "between 2 and 5"
1109 );
1110 }
1111
1112 #[test]
1115 fn record_rejects_duplicate_field_label() {
1116 let a1 = Field::required("a", STRING).unwrap();
1117 let a2 = Field::required("a", INTEGER).unwrap();
1118 let err = Record::new(vec![a1, a2]).unwrap_err();
1119 assert!(err.to_string().contains("duplicate field label"));
1120 assert!(err.to_string().contains("\"a\""));
1121 }
1122
1123 #[test]
1124 fn record_field_lookup_and_ordering() {
1125 let f_b = Field::required("b", STRING).unwrap();
1126 let f_a = Field::required("a", INTEGER).unwrap();
1127 let rec = Record::new(vec![f_b.clone(), f_a.clone()]).unwrap();
1128 assert_eq!(rec.fields(), &[f_b, f_a]);
1129 assert_eq!(rec.field("a").unwrap().label, "a");
1130 assert!(rec.field("missing").is_none());
1131 }
1132
1133 #[test]
1136 fn schema_rejects_unknown_root_ref() {
1137 let err = Schema::new(Ref::new("Missing"), Map::new()).unwrap_err();
1138 assert!(err.to_string().contains("unknown type"));
1139 assert!(err.to_string().contains("Missing"));
1140 }
1141
1142 #[test]
1143 fn schema_rejects_unknown_field_ref() {
1144 let mut env = Map::new();
1145 env.insert(
1146 "Root".to_string(),
1147 Record::new(vec![Field::required("x", Ref::new("Missing")).unwrap()]).unwrap(),
1148 );
1149 let err = Schema::new(Ref::new("Root"), env).unwrap_err();
1150 assert!(err.to_string().contains("unknown type"));
1151 assert!(err.to_string().contains("Missing"));
1152 }
1153
1154 #[test]
1155 fn schema_rejects_a_record_named_after_a_scalar_keyword() {
1156 let mut env = Map::new();
1160 env.insert(
1161 "Root".to_string(),
1162 Record::new(vec![Field::required("x", STRING).unwrap()]).unwrap(),
1163 );
1164 env.insert("string".to_string(), Record::new(vec![]).unwrap());
1165 let err = Schema::new(Ref::new("Root"), env).unwrap_err();
1166 assert!(err.to_string().contains("reserved scalar name"));
1167 assert!(err.to_string().contains("\"string\""));
1168 }
1169
1170 #[test]
1171 fn schema_rejects_a_record_named_any() {
1172 let mut env = Map::new();
1174 env.insert(
1175 "Root".to_string(),
1176 Record::new(vec![Field::required("x", STRING).unwrap()]).unwrap(),
1177 );
1178 env.insert("any".to_string(), Record::new(vec![]).unwrap());
1179 let err = Schema::new(Ref::new("Root"), env).unwrap_err();
1180 assert!(err.to_string().contains("reserved type name"));
1181 }
1182
1183 #[test]
1184 fn schema_accepts_a_valid_self_referential_environment() {
1185 let mut env = Map::new();
1186 env.insert(
1187 "Node".to_string(),
1188 Record::new(vec![
1189 Field::required("value", STRING).unwrap(),
1190 Field::new("child", Ref::new("Node"), 0, Some(1)).unwrap(),
1191 ])
1192 .unwrap(),
1193 );
1194 assert!(Schema::new(Ref::new("Node"), env).is_ok());
1195 }
1196
1197 fn service_schema() -> Schema {
1198 let mut env = Map::new();
1199 env.insert(
1200 "Database".to_string(),
1201 Record::new(vec![
1202 Field::required("type", STRING).unwrap(),
1203 Field::required("server", STRING).unwrap(),
1204 Field::required("port", INTEGER).unwrap(),
1205 ])
1206 .unwrap(),
1207 );
1208 env.insert(
1209 "Service".to_string(),
1210 Record::new(vec![
1211 Field::required("host", STRING).unwrap(),
1212 Field::required("port", INTEGER).unwrap(),
1213 Field::new("databases", Ref::new("Database"), 1, None).unwrap(),
1214 Field::new("tags", STRING, 0, None).unwrap(),
1215 ])
1216 .unwrap(),
1217 );
1218 Schema::new(Ref::new("Service"), env).unwrap()
1219 }
1220
1221 fn valid_service_doc() -> Value {
1222 obj(&[
1223 ("host", Value::Str("api.internal".into())),
1224 ("port", Value::Int((8443).into())),
1225 (
1226 "databases",
1227 Value::Array(vec![obj(&[
1228 ("type", Value::Str("prod".into())),
1229 ("server", Value::Str("db1".into())),
1230 ("port", Value::Int((5432).into())),
1231 ])]),
1232 ),
1233 (
1234 "tags",
1235 Value::Array(vec![
1236 Value::Str("prod".into()),
1237 Value::Str("us-east".into()),
1238 ]),
1239 ),
1240 ])
1241 }
1242
1243 #[test]
1246 fn cardinality_semantics_worked_example() {
1247 let schema = service_schema();
1248 let doc = Doc::of(&valid_service_doc()).unwrap();
1249 let res = schema.validate(&doc.root());
1250 assert!(res.ok(), "{res}");
1251 }
1252
1253 #[test]
1254 fn cardinality_rejects_too_few_databases() {
1255 let schema = service_schema();
1256 let v = obj(&[
1257 ("host", Value::Str("h".into())),
1258 ("port", Value::Int((1).into())),
1259 ]);
1260 let doc = Doc::of(&v).unwrap();
1261 let res = schema.validate(&doc.root());
1262 assert!(!res.ok());
1263 assert!(
1264 res.errors()
1265 .iter()
1266 .any(|e| e.code == ErrorCode::Cardinality && e.message.contains("\"databases\""))
1267 );
1268 }
1269
1270 #[test]
1271 fn cardinality_ignores_order() {
1272 let schema = service_schema();
1275 let v = obj(&[
1276 (
1277 "databases",
1278 Value::Array(vec![obj(&[
1279 ("port", Value::Int((1).into())),
1280 ("type", Value::Str("t".into())),
1281 ("server", Value::Str("s".into())),
1282 ])]),
1283 ),
1284 ("port", Value::Int((8443).into())),
1285 ("host", Value::Str("h".into())),
1286 ]);
1287 let doc = Doc::of(&v).unwrap();
1288 assert!(schema.accepts(&doc.root()));
1289 }
1290
1291 #[test]
1294 fn unexpected_field_is_rejected() {
1295 let schema = service_schema();
1296 let mut v = valid_service_doc();
1297 if let Value::Object(m) = &mut v {
1298 m.insert("extra".to_string(), Value::Int((1).into()));
1299 }
1300 let doc = Doc::of(&v).unwrap();
1301 let res = schema.validate(&doc.root());
1302 assert!(!res.ok());
1303 assert!(
1304 res.errors()
1305 .iter()
1306 .any(|e| e.code == ErrorCode::UnexpectedField && e.path.contains("extra"))
1307 );
1308 }
1309
1310 #[test]
1313 fn scalar_expected_but_object_found_is_shape_mismatch() {
1314 let schema = service_schema();
1315 let v = obj(&[
1316 ("host", obj(&[])),
1317 ("port", Value::Int((1).into())),
1318 (
1319 "databases",
1320 Value::Array(vec![obj(&[
1321 ("type", Value::Str("t".into())),
1322 ("server", Value::Str("s".into())),
1323 ("port", Value::Int((1).into())),
1324 ])]),
1325 ),
1326 ]);
1327 let doc = Doc::of(&v).unwrap();
1328 let res = schema.validate(&doc.root());
1329 assert!(
1330 res.errors()
1331 .iter()
1332 .any(|e| e.code == ErrorCode::ShapeMismatch)
1333 );
1334 }
1335
1336 #[test]
1337 fn record_expected_but_scalar_found_is_shape_mismatch() {
1338 let mut env = Map::new();
1339 env.insert("Root".to_string(), Record::new(vec![]).unwrap());
1340 let schema = Schema::new(Ref::new("Root"), env).unwrap();
1341 let doc = Doc::of(&Value::Int((1).into())).unwrap();
1342 let res = schema.validate(&doc.root());
1343 assert!(!res.ok());
1344 assert_eq!(res.errors()[0].code, ErrorCode::ShapeMismatch);
1345 }
1346
1347 #[test]
1348 fn type_mismatch_reports_expected_and_actual() {
1349 let schema = service_schema();
1350 let mut v = valid_service_doc();
1351 if let Value::Object(m) = &mut v {
1352 m.insert("port".to_string(), Value::Str("not a number".into()));
1353 }
1354 let doc = Doc::of(&v).unwrap();
1355 let res = schema.validate(&doc.root());
1356 let e = res
1357 .errors()
1358 .iter()
1359 .find(|e| e.code == ErrorCode::TypeMismatch)
1360 .unwrap();
1361 assert!(e.message.contains("expected integer"));
1362 assert!(e.message.contains("got string"));
1363 }
1364
1365 #[test]
1366 fn null_rejected_for_non_nullable_scalar_but_accepted_when_nullable() {
1367 let mut env = Map::new();
1368 env.insert(
1369 "Root".to_string(),
1370 Record::new(vec![Field::required("v", STRING).unwrap()]).unwrap(),
1371 );
1372 let schema = Schema::new(Ref::new("Root"), env).unwrap();
1373 let doc = Doc::of(&obj(&[("v", Value::Null)])).unwrap();
1374 let res = schema.validate(&doc.root());
1375 assert!(!res.ok());
1376 assert_eq!(res.errors()[0].code, ErrorCode::NullNotAllowed);
1377
1378 let mut env2 = Map::new();
1379 env2.insert(
1380 "Root".to_string(),
1381 Record::new(vec![Field::required("v", nullable(STRING)).unwrap()]).unwrap(),
1382 );
1383 let schema2 = Schema::new(Ref::new("Root"), env2).unwrap();
1384 let doc2 = Doc::of(&obj(&[("v", Value::Null)])).unwrap();
1385 assert!(schema2.accepts(&doc2.root()));
1386 }
1387
1388 #[test]
1389 fn accepts_and_validation_result_display() {
1390 let schema = service_schema();
1391 let doc = Doc::of(&valid_service_doc()).unwrap();
1392 assert!(schema.accepts(&doc.root()));
1393 assert_eq!(schema.validate(&doc.root()).to_string(), "valid");
1394
1395 let bad = Doc::of(&obj(&[])).unwrap();
1396 let res = schema.validate(&bad.root());
1397 assert!(!res.ok());
1398 let s = res.to_string();
1399 assert!(s.starts_with("invalid:\n at "));
1400 }
1401
1402 #[test]
1405 fn bool_never_satisfies_integer_or_number() {
1406 assert!(!matches_kind(&DocScalar::Bool(true), ScalarKind::Integer));
1407 assert!(!matches_kind(&DocScalar::Bool(true), ScalarKind::Number));
1408 assert!(matches_kind(&DocScalar::Bool(true), ScalarKind::Boolean));
1409 }
1410
1411 #[test]
1412 fn integer_satisfies_number_but_not_vice_versa() {
1413 assert!(matches_kind(
1414 &DocScalar::Int((3).into()),
1415 ScalarKind::Number
1416 ));
1417 assert!(!matches_kind(&DocScalar::Float(3.0), ScalarKind::Integer));
1418 }
1419
1420 #[test]
1423 fn is_iso_date_accepts_valid_dates() {
1424 assert!(is_iso_date("2024-01-01"));
1425 assert!(is_iso_date("9999-12-31"));
1426 }
1427
1428 #[test]
1429 fn is_iso_date_rejects_wrong_shape_and_invalid_calendar_dates() {
1430 assert!(!is_iso_date("20240101"));
1433 assert!(!is_iso_date("2024-1-1"));
1434 assert!(!is_iso_date("2024-W01-1"));
1435 assert!(!is_iso_date("not-a-date"));
1436 assert!(!is_iso_date("2024-13-01"));
1438 assert!(!is_iso_date("2024-02-30"));
1439 assert!(!is_iso_date("2024-00-01"));
1440 assert!(!is_iso_date("2024-01-00"));
1441 assert!(!is_iso_date("0000-01-01"));
1442 }
1443
1444 #[test]
1445 fn is_iso_date_thirty_day_months() {
1446 assert!(is_iso_date("2024-04-30"));
1447 assert!(!is_iso_date("2024-04-31"));
1448 assert!(is_iso_date("2024-06-30"));
1449 assert!(is_iso_date("2024-09-30"));
1450 assert!(is_iso_date("2024-11-30"));
1451 }
1452
1453 #[test]
1454 fn days_in_month_rejects_an_out_of_range_month_directly() {
1455 assert_eq!(days_in_month(2024, 13), 0);
1460 assert_eq!(days_in_month(2024, 0), 0);
1461 }
1462
1463 #[test]
1464 fn is_iso_date_leap_year_boundary() {
1465 assert!(is_iso_date("2024-02-29")); assert!(!is_iso_date("2023-02-29")); assert!(is_iso_date("2000-02-29")); assert!(!is_iso_date("1900-02-29")); }
1470
1471 #[test]
1474 fn is_iso_time_accepts_valid_times() {
1475 assert!(is_iso_time("12:00:00"));
1476 assert!(is_iso_time("12:00"));
1477 assert!(is_iso_time("12:00:00.5"));
1478 assert!(is_iso_time("12:00:00.123456"));
1479 assert!(is_iso_time("12:00:00+02:00"));
1480 assert!(is_iso_time("23:59:59"));
1481 }
1482
1483 #[test]
1484 fn is_iso_time_rejects_out_of_range_and_malformed() {
1485 assert!(!is_iso_time("25:00:00"));
1486 assert!(!is_iso_time("12:60:00"));
1487 assert!(!is_iso_time("24:00:00"));
1488 assert!(!is_iso_time("12:00:00+24:00"));
1489 assert!(!is_iso_time("12:00:00+99:99"));
1490 assert!(!is_iso_time("12:00:00.1234567")); assert!(!is_iso_time("1:00:00")); assert!(is_iso_time("12:00:00+23:59"));
1493 }
1494
1495 #[test]
1499 fn is_iso_datetime_accepts_valid_timestamps() {
1500 assert!(is_iso_datetime("2024-01-01T12:00:00"));
1501 assert!(is_iso_datetime("2024-01-01T12:00"));
1502 assert!(is_iso_datetime("2024-01-01T12:00:00+02:00"));
1503 assert!(is_iso_datetime("2024-01-01T12:00:00.123456"));
1504 }
1505
1506 #[test]
1507 fn is_iso_datetime_rejects_bare_date_and_invalid_components() {
1508 assert!(!is_iso_datetime("2024-01-01"));
1509 assert!(!is_iso_datetime("2024-01-01T25:00:00"));
1510 assert!(!is_iso_datetime("2024-13-01T12:00:00"));
1511 }
1512
1513 #[test]
1514 fn matches_kind_datetime_excludes_bare_date_string_and_a_real_date() {
1515 let v = DocScalar::Str("2024-01-01".to_string());
1523 assert!(matches_kind(&v, ScalarKind::Date));
1524 assert!(!matches_kind(&v, ScalarKind::Datetime));
1525
1526 let dt = DocScalar::Str("2024-01-01T00:00:00".to_string());
1527 assert!(matches_kind(&dt, ScalarKind::Datetime));
1528 assert!(!matches_kind(&dt, ScalarKind::Date));
1529
1530 let d_real = DocScalar::Date("2024-01-01".to_string());
1531 assert!(matches_kind(&d_real, ScalarKind::Date));
1532 assert!(!matches_kind(&d_real, ScalarKind::Datetime));
1533
1534 let dt_real = DocScalar::Datetime("2024-01-01T00:00:00".to_string());
1535 assert!(matches_kind(&dt_real, ScalarKind::Datetime));
1536 assert!(!matches_kind(&dt_real, ScalarKind::Date));
1537 }
1538
1539 #[test]
1540 fn matches_kind_date_and_time_directly() {
1541 assert!(matches_kind(
1545 &DocScalar::Date("2024-01-01".into()),
1546 ScalarKind::Date
1547 ));
1548 assert!(matches_kind(
1549 &DocScalar::Str("2024-01-01".into()),
1550 ScalarKind::Date
1551 ));
1552 assert!(!matches_kind(
1553 &DocScalar::Str("not-a-date".into()),
1554 ScalarKind::Date
1555 ));
1556 assert!(matches_kind(
1557 &DocScalar::Time("12:00:00".into()),
1558 ScalarKind::Time
1559 ));
1560 assert!(matches_kind(
1561 &DocScalar::Str("12:00:00".into()),
1562 ScalarKind::Time
1563 ));
1564 assert!(!matches_kind(
1565 &DocScalar::Str("25:00:00".into()),
1566 ScalarKind::Time
1567 ));
1568 assert!(!matches_kind(&DocScalar::Int((1).into()), ScalarKind::Date));
1569 assert!(!matches_kind(&DocScalar::Int((1).into()), ScalarKind::Time));
1570 }
1571
1572 #[test]
1573 fn schema_root_and_env_accessors() {
1574 let schema = service_schema();
1575 assert_eq!(schema.root().name, "Service");
1576 assert!(schema.env().contains_key("Database"));
1577 assert!(schema.env().contains_key("Service"));
1578 }
1579
1580 #[test]
1585 fn any_field_accepts_scalars_and_objects_unchecked() {
1586 let mut env = Map::new();
1587 env.insert(
1588 "Root".to_string(),
1589 Record::new(vec![Field::required("x", FieldType::Any).unwrap()]).unwrap(),
1590 );
1591 let schema = Schema::new(Ref::new("Root"), env).unwrap();
1592
1593 for v in [
1599 Value::Str("hi".into()),
1600 Value::Int((1).into()),
1601 Value::Float(1.5),
1602 Value::Bool(true),
1603 Value::Null,
1604 obj(&[("nested", Value::Int((1).into()))]),
1605 ] {
1606 let doc = Doc::of(&obj(&[("x", v.clone())])).unwrap();
1607 assert!(schema.accepts(&doc.root()), "any field rejected {v:?}");
1608 }
1609 }
1610
1611 #[test]
1612 fn any_field_with_array_cardinality_accepts_repeated_values_unchecked() {
1613 let mut env = Map::new();
1614 env.insert(
1615 "Root".to_string(),
1616 Record::new(vec![Field::new("x", FieldType::Any, 0, None).unwrap()]).unwrap(),
1617 );
1618 let schema = Schema::new(Ref::new("Root"), env).unwrap();
1619 let doc = Doc::of(&obj(&[(
1620 "x",
1621 Value::Array(vec![Value::Int((1).into()), Value::Str("mixed".into())]),
1622 )]))
1623 .unwrap();
1624 assert!(schema.accepts(&doc.root()));
1625 }
1626
1627 #[test]
1628 fn any_resolves_without_touching_env() {
1629 let env: Map<String, Record> = Map::new();
1630 let schema = Schema::new(Ref::new("Root"), {
1631 let mut e = env;
1632 e.insert("Root".to_string(), Record::new(vec![]).unwrap());
1633 e
1634 })
1635 .unwrap();
1636 assert!(matches!(schema.resolve(&FieldType::Any), Resolved::Any));
1637 }
1638
1639 #[test]
1640 fn field_type_from_conversions() {
1641 let ft: FieldType = STRING.into();
1642 assert_eq!(ft, FieldType::Scalar(STRING));
1643 let ft2: FieldType = Ref::new("X").into();
1644 assert_eq!(ft2, FieldType::Ref(Ref::new("X")));
1645 }
1646
1647 #[test]
1648 fn ref_display() {
1649 assert_eq!(Ref::new("Foo").to_string(), "ref(Foo)");
1650 }
1651
1652 #[test]
1653 fn error_code_as_str_covers_every_variant() {
1654 assert_eq!(
1655 ErrorCode::UnexpectedField.as_str(ErrorFamily::Validate),
1656 "validate.unexpected-field"
1657 );
1658 assert_eq!(
1659 ErrorCode::UnexpectedField.as_str(ErrorFamily::Materialize),
1660 "materialize.unexpected-field"
1661 );
1662 assert_eq!(
1663 ErrorCode::Cardinality.as_str(ErrorFamily::Validate),
1664 "validate.cardinality"
1665 );
1666 assert_eq!(
1667 ErrorCode::Cardinality.as_str(ErrorFamily::Materialize),
1668 "materialize.cardinality"
1669 );
1670 assert_eq!(
1671 ErrorCode::TypeMismatch.as_str(ErrorFamily::Validate),
1672 "validate.type-mismatch"
1673 );
1674 assert_eq!(
1675 ErrorCode::TypeMismatch.as_str(ErrorFamily::Materialize),
1676 "materialize.inexact-conversion"
1677 );
1678 assert_eq!(
1679 ErrorCode::NullNotAllowed.as_str(ErrorFamily::Validate),
1680 "validate.null-not-allowed"
1681 );
1682 assert_eq!(
1683 ErrorCode::NullNotAllowed.as_str(ErrorFamily::Materialize),
1684 "materialize.null-not-allowed"
1685 );
1686 assert_eq!(
1687 ErrorCode::ShapeMismatch.as_str(ErrorFamily::Validate),
1688 "validate.shape-mismatch"
1689 );
1690 assert_eq!(
1691 ErrorCode::ShapeMismatch.as_str(ErrorFamily::Materialize),
1692 "materialize.shape-mismatch"
1693 );
1694 }
1695
1696 #[test]
1697 fn value_kind_name_covers_every_variant() {
1698 assert_eq!(value_kind_name(&DocScalar::Null), "null");
1699 assert_eq!(value_kind_name(&DocScalar::Bool(true)), "boolean");
1700 assert_eq!(value_kind_name(&DocScalar::Int((1).into())), "integer");
1701 assert_eq!(value_kind_name(&DocScalar::Float(1.0)), "number");
1702 assert_eq!(value_kind_name(&DocScalar::Str("x".into())), "string");
1703 assert_eq!(
1704 value_kind_name(&DocScalar::Date("2024-01-01".into())),
1705 "date"
1706 );
1707 assert_eq!(value_kind_name(&DocScalar::Time("12:00:00".into())), "time");
1708 assert_eq!(
1709 value_kind_name(&DocScalar::Datetime("2024-01-01T12:00:00".into())),
1710 "datetime"
1711 );
1712 }
1713}