1use std::collections::HashMap;
46use std::sync::LazyLock;
47
48use serde_json::Value;
49
50use crate::atlassian::adf_schema::AdfSchemaViolation;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum AttrPresence {
59 Required,
61 Optional,
63}
64
65#[derive(Debug, Clone, PartialEq)]
67pub enum AttrType {
68 Enum(&'static [&'static str]),
70 IntRange(i64, i64),
72 NumRange(f64, f64),
74 Bool,
76 String,
78 Url,
80 Object,
82 Free,
84}
85
86#[derive(Debug, Clone, PartialEq)]
89pub enum AttrProblem {
90 NotInEnum {
92 allowed: Vec<&'static str>,
94 actual: String,
96 },
97 OutOfRange {
99 lo: i64,
101 hi: i64,
103 actual: i64,
105 },
106 OutOfRangeF {
108 lo: f64,
110 hi: f64,
112 actual: f64,
114 },
115 WrongType {
117 expected: &'static str,
119 },
120 BadFormat {
122 reason: &'static str,
124 },
125}
126
127impl std::fmt::Display for AttrProblem {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 match self {
130 Self::NotInEnum { allowed, actual } => {
131 let allowed_str = allowed
132 .iter()
133 .map(|a| format!("'{a}'"))
134 .collect::<Vec<_>>()
135 .join(", ");
136 write!(
137 f,
138 "value '{actual}' is not in the allowed set ({allowed_str})"
139 )
140 }
141 Self::OutOfRange { lo, hi, actual } => {
142 write!(
143 f,
144 "value {actual} is outside the allowed range [{lo}, {hi}]"
145 )
146 }
147 Self::OutOfRangeF { lo, hi, actual } => {
148 write!(
149 f,
150 "value {actual} is outside the allowed range [{lo}, {hi}]"
151 )
152 }
153 Self::WrongType { expected } => {
154 write!(f, "value has wrong type (expected {expected})")
155 }
156 Self::BadFormat { reason } => write!(f, "{reason}"),
157 }
158 }
159}
160
161#[derive(Debug, Clone)]
163pub struct AttrSchema {
164 pub fields: &'static [(&'static str, AttrType, AttrPresence)],
167}
168
169const ENUM_PANEL_TYPE: &[&str] = &["info", "note", "warning", "success", "error", "custom"];
174
175const ENUM_TASK_STATE: &[&str] = &["TODO", "DONE"];
176
177const ENUM_DECISION_STATE: &[&str] = &["DECIDED", "UNDECIDED"];
178
179const ENUM_MEDIA_TYPE: &[&str] = &["file", "link", "external"];
180
181const ENUM_MEDIA_SINGLE_LAYOUT: &[&str] = &[
182 "align-end",
183 "align-start",
184 "center",
185 "full-width",
186 "wide",
187 "wrap-left",
188 "wrap-right",
189];
190
191const ENUM_STATUS_COLOR: &[&str] = &["neutral", "purple", "blue", "red", "yellow", "green"];
192
193const ENUM_MENTION_USER_TYPE: &[&str] = &["DEFAULT", "SPECIAL", "APP", "TEAM"];
194
195const ENUM_EXTENSION_LAYOUT: &[&str] = &["default", "wide", "full-width"];
196
197type AttrEntry = (&'static str, AttrSchema);
200
201const ATTR_ENTRIES: &[AttrEntry] = &[
202 (
207 "blockCard",
208 AttrSchema {
209 fields: &[
210 ("url", AttrType::Url, AttrPresence::Optional),
211 ("data", AttrType::Object, AttrPresence::Optional),
212 ],
213 },
214 ),
215 (
217 "bodiedExtension",
218 AttrSchema {
219 fields: &[
220 ("extensionType", AttrType::String, AttrPresence::Required),
221 ("extensionKey", AttrType::String, AttrPresence::Required),
222 (
223 "layout",
224 AttrType::Enum(ENUM_EXTENSION_LAYOUT),
225 AttrPresence::Optional,
226 ),
227 ("parameters", AttrType::Object, AttrPresence::Optional),
228 ("text", AttrType::String, AttrPresence::Optional),
229 ],
230 },
231 ),
232 (
235 "codeBlock",
236 AttrSchema {
237 fields: &[("language", AttrType::String, AttrPresence::Optional)],
238 },
239 ),
240 (
243 "date",
244 AttrSchema {
245 fields: &[("timestamp", AttrType::String, AttrPresence::Required)],
246 },
247 ),
248 (
250 "decisionItem",
251 AttrSchema {
252 fields: &[
253 ("localId", AttrType::String, AttrPresence::Required),
254 (
255 "state",
256 AttrType::Enum(ENUM_DECISION_STATE),
257 AttrPresence::Required,
258 ),
259 ],
260 },
261 ),
262 (
264 "decisionList",
265 AttrSchema {
266 fields: &[("localId", AttrType::String, AttrPresence::Required)],
267 },
268 ),
269 (
271 "embedCard",
272 AttrSchema {
273 fields: &[
274 ("url", AttrType::Url, AttrPresence::Required),
275 (
276 "layout",
277 AttrType::Enum(ENUM_EXTENSION_LAYOUT),
278 AttrPresence::Optional,
279 ),
280 (
281 "width",
282 AttrType::NumRange(0.0, 100.0),
283 AttrPresence::Optional,
284 ),
285 (
286 "originalHeight",
287 AttrType::NumRange(0.0, f64::MAX),
288 AttrPresence::Optional,
289 ),
290 (
291 "originalWidth",
292 AttrType::NumRange(0.0, f64::MAX),
293 AttrPresence::Optional,
294 ),
295 ],
296 },
297 ),
298 (
300 "emoji",
301 AttrSchema {
302 fields: &[
303 ("shortName", AttrType::String, AttrPresence::Required),
304 ("id", AttrType::String, AttrPresence::Optional),
305 ("text", AttrType::String, AttrPresence::Optional),
306 ],
307 },
308 ),
309 (
311 "expand",
312 AttrSchema {
313 fields: &[("title", AttrType::String, AttrPresence::Optional)],
314 },
315 ),
316 (
318 "extension",
319 AttrSchema {
320 fields: &[
321 ("extensionType", AttrType::String, AttrPresence::Required),
322 ("extensionKey", AttrType::String, AttrPresence::Required),
323 (
324 "layout",
325 AttrType::Enum(ENUM_EXTENSION_LAYOUT),
326 AttrPresence::Optional,
327 ),
328 ("parameters", AttrType::Object, AttrPresence::Optional),
329 ("text", AttrType::String, AttrPresence::Optional),
330 ],
331 },
332 ),
333 (
336 "heading",
337 AttrSchema {
338 fields: &[("level", AttrType::IntRange(1, 6), AttrPresence::Required)],
339 },
340 ),
341 (
343 "inlineCard",
344 AttrSchema {
345 fields: &[
346 ("url", AttrType::Url, AttrPresence::Optional),
347 ("data", AttrType::Object, AttrPresence::Optional),
348 ],
349 },
350 ),
351 (
354 "layoutColumn",
355 AttrSchema {
356 fields: &[(
357 "width",
358 AttrType::NumRange(0.0, 100.0),
359 AttrPresence::Required,
360 )],
361 },
362 ),
363 (
365 "media",
366 AttrSchema {
367 fields: &[
368 (
369 "type",
370 AttrType::Enum(ENUM_MEDIA_TYPE),
371 AttrPresence::Required,
372 ),
373 ("id", AttrType::String, AttrPresence::Optional),
374 ("collection", AttrType::String, AttrPresence::Optional),
375 ("url", AttrType::String, AttrPresence::Optional),
376 ("alt", AttrType::String, AttrPresence::Optional),
377 (
378 "width",
379 AttrType::NumRange(0.0, f64::MAX),
380 AttrPresence::Optional,
381 ),
382 (
383 "height",
384 AttrType::NumRange(0.0, f64::MAX),
385 AttrPresence::Optional,
386 ),
387 ("occurrenceKey", AttrType::String, AttrPresence::Optional),
388 ],
389 },
390 ),
391 (
393 "mediaSingle",
394 AttrSchema {
395 fields: &[
396 (
397 "layout",
398 AttrType::Enum(ENUM_MEDIA_SINGLE_LAYOUT),
399 AttrPresence::Optional,
400 ),
401 (
402 "width",
403 AttrType::NumRange(0.0, 100.0),
404 AttrPresence::Optional,
405 ),
406 ("widthType", AttrType::String, AttrPresence::Optional),
407 ],
408 },
409 ),
410 (
412 "mention",
413 AttrSchema {
414 fields: &[
415 ("id", AttrType::String, AttrPresence::Required),
416 ("text", AttrType::String, AttrPresence::Optional),
417 (
418 "userType",
419 AttrType::Enum(ENUM_MENTION_USER_TYPE),
420 AttrPresence::Optional,
421 ),
422 ("accessLevel", AttrType::String, AttrPresence::Optional),
423 ],
424 },
425 ),
426 (
428 "nestedExpand",
429 AttrSchema {
430 fields: &[("title", AttrType::String, AttrPresence::Optional)],
431 },
432 ),
433 (
436 "orderedList",
437 AttrSchema {
438 fields: &[(
439 "order",
440 AttrType::IntRange(0, i64::MAX),
441 AttrPresence::Optional,
442 )],
443 },
444 ),
445 (
448 "panel",
449 AttrSchema {
450 fields: &[(
451 "panelType",
452 AttrType::Enum(ENUM_PANEL_TYPE),
453 AttrPresence::Required,
454 )],
455 },
456 ),
457 (
459 "status",
460 AttrSchema {
461 fields: &[
462 ("text", AttrType::String, AttrPresence::Required),
463 (
464 "color",
465 AttrType::Enum(ENUM_STATUS_COLOR),
466 AttrPresence::Required,
467 ),
468 ("localId", AttrType::String, AttrPresence::Optional),
469 ("style", AttrType::String, AttrPresence::Optional),
470 ],
471 },
472 ),
473 (
475 "taskItem",
476 AttrSchema {
477 fields: &[
478 ("localId", AttrType::String, AttrPresence::Required),
479 (
480 "state",
481 AttrType::Enum(ENUM_TASK_STATE),
482 AttrPresence::Required,
483 ),
484 ],
485 },
486 ),
487 (
489 "taskList",
490 AttrSchema {
491 fields: &[("localId", AttrType::String, AttrPresence::Required)],
492 },
493 ),
494];
495
496static ATTR_SCHEMAS: LazyLock<HashMap<&'static str, &'static AttrSchema>> = LazyLock::new(|| {
497 ATTR_ENTRIES
498 .iter()
499 .map(|(node_type, schema)| (*node_type, schema))
500 .collect()
501});
502
503#[must_use]
505pub fn attr_schema(node_type: &str) -> Option<&'static AttrSchema> {
506 ATTR_SCHEMAS.get(node_type).copied()
507}
508
509pub fn validate_attrs(
524 node_type: &str,
525 attrs: Option<&Value>,
526 path: &[usize],
527 out: &mut Vec<AdfSchemaViolation>,
528) {
529 let Some(schema) = attr_schema(node_type) else {
530 return;
531 };
532
533 let attr_obj = match attrs {
535 Some(Value::Object(map)) => Some(map),
536 Some(Value::Null) | None => None,
537 Some(_other) => {
538 for (field, _ty, presence) in schema.fields {
543 if *presence == AttrPresence::Required {
544 out.push(AdfSchemaViolation::MissingAttr {
545 node_type: node_type.to_string(),
546 attr_name: (*field).to_string(),
547 path: path.to_vec(),
548 });
549 }
550 }
551 return;
552 }
553 };
554
555 for (field, ty, presence) in schema.fields {
556 let value = attr_obj.and_then(|m| m.get(*field));
557
558 let value = match value {
560 Some(Value::Null) | None => None,
561 Some(v) => Some(v),
562 };
563
564 match (value, *presence) {
565 (None, AttrPresence::Required) => {
566 out.push(AdfSchemaViolation::MissingAttr {
567 node_type: node_type.to_string(),
568 attr_name: (*field).to_string(),
569 path: path.to_vec(),
570 });
571 }
572 (None, AttrPresence::Optional) => {
573 }
575 (Some(v), _) => {
576 if let Some(problem) = check_value(ty, v) {
577 out.push(AdfSchemaViolation::InvalidAttr {
578 node_type: node_type.to_string(),
579 attr_name: (*field).to_string(),
580 problem,
581 path: path.to_vec(),
582 });
583 }
584 }
585 }
586 }
587}
588
589#[must_use]
595pub fn check_value(ty: &AttrType, value: &Value) -> Option<AttrProblem> {
596 match ty {
597 AttrType::Enum(allowed) => match value.as_str() {
598 Some(s) if allowed.contains(&s) => None,
599 Some(s) => Some(AttrProblem::NotInEnum {
600 allowed: allowed.to_vec(),
601 actual: s.to_string(),
602 }),
603 None => Some(AttrProblem::WrongType { expected: "string" }),
604 },
605 AttrType::IntRange(lo, hi) => match value.as_i64() {
606 Some(n) if n >= *lo && n <= *hi => None,
607 Some(n) => Some(AttrProblem::OutOfRange {
608 lo: *lo,
609 hi: *hi,
610 actual: n,
611 }),
612 None => Some(AttrProblem::WrongType {
613 expected: "integer",
614 }),
615 },
616 AttrType::NumRange(lo, hi) => match value.as_f64() {
617 Some(n) if n >= *lo && n <= *hi => None,
618 Some(n) => Some(AttrProblem::OutOfRangeF {
619 lo: *lo,
620 hi: *hi,
621 actual: n,
622 }),
623 None => Some(AttrProblem::WrongType { expected: "number" }),
624 },
625 AttrType::Bool => match value.as_bool() {
626 Some(_) => None,
627 None => Some(AttrProblem::WrongType { expected: "bool" }),
628 },
629 AttrType::String => match value.as_str() {
630 Some(_) => None,
631 None => Some(AttrProblem::WrongType { expected: "string" }),
632 },
633 AttrType::Url => match value.as_str() {
634 Some(s) => match url::Url::parse(s) {
635 Ok(_) => None,
636 Err(_) => Some(AttrProblem::BadFormat {
637 reason: "not a valid URL",
638 }),
639 },
640 None => Some(AttrProblem::WrongType { expected: "string" }),
641 },
642 AttrType::Object => match value {
643 Value::Object(_) => None,
644 _ => Some(AttrProblem::WrongType { expected: "object" }),
645 },
646 AttrType::Free => None,
647 }
648}
649
650#[cfg(test)]
651#[allow(clippy::unwrap_used, clippy::expect_used)]
652mod tests {
653 use super::*;
654 use serde_json::json;
655
656 fn run(node_type: &str, attrs: Value) -> Vec<AdfSchemaViolation> {
657 let mut out = Vec::new();
658 validate_attrs(node_type, Some(&attrs), &[], &mut out);
659 out
660 }
661
662 fn run_no_attrs(node_type: &str) -> Vec<AdfSchemaViolation> {
663 let mut out = Vec::new();
664 validate_attrs(node_type, None, &[], &mut out);
665 out
666 }
667
668 #[test]
669 fn panel_panel_type_known_value_validates() {
670 for value in ENUM_PANEL_TYPE {
671 assert!(
672 run("panel", json!({ "panelType": value })).is_empty(),
673 "panelType '{value}' should validate"
674 );
675 }
676 }
677
678 #[test]
679 fn panel_panel_type_unknown_value_flagged() {
680 let v = run("panel", json!({ "panelType": "purple" }));
681 assert_eq!(v.len(), 1);
682 match &v[0] {
683 AdfSchemaViolation::InvalidAttr {
684 node_type,
685 attr_name,
686 problem,
687 ..
688 } => {
689 assert_eq!(node_type, "panel");
690 assert_eq!(attr_name, "panelType");
691 assert!(matches!(problem, AttrProblem::NotInEnum { .. }));
692 }
693 other => panic!("expected InvalidAttr, got {other:?}"),
694 }
695 }
696
697 #[test]
698 fn panel_missing_panel_type_flagged() {
699 let v = run("panel", json!({}));
700 assert_eq!(v.len(), 1);
701 match &v[0] {
702 AdfSchemaViolation::MissingAttr {
703 node_type,
704 attr_name,
705 ..
706 } => {
707 assert_eq!(node_type, "panel");
708 assert_eq!(attr_name, "panelType");
709 }
710 other => panic!("expected MissingAttr, got {other:?}"),
711 }
712 }
713
714 #[test]
715 fn panel_missing_attrs_object_flagged() {
716 let v = run_no_attrs("panel");
717 assert_eq!(v.len(), 1);
718 assert!(matches!(v[0], AdfSchemaViolation::MissingAttr { .. }));
719 }
720
721 #[test]
722 fn heading_level_in_range_validates() {
723 for level in 1_i64..=6 {
724 assert!(run("heading", json!({ "level": level })).is_empty());
725 }
726 }
727
728 #[test]
729 fn heading_level_out_of_range_flagged() {
730 let v = run("heading", json!({ "level": 7 }));
731 assert_eq!(v.len(), 1);
732 match &v[0] {
733 AdfSchemaViolation::InvalidAttr {
734 attr_name, problem, ..
735 } => {
736 assert_eq!(attr_name, "level");
737 assert!(matches!(
738 problem,
739 AttrProblem::OutOfRange {
740 lo: 1,
741 hi: 6,
742 actual: 7
743 }
744 ));
745 }
746 other => panic!("expected InvalidAttr, got {other:?}"),
747 }
748 }
749
750 #[test]
751 fn heading_level_wrong_type_flagged() {
752 let v = run("heading", json!({ "level": "two" }));
753 assert_eq!(v.len(), 1);
754 match &v[0] {
755 AdfSchemaViolation::InvalidAttr { problem, .. } => {
756 assert!(matches!(
757 problem,
758 AttrProblem::WrongType {
759 expected: "integer"
760 }
761 ));
762 }
763 other => panic!("expected InvalidAttr, got {other:?}"),
764 }
765 }
766
767 #[test]
768 fn heading_missing_level_flagged_as_missing() {
769 let v = run("heading", json!({}));
770 assert_eq!(v.len(), 1);
771 assert!(
772 matches!(&v[0], AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "level")
773 );
774 }
775
776 #[test]
777 fn task_item_known_state_validates() {
778 for state in ENUM_TASK_STATE {
779 assert!(run("taskItem", json!({ "localId": "abc", "state": state })).is_empty());
780 }
781 }
782
783 #[test]
784 fn task_item_unknown_state_flagged() {
785 let v = run(
786 "taskItem",
787 json!({ "localId": "abc", "state": "INPROGRESS" }),
788 );
789 assert_eq!(v.len(), 1);
790 match &v[0] {
791 AdfSchemaViolation::InvalidAttr { attr_name, .. } => {
792 assert_eq!(attr_name, "state");
793 }
794 other => panic!("expected InvalidAttr, got {other:?}"),
795 }
796 }
797
798 #[test]
799 fn media_single_layout_known_validates() {
800 assert!(run("mediaSingle", json!({ "layout": "center" })).is_empty());
801 assert!(run("mediaSingle", json!({ "layout": "wide" })).is_empty());
802 }
803
804 #[test]
805 fn media_single_layout_misspelled_flagged() {
806 let v = run("mediaSingle", json!({ "layout": "centre" }));
807 assert_eq!(v.len(), 1);
808 assert!(matches!(
809 &v[0],
810 AdfSchemaViolation::InvalidAttr { attr_name, .. } if attr_name == "layout"
811 ));
812 }
813
814 #[test]
815 fn media_type_required() {
816 let v = run("media", json!({}));
817 assert_eq!(v.len(), 1);
818 assert!(matches!(
819 &v[0],
820 AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "type"
821 ));
822 }
823
824 #[test]
825 fn embed_card_url_format() {
826 assert!(run("embedCard", json!({ "url": "https://example.com" })).is_empty());
827 let v = run("embedCard", json!({ "url": "not a url" }));
828 assert_eq!(v.len(), 1);
829 match &v[0] {
830 AdfSchemaViolation::InvalidAttr { problem, .. } => {
831 assert!(matches!(problem, AttrProblem::BadFormat { .. }));
832 }
833 other => panic!("expected InvalidAttr, got {other:?}"),
834 }
835 }
836
837 #[test]
838 fn layout_column_width_in_range() {
839 assert!(run("layoutColumn", json!({ "width": 33.3 })).is_empty());
840 let v = run("layoutColumn", json!({ "width": 150 }));
841 assert_eq!(v.len(), 1);
842 assert!(matches!(
843 &v[0],
844 AdfSchemaViolation::InvalidAttr {
845 problem: AttrProblem::OutOfRangeF { .. },
846 ..
847 }
848 ));
849 }
850
851 #[test]
852 fn ordered_list_order_optional() {
853 assert!(run("orderedList", json!({})).is_empty());
854 assert!(run("orderedList", json!({ "order": 5 })).is_empty());
855 let v = run("orderedList", json!({ "order": -1 }));
857 assert_eq!(v.len(), 1);
858 }
859
860 #[test]
861 fn unknown_node_type_is_permissive() {
862 assert!(run("madeUpNode", json!({ "anyField": "anyValue" })).is_empty());
863 }
864
865 #[test]
866 fn unknown_field_under_known_node_is_permissive() {
867 assert!(run("panel", json!({ "panelType": "info", "futureField": "ok" })).is_empty());
869 }
870
871 #[test]
872 fn null_attribute_treated_as_absent() {
873 assert!(run(
875 "status",
876 json!({ "text": "hi", "color": "blue", "localId": null })
877 )
878 .is_empty());
879 let v = run("status", json!({ "text": "hi", "color": null }));
881 assert!(matches!(
882 &v[0],
883 AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "color"
884 ));
885 }
886
887 #[test]
888 fn attrs_array_treated_as_invalid_object() {
889 let mut out = Vec::new();
892 validate_attrs("panel", Some(&json!([1, 2, 3])), &[], &mut out);
893 assert_eq!(out.len(), 1);
894 assert!(matches!(
895 &out[0],
896 AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "panelType"
897 ));
898 }
899
900 #[test]
901 fn attr_problem_display_messages() {
902 let p = AttrProblem::NotInEnum {
903 allowed: vec!["info", "note"],
904 actual: "purple".to_string(),
905 };
906 let s = p.to_string();
907 assert!(s.contains("'purple'"), "got: {s}");
908 assert!(s.contains("'info'"), "got: {s}");
909
910 let p = AttrProblem::OutOfRange {
911 lo: 1,
912 hi: 6,
913 actual: 7,
914 };
915 assert!(p.to_string().contains("[1, 6]"));
916
917 let p = AttrProblem::BadFormat {
918 reason: "not a valid URL",
919 };
920 assert_eq!(p.to_string(), "not a valid URL");
921
922 let p = AttrProblem::WrongType {
923 expected: "integer",
924 };
925 assert!(p.to_string().contains("integer"));
926 }
927
928 #[test]
929 fn attr_problem_out_of_range_f_display() {
930 let p = AttrProblem::OutOfRangeF {
934 lo: 0.0,
935 hi: 100.0,
936 actual: 200.0,
937 };
938 let s = p.to_string();
939 assert!(s.contains("200"), "got: {s}");
940 assert!(s.contains("[0, 100]"), "got: {s}");
941 }
942
943 #[test]
953 fn check_value_enum_wrong_type_for_non_string() {
954 let ty = AttrType::Enum(&["a", "b"]);
955 let p = check_value(&ty, &json!(123)).expect("should reject");
956 assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
957 }
958
959 #[test]
960 fn check_value_int_range_wrong_type_for_non_integer() {
961 let ty = AttrType::IntRange(0, 10);
962 let p = check_value(&ty, &json!("abc")).expect("should reject");
963 assert!(matches!(
964 p,
965 AttrProblem::WrongType {
966 expected: "integer"
967 }
968 ));
969 }
970
971 #[test]
972 fn check_value_num_range_wrong_type_for_non_number() {
973 let ty = AttrType::NumRange(0.0, 100.0);
974 let p = check_value(&ty, &json!("abc")).expect("should reject");
975 assert!(matches!(p, AttrProblem::WrongType { expected: "number" }));
976 }
977
978 #[test]
979 fn check_value_bool_arms() {
980 let ty = AttrType::Bool;
981 assert!(check_value(&ty, &json!(true)).is_none());
982 let p = check_value(&ty, &json!("yes")).expect("should reject");
983 assert!(matches!(p, AttrProblem::WrongType { expected: "bool" }));
984 }
985
986 #[test]
987 fn check_value_string_arms() {
988 let ty = AttrType::String;
989 assert!(check_value(&ty, &json!("hi")).is_none());
990 let p = check_value(&ty, &json!(42)).expect("should reject");
991 assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
992 }
993
994 #[test]
995 fn check_value_url_wrong_type_for_non_string() {
996 let ty = AttrType::Url;
997 let p = check_value(&ty, &json!(42)).expect("should reject");
998 assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
999 }
1000
1001 #[test]
1002 fn check_value_object_arms() {
1003 let ty = AttrType::Object;
1004 assert!(check_value(&ty, &json!({"k": "v"})).is_none());
1005 let p = check_value(&ty, &json!([1, 2])).expect("should reject");
1006 assert!(matches!(p, AttrProblem::WrongType { expected: "object" }));
1007 }
1008
1009 #[test]
1010 fn check_value_free_accepts_anything() {
1011 let ty = AttrType::Free;
1012 assert!(check_value(&ty, &json!(null)).is_none());
1013 assert!(check_value(&ty, &json!(42)).is_none());
1014 assert!(check_value(&ty, &json!("x")).is_none());
1015 assert!(check_value(&ty, &json!({"k": "v"})).is_none());
1016 assert!(check_value(&ty, &json!([1, 2, 3])).is_none());
1017 }
1018}