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