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 StatusColor,
72 IntRange(i64, i64),
74 NumRange(f64, f64),
76 CondNumRange {
88 sibling: &'static str,
90 equals: &'static str,
92 when_true: (f64, f64),
94 when_false: (f64, f64),
96 },
97 Bool,
99 String,
101 Url,
103 Object,
105 Free,
107}
108
109#[derive(Debug, Clone, PartialEq)]
112pub enum AttrProblem {
113 NotInEnum {
115 allowed: Vec<&'static str>,
117 actual: String,
119 },
120 OutOfRange {
122 lo: i64,
124 hi: i64,
126 actual: i64,
128 },
129 OutOfRangeF {
131 lo: f64,
133 hi: f64,
135 actual: f64,
137 },
138 WrongType {
140 expected: &'static str,
142 },
143 BadFormat {
145 reason: &'static str,
147 },
148}
149
150impl std::fmt::Display for AttrProblem {
151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 match self {
153 Self::NotInEnum { allowed, actual } => {
154 let allowed_str = allowed
155 .iter()
156 .map(|a| format!("'{a}'"))
157 .collect::<Vec<_>>()
158 .join(", ");
159 write!(
160 f,
161 "value '{actual}' is not in the allowed set ({allowed_str})"
162 )
163 }
164 Self::OutOfRange { lo, hi, actual } => {
165 write!(
166 f,
167 "value {actual} is outside the allowed range [{lo}, {hi}]"
168 )
169 }
170 Self::OutOfRangeF { lo, hi, actual } => {
171 write!(
172 f,
173 "value {actual} is outside the allowed range [{lo}, {hi}]"
174 )
175 }
176 Self::WrongType { expected } => {
177 write!(f, "value has wrong type (expected {expected})")
178 }
179 Self::BadFormat { reason } => write!(f, "{reason}"),
180 }
181 }
182}
183
184#[derive(Debug, Clone)]
186pub struct AttrSchema {
187 pub fields: &'static [(&'static str, AttrType, AttrPresence)],
190}
191
192const ENUM_PANEL_TYPE: &[&str] = &["info", "note", "warning", "success", "error", "custom"];
197
198const ENUM_TASK_STATE: &[&str] = &["TODO", "DONE"];
199
200const ENUM_DECISION_STATE: &[&str] = &["DECIDED", "UNDECIDED"];
201
202const ENUM_MEDIA_TYPE: &[&str] = &["file", "link", "external"];
203
204const ENUM_MEDIA_SINGLE_LAYOUT: &[&str] = &[
205 "align-end",
206 "align-start",
207 "center",
208 "full-width",
209 "wide",
210 "wrap-left",
211 "wrap-right",
212];
213
214const ENUM_STATUS_COLOR: &[&str] = &["neutral", "purple", "blue", "red", "yellow", "green"];
215
216const ENUM_MENTION_USER_TYPE: &[&str] = &["DEFAULT", "SPECIAL", "APP", "TEAM"];
217
218const ENUM_EXTENSION_LAYOUT: &[&str] = &["default", "wide", "full-width"];
219
220type AttrEntry = (&'static str, AttrSchema);
223
224const ATTR_ENTRIES: &[AttrEntry] = &[
225 (
230 "blockCard",
231 AttrSchema {
232 fields: &[
233 ("url", AttrType::Url, AttrPresence::Optional),
234 ("data", AttrType::Object, AttrPresence::Optional),
235 ],
236 },
237 ),
238 (
240 "bodiedExtension",
241 AttrSchema {
242 fields: &[
243 ("extensionType", AttrType::String, AttrPresence::Required),
244 ("extensionKey", AttrType::String, AttrPresence::Required),
245 (
246 "layout",
247 AttrType::Enum(ENUM_EXTENSION_LAYOUT),
248 AttrPresence::Optional,
249 ),
250 ("parameters", AttrType::Object, AttrPresence::Optional),
251 ("text", AttrType::String, AttrPresence::Optional),
252 ],
253 },
254 ),
255 (
258 "codeBlock",
259 AttrSchema {
260 fields: &[("language", AttrType::String, AttrPresence::Optional)],
261 },
262 ),
263 (
266 "date",
267 AttrSchema {
268 fields: &[("timestamp", AttrType::String, AttrPresence::Required)],
269 },
270 ),
271 (
273 "decisionItem",
274 AttrSchema {
275 fields: &[
276 ("localId", AttrType::String, AttrPresence::Required),
277 (
278 "state",
279 AttrType::Enum(ENUM_DECISION_STATE),
280 AttrPresence::Required,
281 ),
282 ],
283 },
284 ),
285 (
287 "decisionList",
288 AttrSchema {
289 fields: &[("localId", AttrType::String, AttrPresence::Required)],
290 },
291 ),
292 (
294 "embedCard",
295 AttrSchema {
296 fields: &[
297 ("url", AttrType::Url, AttrPresence::Required),
298 (
299 "layout",
300 AttrType::Enum(ENUM_EXTENSION_LAYOUT),
301 AttrPresence::Optional,
302 ),
303 (
304 "width",
305 AttrType::NumRange(0.0, 100.0),
306 AttrPresence::Optional,
307 ),
308 (
309 "originalHeight",
310 AttrType::NumRange(0.0, f64::MAX),
311 AttrPresence::Optional,
312 ),
313 (
314 "originalWidth",
315 AttrType::NumRange(0.0, f64::MAX),
316 AttrPresence::Optional,
317 ),
318 ],
319 },
320 ),
321 (
323 "emoji",
324 AttrSchema {
325 fields: &[
326 ("shortName", AttrType::String, AttrPresence::Required),
327 ("id", AttrType::String, AttrPresence::Optional),
328 ("text", AttrType::String, AttrPresence::Optional),
329 ],
330 },
331 ),
332 (
334 "expand",
335 AttrSchema {
336 fields: &[("title", AttrType::String, AttrPresence::Optional)],
337 },
338 ),
339 (
341 "extension",
342 AttrSchema {
343 fields: &[
344 ("extensionType", AttrType::String, AttrPresence::Required),
345 ("extensionKey", AttrType::String, AttrPresence::Required),
346 (
347 "layout",
348 AttrType::Enum(ENUM_EXTENSION_LAYOUT),
349 AttrPresence::Optional,
350 ),
351 ("parameters", AttrType::Object, AttrPresence::Optional),
352 ("text", AttrType::String, AttrPresence::Optional),
353 ],
354 },
355 ),
356 (
359 "heading",
360 AttrSchema {
361 fields: &[("level", AttrType::IntRange(1, 6), AttrPresence::Required)],
362 },
363 ),
364 (
366 "inlineCard",
367 AttrSchema {
368 fields: &[
369 ("url", AttrType::Url, AttrPresence::Optional),
370 ("data", AttrType::Object, AttrPresence::Optional),
371 ],
372 },
373 ),
374 (
377 "layoutColumn",
378 AttrSchema {
379 fields: &[(
380 "width",
381 AttrType::NumRange(0.0, 100.0),
382 AttrPresence::Required,
383 )],
384 },
385 ),
386 (
388 "media",
389 AttrSchema {
390 fields: &[
391 (
392 "type",
393 AttrType::Enum(ENUM_MEDIA_TYPE),
394 AttrPresence::Required,
395 ),
396 ("id", AttrType::String, AttrPresence::Optional),
397 ("collection", AttrType::String, AttrPresence::Optional),
398 ("url", AttrType::String, AttrPresence::Optional),
399 ("alt", AttrType::String, AttrPresence::Optional),
400 (
401 "width",
402 AttrType::NumRange(0.0, f64::MAX),
403 AttrPresence::Optional,
404 ),
405 (
406 "height",
407 AttrType::NumRange(0.0, f64::MAX),
408 AttrPresence::Optional,
409 ),
410 ("occurrenceKey", AttrType::String, AttrPresence::Optional),
411 ],
412 },
413 ),
414 (
421 "mediaSingle",
422 AttrSchema {
423 fields: &[
424 (
425 "layout",
426 AttrType::Enum(ENUM_MEDIA_SINGLE_LAYOUT),
427 AttrPresence::Optional,
428 ),
429 (
430 "width",
431 AttrType::CondNumRange {
432 sibling: "widthType",
433 equals: "pixel",
434 when_true: (0.0, f64::MAX),
435 when_false: (0.0, 100.0),
436 },
437 AttrPresence::Optional,
438 ),
439 ("widthType", AttrType::String, AttrPresence::Optional),
440 ],
441 },
442 ),
443 (
445 "mention",
446 AttrSchema {
447 fields: &[
448 ("id", AttrType::String, AttrPresence::Required),
449 ("text", AttrType::String, AttrPresence::Optional),
450 (
451 "userType",
452 AttrType::Enum(ENUM_MENTION_USER_TYPE),
453 AttrPresence::Optional,
454 ),
455 ("accessLevel", AttrType::String, AttrPresence::Optional),
456 ],
457 },
458 ),
459 (
461 "nestedExpand",
462 AttrSchema {
463 fields: &[("title", AttrType::String, AttrPresence::Optional)],
464 },
465 ),
466 (
469 "orderedList",
470 AttrSchema {
471 fields: &[(
472 "order",
473 AttrType::IntRange(0, i64::MAX),
474 AttrPresence::Optional,
475 )],
476 },
477 ),
478 (
481 "panel",
482 AttrSchema {
483 fields: &[(
484 "panelType",
485 AttrType::Enum(ENUM_PANEL_TYPE),
486 AttrPresence::Required,
487 )],
488 },
489 ),
490 (
492 "status",
493 AttrSchema {
494 fields: &[
495 ("text", AttrType::String, AttrPresence::Required),
496 ("color", AttrType::StatusColor, AttrPresence::Required),
497 ("localId", AttrType::String, AttrPresence::Optional),
498 ("style", AttrType::String, AttrPresence::Optional),
499 ],
500 },
501 ),
502 (
504 "taskItem",
505 AttrSchema {
506 fields: &[
507 ("localId", AttrType::String, AttrPresence::Required),
508 (
509 "state",
510 AttrType::Enum(ENUM_TASK_STATE),
511 AttrPresence::Required,
512 ),
513 ],
514 },
515 ),
516 (
518 "taskList",
519 AttrSchema {
520 fields: &[("localId", AttrType::String, AttrPresence::Required)],
521 },
522 ),
523];
524
525static ATTR_SCHEMAS: LazyLock<HashMap<&'static str, &'static AttrSchema>> = LazyLock::new(|| {
526 ATTR_ENTRIES
527 .iter()
528 .map(|(node_type, schema)| (*node_type, schema))
529 .collect()
530});
531
532#[must_use]
534pub fn attr_schema(node_type: &str) -> Option<&'static AttrSchema> {
535 ATTR_SCHEMAS.get(node_type).copied()
536}
537
538pub fn validate_attrs(
553 node_type: &str,
554 attrs: Option<&Value>,
555 path: &[usize],
556 out: &mut Vec<AdfSchemaViolation>,
557) {
558 let Some(schema) = attr_schema(node_type) else {
559 return;
560 };
561
562 let attr_obj = match attrs {
564 Some(Value::Object(map)) => Some(map),
565 Some(Value::Null) | None => None,
566 Some(_other) => {
567 for (field, _ty, presence) in schema.fields {
572 if *presence == AttrPresence::Required {
573 out.push(AdfSchemaViolation::MissingAttr {
574 node_type: node_type.to_string(),
575 attr_name: (*field).to_string(),
576 path: path.to_vec(),
577 });
578 }
579 }
580 return;
581 }
582 };
583
584 for (field, ty, presence) in schema.fields {
585 let value = attr_obj.and_then(|m| m.get(*field));
586
587 let value = match value {
589 Some(Value::Null) | None => None,
590 Some(v) => Some(v),
591 };
592
593 match (value, *presence) {
594 (None, AttrPresence::Required) => {
595 out.push(AdfSchemaViolation::MissingAttr {
596 node_type: node_type.to_string(),
597 attr_name: (*field).to_string(),
598 path: path.to_vec(),
599 });
600 }
601 (None, AttrPresence::Optional) => {
602 }
604 (Some(v), _) => {
605 let effective = resolve_attr_type(ty, attr_obj);
609 if let Some(problem) = check_value(&effective, v) {
610 out.push(AdfSchemaViolation::InvalidAttr {
611 node_type: node_type.to_string(),
612 attr_name: (*field).to_string(),
613 problem,
614 path: path.to_vec(),
615 });
616 }
617 }
618 }
619 }
620}
621
622fn resolve_attr_type(ty: &AttrType, attrs: Option<&serde_json::Map<String, Value>>) -> AttrType {
629 match ty {
630 AttrType::CondNumRange {
631 sibling,
632 equals,
633 when_true,
634 when_false,
635 } => {
636 let selected =
637 attrs.and_then(|m| m.get(*sibling)).and_then(Value::as_str) == Some(*equals);
638 let (lo, hi) = if selected { *when_true } else { *when_false };
639 AttrType::NumRange(lo, hi)
640 }
641 other => other.clone(),
642 }
643}
644
645#[must_use]
656pub fn check_value(ty: &AttrType, value: &Value) -> Option<AttrProblem> {
657 match ty {
658 AttrType::Enum(allowed) => match value.as_str() {
659 Some(s) if allowed.contains(&s) => None,
660 Some(s) => Some(AttrProblem::NotInEnum {
661 allowed: allowed.to_vec(),
662 actual: s.to_string(),
663 }),
664 None => Some(AttrProblem::WrongType { expected: "string" }),
665 },
666 AttrType::StatusColor => match value.as_str() {
667 Some(s)
668 if ENUM_STATUS_COLOR.contains(&s)
669 || (s.len() == 7
670 && s.starts_with('#')
671 && s.as_bytes()[1..].iter().all(u8::is_ascii_hexdigit)) =>
672 {
673 None
674 }
675 Some(_) => Some(AttrProblem::BadFormat {
676 reason: "expected a named status color or #RRGGBB",
677 }),
678 None => Some(AttrProblem::WrongType { expected: "string" }),
679 },
680 AttrType::IntRange(lo, hi) => match value.as_i64() {
681 Some(n) if n >= *lo && n <= *hi => None,
682 Some(n) => Some(AttrProblem::OutOfRange {
683 lo: *lo,
684 hi: *hi,
685 actual: n,
686 }),
687 None => Some(AttrProblem::WrongType {
688 expected: "integer",
689 }),
690 },
691 AttrType::NumRange(lo, hi) => match value.as_f64() {
692 Some(n) if n >= *lo && n <= *hi => None,
693 Some(n) => Some(AttrProblem::OutOfRangeF {
694 lo: *lo,
695 hi: *hi,
696 actual: n,
697 }),
698 None => Some(AttrProblem::WrongType { expected: "number" }),
699 },
700 AttrType::CondNumRange { when_false, .. } => {
701 check_value(&AttrType::NumRange(when_false.0, when_false.1), value)
704 }
705 AttrType::Bool => match value.as_bool() {
706 Some(_) => None,
707 None => Some(AttrProblem::WrongType { expected: "bool" }),
708 },
709 AttrType::String => match value.as_str() {
710 Some(_) => None,
711 None => Some(AttrProblem::WrongType { expected: "string" }),
712 },
713 AttrType::Url => match value.as_str() {
714 Some(s) => match url::Url::parse(s) {
715 Ok(_) => None,
716 Err(_) => Some(AttrProblem::BadFormat {
717 reason: "not a valid URL",
718 }),
719 },
720 None => Some(AttrProblem::WrongType { expected: "string" }),
721 },
722 AttrType::Object => match value {
723 Value::Object(_) => None,
724 _ => Some(AttrProblem::WrongType { expected: "object" }),
725 },
726 AttrType::Free => None,
727 }
728}
729
730#[cfg(test)]
731#[allow(clippy::unwrap_used, clippy::expect_used)]
732mod tests {
733 use super::*;
734 use serde_json::json;
735
736 fn run(node_type: &str, attrs: Value) -> Vec<AdfSchemaViolation> {
737 let mut out = Vec::new();
738 validate_attrs(node_type, Some(&attrs), &[], &mut out);
739 out
740 }
741
742 fn run_no_attrs(node_type: &str) -> Vec<AdfSchemaViolation> {
743 let mut out = Vec::new();
744 validate_attrs(node_type, None, &[], &mut out);
745 out
746 }
747
748 #[test]
749 fn status_color_accepts_names_and_six_digit_hex() {
750 for color in ENUM_STATUS_COLOR
751 .iter()
752 .copied()
753 .chain(["#000000", "#ffffff", "#aB12Cd"])
754 {
755 assert!(run("status", json!({"text": "Ready", "color": color})).is_empty());
756 }
757 }
758
759 #[test]
760 fn status_color_rejects_malformed_strings_and_wrong_types() {
761 for color in [
762 "orange",
763 "Blue",
764 "#fff",
765 "#12345678",
766 "123456",
767 "#12GG34",
768 " #123456",
769 "#123456\n",
770 "#é1234",
771 "",
772 ] {
773 let violations = run("status", json!({"text": "Ready", "color": color}));
774 assert!(
775 matches!(violations.as_slice(), [AdfSchemaViolation::InvalidAttr {
776 attr_name, problem: AttrProblem::BadFormat { .. }, ..
777 }] if attr_name == "color"),
778 "color={color:?}: {violations:?}"
779 );
780 }
781 for color in [json!(42), json!(true), json!({}), json!([])] {
782 let violations = run("status", json!({"text": "Ready", "color": color}));
783 assert!(
784 matches!(violations.as_slice(), [AdfSchemaViolation::InvalidAttr {
785 attr_name, problem: AttrProblem::WrongType { expected: "string" }, ..
786 }] if attr_name == "color")
787 );
788 }
789 }
790
791 #[test]
792 fn panel_panel_type_known_value_validates() {
793 for value in ENUM_PANEL_TYPE {
794 assert!(
795 run("panel", json!({ "panelType": value })).is_empty(),
796 "panelType '{value}' should validate"
797 );
798 }
799 }
800
801 #[test]
802 fn panel_panel_type_unknown_value_flagged() {
803 let v = run("panel", json!({ "panelType": "purple" }));
804 assert_eq!(v.len(), 1);
805 match &v[0] {
806 AdfSchemaViolation::InvalidAttr {
807 node_type,
808 attr_name,
809 problem,
810 ..
811 } => {
812 assert_eq!(node_type, "panel");
813 assert_eq!(attr_name, "panelType");
814 assert!(matches!(problem, AttrProblem::NotInEnum { .. }));
815 }
816 other => panic!("expected InvalidAttr, got {other:?}"),
817 }
818 }
819
820 #[test]
821 fn panel_missing_panel_type_flagged() {
822 let v = run("panel", json!({}));
823 assert_eq!(v.len(), 1);
824 match &v[0] {
825 AdfSchemaViolation::MissingAttr {
826 node_type,
827 attr_name,
828 ..
829 } => {
830 assert_eq!(node_type, "panel");
831 assert_eq!(attr_name, "panelType");
832 }
833 other => panic!("expected MissingAttr, got {other:?}"),
834 }
835 }
836
837 #[test]
838 fn panel_missing_attrs_object_flagged() {
839 let v = run_no_attrs("panel");
840 assert_eq!(v.len(), 1);
841 assert!(matches!(v[0], AdfSchemaViolation::MissingAttr { .. }));
842 }
843
844 #[test]
845 fn heading_level_in_range_validates() {
846 for level in 1_i64..=6 {
847 assert!(run("heading", json!({ "level": level })).is_empty());
848 }
849 }
850
851 #[test]
852 fn heading_level_out_of_range_flagged() {
853 let v = run("heading", json!({ "level": 7 }));
854 assert_eq!(v.len(), 1);
855 match &v[0] {
856 AdfSchemaViolation::InvalidAttr {
857 attr_name, problem, ..
858 } => {
859 assert_eq!(attr_name, "level");
860 assert!(matches!(
861 problem,
862 AttrProblem::OutOfRange {
863 lo: 1,
864 hi: 6,
865 actual: 7
866 }
867 ));
868 }
869 other => panic!("expected InvalidAttr, got {other:?}"),
870 }
871 }
872
873 #[test]
874 fn heading_level_wrong_type_flagged() {
875 let v = run("heading", json!({ "level": "two" }));
876 assert_eq!(v.len(), 1);
877 match &v[0] {
878 AdfSchemaViolation::InvalidAttr { problem, .. } => {
879 assert!(matches!(
880 problem,
881 AttrProblem::WrongType {
882 expected: "integer"
883 }
884 ));
885 }
886 other => panic!("expected InvalidAttr, got {other:?}"),
887 }
888 }
889
890 #[test]
891 fn heading_missing_level_flagged_as_missing() {
892 let v = run("heading", json!({}));
893 assert_eq!(v.len(), 1);
894 assert!(
895 matches!(&v[0], AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "level")
896 );
897 }
898
899 #[test]
900 fn task_item_known_state_validates() {
901 for state in ENUM_TASK_STATE {
902 assert!(run("taskItem", json!({ "localId": "abc", "state": state })).is_empty());
903 }
904 }
905
906 #[test]
907 fn task_item_unknown_state_flagged() {
908 let v = run(
909 "taskItem",
910 json!({ "localId": "abc", "state": "INPROGRESS" }),
911 );
912 assert_eq!(v.len(), 1);
913 match &v[0] {
914 AdfSchemaViolation::InvalidAttr { attr_name, .. } => {
915 assert_eq!(attr_name, "state");
916 }
917 other => panic!("expected InvalidAttr, got {other:?}"),
918 }
919 }
920
921 #[test]
922 fn media_single_layout_known_validates() {
923 assert!(run("mediaSingle", json!({ "layout": "center" })).is_empty());
924 assert!(run("mediaSingle", json!({ "layout": "wide" })).is_empty());
925 }
926
927 #[test]
928 fn media_single_layout_misspelled_flagged() {
929 let v = run("mediaSingle", json!({ "layout": "centre" }));
930 assert_eq!(v.len(), 1);
931 assert!(matches!(
932 &v[0],
933 AdfSchemaViolation::InvalidAttr { attr_name, .. } if attr_name == "layout"
934 ));
935 }
936
937 #[test]
938 fn media_single_pixel_width_above_100_validates() {
939 assert!(run(
943 "mediaSingle",
944 json!({ "layout": "center", "width": 900, "widthType": "pixel" })
945 )
946 .is_empty());
947 }
948
949 #[test]
950 fn media_single_percentage_width_above_100_flagged() {
951 for attrs in [
954 json!({ "layout": "center", "width": 900 }),
955 json!({ "layout": "center", "width": 900, "widthType": "percentage" }),
956 ] {
957 let v = run("mediaSingle", attrs.clone());
958 assert_eq!(v.len(), 1, "expected one violation for {attrs}");
959 assert!(matches!(
960 &v[0],
961 AdfSchemaViolation::InvalidAttr { attr_name, problem, .. }
962 if attr_name == "width"
963 && matches!(problem, AttrProblem::OutOfRangeF { .. })
964 ));
965 }
966 }
967
968 #[test]
969 fn check_value_cond_num_range_falls_back_to_strict_branch() {
970 let ty = AttrType::CondNumRange {
974 sibling: "widthType",
975 equals: "pixel",
976 when_true: (0.0, f64::MAX),
977 when_false: (0.0, 100.0),
978 };
979 assert!(matches!(
980 check_value(&ty, &json!(900)),
981 Some(AttrProblem::OutOfRangeF { .. })
982 ));
983 assert!(check_value(&ty, &json!(50)).is_none());
984 }
985
986 #[test]
987 fn media_single_percentage_width_in_range_validates() {
988 assert!(run(
989 "mediaSingle",
990 json!({ "layout": "center", "width": 75, "widthType": "percentage" })
991 )
992 .is_empty());
993 assert!(run("mediaSingle", json!({ "layout": "center", "width": 75 })).is_empty());
995 }
996
997 #[test]
998 fn media_type_required() {
999 let v = run("media", json!({}));
1000 assert_eq!(v.len(), 1);
1001 assert!(matches!(
1002 &v[0],
1003 AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "type"
1004 ));
1005 }
1006
1007 #[test]
1008 fn embed_card_url_format() {
1009 assert!(run("embedCard", json!({ "url": "https://example.com" })).is_empty());
1010 let v = run("embedCard", json!({ "url": "not a url" }));
1011 assert_eq!(v.len(), 1);
1012 match &v[0] {
1013 AdfSchemaViolation::InvalidAttr { problem, .. } => {
1014 assert!(matches!(problem, AttrProblem::BadFormat { .. }));
1015 }
1016 other => panic!("expected InvalidAttr, got {other:?}"),
1017 }
1018 }
1019
1020 #[test]
1021 fn layout_column_width_in_range() {
1022 assert!(run("layoutColumn", json!({ "width": 33.3 })).is_empty());
1023 let v = run("layoutColumn", json!({ "width": 150 }));
1024 assert_eq!(v.len(), 1);
1025 assert!(matches!(
1026 &v[0],
1027 AdfSchemaViolation::InvalidAttr {
1028 problem: AttrProblem::OutOfRangeF { .. },
1029 ..
1030 }
1031 ));
1032 }
1033
1034 #[test]
1035 fn ordered_list_order_optional() {
1036 assert!(run("orderedList", json!({})).is_empty());
1037 assert!(run("orderedList", json!({ "order": 5 })).is_empty());
1038 let v = run("orderedList", json!({ "order": -1 }));
1040 assert_eq!(v.len(), 1);
1041 }
1042
1043 #[test]
1044 fn unknown_node_type_is_permissive() {
1045 assert!(run("madeUpNode", json!({ "anyField": "anyValue" })).is_empty());
1046 }
1047
1048 #[test]
1049 fn unknown_field_under_known_node_is_permissive() {
1050 assert!(run("panel", json!({ "panelType": "info", "futureField": "ok" })).is_empty());
1052 }
1053
1054 #[test]
1055 fn null_attribute_treated_as_absent() {
1056 assert!(run(
1058 "status",
1059 json!({ "text": "hi", "color": "blue", "localId": null })
1060 )
1061 .is_empty());
1062 let v = run("status", json!({ "text": "hi", "color": null }));
1064 assert!(matches!(
1065 &v[0],
1066 AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "color"
1067 ));
1068 }
1069
1070 #[test]
1071 fn attrs_array_treated_as_invalid_object() {
1072 let mut out = Vec::new();
1075 validate_attrs("panel", Some(&json!([1, 2, 3])), &[], &mut out);
1076 assert_eq!(out.len(), 1);
1077 assert!(matches!(
1078 &out[0],
1079 AdfSchemaViolation::MissingAttr { attr_name, .. } if attr_name == "panelType"
1080 ));
1081 }
1082
1083 #[test]
1084 fn attr_problem_display_messages() {
1085 let p = AttrProblem::NotInEnum {
1086 allowed: vec!["info", "note"],
1087 actual: "purple".to_string(),
1088 };
1089 let s = p.to_string();
1090 assert!(s.contains("'purple'"), "got: {s}");
1091 assert!(s.contains("'info'"), "got: {s}");
1092
1093 let p = AttrProblem::OutOfRange {
1094 lo: 1,
1095 hi: 6,
1096 actual: 7,
1097 };
1098 assert!(p.to_string().contains("[1, 6]"));
1099
1100 let p = AttrProblem::BadFormat {
1101 reason: "not a valid URL",
1102 };
1103 assert_eq!(p.to_string(), "not a valid URL");
1104
1105 let p = AttrProblem::WrongType {
1106 expected: "integer",
1107 };
1108 assert!(p.to_string().contains("integer"));
1109 }
1110
1111 #[test]
1112 fn attr_problem_out_of_range_f_display() {
1113 let p = AttrProblem::OutOfRangeF {
1117 lo: 0.0,
1118 hi: 100.0,
1119 actual: 200.0,
1120 };
1121 let s = p.to_string();
1122 assert!(s.contains("200"), "got: {s}");
1123 assert!(s.contains("[0, 100]"), "got: {s}");
1124 }
1125
1126 #[test]
1136 fn check_value_enum_wrong_type_for_non_string() {
1137 let ty = AttrType::Enum(&["a", "b"]);
1138 let p = check_value(&ty, &json!(123)).expect("should reject");
1139 assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
1140 }
1141
1142 #[test]
1143 fn check_value_int_range_wrong_type_for_non_integer() {
1144 let ty = AttrType::IntRange(0, 10);
1145 let p = check_value(&ty, &json!("abc")).expect("should reject");
1146 assert!(matches!(
1147 p,
1148 AttrProblem::WrongType {
1149 expected: "integer"
1150 }
1151 ));
1152 }
1153
1154 #[test]
1155 fn check_value_num_range_wrong_type_for_non_number() {
1156 let ty = AttrType::NumRange(0.0, 100.0);
1157 let p = check_value(&ty, &json!("abc")).expect("should reject");
1158 assert!(matches!(p, AttrProblem::WrongType { expected: "number" }));
1159 }
1160
1161 #[test]
1162 fn check_value_bool_arms() {
1163 let ty = AttrType::Bool;
1164 assert!(check_value(&ty, &json!(true)).is_none());
1165 let p = check_value(&ty, &json!("yes")).expect("should reject");
1166 assert!(matches!(p, AttrProblem::WrongType { expected: "bool" }));
1167 }
1168
1169 #[test]
1170 fn check_value_string_arms() {
1171 let ty = AttrType::String;
1172 assert!(check_value(&ty, &json!("hi")).is_none());
1173 let p = check_value(&ty, &json!(42)).expect("should reject");
1174 assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
1175 }
1176
1177 #[test]
1178 fn check_value_url_wrong_type_for_non_string() {
1179 let ty = AttrType::Url;
1180 let p = check_value(&ty, &json!(42)).expect("should reject");
1181 assert!(matches!(p, AttrProblem::WrongType { expected: "string" }));
1182 }
1183
1184 #[test]
1185 fn check_value_object_arms() {
1186 let ty = AttrType::Object;
1187 assert!(check_value(&ty, &json!({"k": "v"})).is_none());
1188 let p = check_value(&ty, &json!([1, 2])).expect("should reject");
1189 assert!(matches!(p, AttrProblem::WrongType { expected: "object" }));
1190 }
1191
1192 #[test]
1193 fn check_value_free_accepts_anything() {
1194 let ty = AttrType::Free;
1195 assert!(check_value(&ty, &json!(null)).is_none());
1196 assert!(check_value(&ty, &json!(42)).is_none());
1197 assert!(check_value(&ty, &json!("x")).is_none());
1198 assert!(check_value(&ty, &json!({"k": "v"})).is_none());
1199 assert!(check_value(&ty, &json!([1, 2, 3])).is_none());
1200 }
1201}