1use std::collections::HashMap;
38use std::sync::LazyLock;
39
40use serde_json::Value;
41
42use crate::atlassian::adf_attr_schema::{AttrPresence, AttrSchema, AttrType};
43use crate::atlassian::adf_schema::AdfSchemaViolation;
44
45const STD_INLINE_MARKS: &[&str] = &[
52 "annotation",
53 "backgroundColor",
54 "code",
55 "em",
56 "link",
57 "strike",
58 "strong",
59 "subsup",
60 "textColor",
61 "underline",
62];
63
64const HEADING_INLINE_MARKS: &[&str] = &[
68 "annotation",
69 "backgroundColor",
70 "em",
71 "link",
72 "strike",
73 "strong",
74 "subsup",
75 "textColor",
76 "underline",
77];
78
79const CODE_BLOCK_INLINE_MARKS: &[&str] = &[];
81
82const CAPTION_INLINE_MARKS: &[&str] = &[
84 "backgroundColor",
85 "em",
86 "link",
87 "strike",
88 "strong",
89 "subsup",
90 "textColor",
91 "underline",
92];
93
94const PARAGRAPH_BLOCK_MARKS: &[&str] = &["alignment", "indentation"];
96const HEADING_BLOCK_MARKS: &[&str] = &["alignment", "indentation"];
97const TABLE_CELL_BLOCK_MARKS: &[&str] = &["backgroundColor", "border"];
98const TABLE_HEADER_BLOCK_MARKS: &[&str] = &["backgroundColor", "border"];
99
100const CODE_INLINE_MARK_GROUP: &[&str] = &["annotation", "code", "link"];
108
109const FORMATTED_INLINE_MARK_GROUP: &[&str] = &[
112 "annotation",
113 "backgroundColor",
114 "em",
115 "link",
116 "strike",
117 "strong",
118 "subsup",
119 "textColor",
120 "underline",
121];
122
123const INLINE_MARK_GROUPS: &[&[&str]] = &[CODE_INLINE_MARK_GROUP, FORMATTED_INLINE_MARK_GROUP];
133
134fn marks_may_coexist(a: &str, b: &str) -> bool {
139 let in_a_group = |m: &str| INLINE_MARK_GROUPS.iter().any(|g| g.contains(&m));
140 if !in_a_group(a) || !in_a_group(b) {
141 return true;
142 }
143 INLINE_MARK_GROUPS
144 .iter()
145 .any(|g| g.contains(&a) && g.contains(&b))
146}
147
148const INLINE_MARKS_ENTRIES: &[(&str, &[&str])] = &[
149 ("caption", CAPTION_INLINE_MARKS),
150 ("codeBlock", CODE_BLOCK_INLINE_MARKS),
151 ("decisionItem", STD_INLINE_MARKS),
152 ("heading", HEADING_INLINE_MARKS),
153 ("paragraph", STD_INLINE_MARKS),
154 ("taskItem", STD_INLINE_MARKS),
155];
156
157const BLOCK_MARKS_ENTRIES: &[(&str, &[&str])] = &[
158 ("heading", HEADING_BLOCK_MARKS),
159 ("paragraph", PARAGRAPH_BLOCK_MARKS),
160 ("tableCell", TABLE_CELL_BLOCK_MARKS),
161 ("tableHeader", TABLE_HEADER_BLOCK_MARKS),
162];
163
164static INLINE_MARKS: LazyLock<HashMap<&'static str, &'static [&'static str]>> =
165 LazyLock::new(|| INLINE_MARKS_ENTRIES.iter().copied().collect());
166
167static BLOCK_MARKS: LazyLock<HashMap<&'static str, &'static [&'static str]>> =
168 LazyLock::new(|| BLOCK_MARKS_ENTRIES.iter().copied().collect());
169
170const INLINE_NODE_TYPES: &[&str] = &[
174 "date",
175 "emoji",
176 "hardBreak",
177 "inlineCard",
178 "inlineExtension",
179 "mediaInline",
180 "mention",
181 "placeholder",
182 "status",
183 "text",
184];
185
186#[must_use]
190pub fn allowed_inline_marks(parent: &str) -> Option<&'static [&'static str]> {
191 INLINE_MARKS.get(parent).copied()
192}
193
194#[must_use]
197pub fn allowed_block_marks(node_type: &str) -> Option<&'static [&'static str]> {
198 BLOCK_MARKS.get(node_type).copied()
199}
200
201#[must_use]
205pub fn is_inline_node(node_type: &str) -> bool {
206 INLINE_NODE_TYPES.contains(&node_type)
207}
208
209fn is_unsupported_mark(mark_type: &str) -> bool {
212 mark_type == "unsupportedMark" || mark_type == "unsupportedNodeAttribute"
213}
214
215const ENUM_SUBSUP_TYPE: &[&str] = &["sub", "sup"];
220const ENUM_ALIGNMENT_ALIGN: &[&str] = &["start", "end", "center", "right", "left"];
221const ENUM_BREAKOUT_MODE: &[&str] = &["wide", "full-width"];
222
223type MarkAttrEntry = (&'static str, AttrSchema);
224
225const MARK_ATTR_ENTRIES: &[MarkAttrEntry] = &[
226 (
228 "alignment",
229 AttrSchema {
230 fields: &[(
231 "align",
232 AttrType::Enum(ENUM_ALIGNMENT_ALIGN),
233 AttrPresence::Required,
234 )],
235 },
236 ),
237 (
239 "annotation",
240 AttrSchema {
241 fields: &[
242 ("id", AttrType::String, AttrPresence::Required),
243 ("annotationType", AttrType::String, AttrPresence::Required),
244 ],
245 },
246 ),
247 (
250 "backgroundColor",
251 AttrSchema {
252 fields: &[("color", AttrType::String, AttrPresence::Required)],
253 },
254 ),
255 (
257 "border",
258 AttrSchema {
259 fields: &[
260 ("color", AttrType::String, AttrPresence::Required),
261 ("size", AttrType::IntRange(1, 3), AttrPresence::Required),
262 ],
263 },
264 ),
265 (
267 "breakout",
268 AttrSchema {
269 fields: &[(
270 "mode",
271 AttrType::Enum(ENUM_BREAKOUT_MODE),
272 AttrPresence::Required,
273 )],
274 },
275 ),
276 ("code", AttrSchema { fields: &[] }),
278 ("em", AttrSchema { fields: &[] }),
280 (
282 "indentation",
283 AttrSchema {
284 fields: &[("level", AttrType::IntRange(1, 6), AttrPresence::Required)],
285 },
286 ),
287 (
291 "link",
292 AttrSchema {
293 fields: &[
294 ("href", AttrType::Url, AttrPresence::Required),
295 ("title", AttrType::String, AttrPresence::Optional),
296 ("id", AttrType::String, AttrPresence::Optional),
297 ("collection", AttrType::String, AttrPresence::Optional),
298 ("occurrenceKey", AttrType::String, AttrPresence::Optional),
299 ],
300 },
301 ),
302 ("strike", AttrSchema { fields: &[] }),
304 ("strong", AttrSchema { fields: &[] }),
306 (
308 "subsup",
309 AttrSchema {
310 fields: &[(
311 "type",
312 AttrType::Enum(ENUM_SUBSUP_TYPE),
313 AttrPresence::Required,
314 )],
315 },
316 ),
317 (
320 "textColor",
321 AttrSchema {
322 fields: &[("color", AttrType::String, AttrPresence::Required)],
323 },
324 ),
325 ("underline", AttrSchema { fields: &[] }),
327];
328
329static MARK_ATTR_SCHEMAS: LazyLock<HashMap<&'static str, &'static AttrSchema>> =
330 LazyLock::new(|| {
331 MARK_ATTR_ENTRIES
332 .iter()
333 .map(|(mark_type, schema)| (*mark_type, schema))
334 .collect()
335 });
336
337#[must_use]
344pub fn mark_attr_schema(mark_type: &str) -> Option<&'static AttrSchema> {
345 MARK_ATTR_SCHEMAS.get(mark_type).copied()
346}
347
348pub fn validate_marks(
373 parent_type: &str,
374 node: &crate::atlassian::adf::AdfNode,
375 path: &[usize],
376 out: &mut Vec<AdfSchemaViolation>,
377) {
378 let Some(marks) = node.marks.as_ref() else {
379 return;
380 };
381 if marks.is_empty() {
382 return;
383 }
384
385 let node_type = node.node_type.as_str();
386 let allowed = if is_inline_node(node_type) {
387 allowed_inline_marks(parent_type)
388 } else {
389 allowed_block_marks(node_type)
390 };
391
392 for (mark_idx, mark) in marks.iter().enumerate() {
393 let mark_type = mark.mark_type.as_str();
394
395 if is_unsupported_mark(mark_type) {
396 continue;
397 }
398
399 if let Some(allowed) = allowed {
400 if !allowed.contains(&mark_type) {
401 out.push(AdfSchemaViolation::DisallowedMark {
402 mark_type: mark_type.to_string(),
403 parent_type: if is_inline_node(node_type) {
404 parent_type.to_string()
405 } else {
406 node_type.to_string()
407 },
408 inline_index: if is_inline_node(node_type) {
409 Some(*path.last().unwrap_or(&0))
410 } else {
411 None
412 },
413 path: path.to_vec(),
414 });
415 continue;
419 }
420 }
421
422 if let Some(schema) = mark_attr_schema(mark_type) {
423 validate_mark_attrs_against(
424 schema,
425 mark_type,
426 mark.attrs.as_ref(),
427 mark_idx,
428 path,
429 out,
430 );
431 }
432 }
433
434 if is_inline_node(node_type) {
438 check_inline_mark_combination(parent_type, marks, path, out);
439 }
440}
441
442fn check_inline_mark_combination(
448 parent_type: &str,
449 marks: &[crate::atlassian::adf::AdfMark],
450 path: &[usize],
451 out: &mut Vec<AdfSchemaViolation>,
452) {
453 let mut seen: Vec<&str> = Vec::new();
455 for mark in marks {
456 let m = mark.mark_type.as_str();
457 if is_unsupported_mark(m) || seen.contains(&m) {
458 continue;
459 }
460 seen.push(m);
461 }
462
463 for i in 0..seen.len() {
464 for j in (i + 1)..seen.len() {
465 if !marks_may_coexist(seen[i], seen[j]) {
466 out.push(AdfSchemaViolation::ForbiddenMarkCombination {
467 mark_type: seen[i].to_string(),
468 conflicts_with: seen[j].to_string(),
469 parent_type: parent_type.to_string(),
470 inline_index: Some(*path.last().unwrap_or(&0)),
471 path: path.to_vec(),
472 });
473 return;
474 }
475 }
476 }
477}
478
479fn validate_mark_attrs_against(
480 schema: &AttrSchema,
481 mark_type: &str,
482 attrs: Option<&Value>,
483 mark_idx: usize,
484 path: &[usize],
485 out: &mut Vec<AdfSchemaViolation>,
486) {
487 let mut tmp: Vec<AdfSchemaViolation> = Vec::new();
491 crate::atlassian::adf_attr_schema::validate_attrs(
492 "<__adf_mark_inline__>",
497 attrs,
498 path,
499 &mut tmp,
500 );
501 debug_assert!(
502 tmp.is_empty(),
503 "sentinel must not match a registered schema"
504 );
505
506 let attr_obj = match attrs {
508 Some(Value::Object(map)) => Some(map),
509 Some(Value::Null) | None => None,
510 Some(_other) => {
511 for (field, _ty, presence) in schema.fields {
512 if *presence == AttrPresence::Required {
513 out.push(AdfSchemaViolation::DisallowedMark {
514 mark_type: mark_type.to_string(),
515 parent_type: format!("<malformed attrs for mark '{mark_type}'>"),
516 inline_index: Some(mark_idx),
517 path: path.to_vec(),
518 });
519 let _ = field;
520 return;
521 }
522 }
523 return;
524 }
525 };
526
527 for (field, ty, presence) in schema.fields {
528 let value = attr_obj.and_then(|m| m.get(*field));
529 let value = match value {
530 Some(Value::Null) | None => None,
531 Some(v) => Some(v),
532 };
533
534 match (value, *presence) {
535 (None, AttrPresence::Required) => {
536 out.push(AdfSchemaViolation::InvalidMarkAttr {
537 mark_type: mark_type.to_string(),
538 attr_name: (*field).to_string(),
539 problem: crate::atlassian::adf_attr_schema::AttrProblem::WrongType {
540 expected: "present",
541 },
542 inline_index: Some(mark_idx),
543 path: path.to_vec(),
544 });
545 }
546 (None, AttrPresence::Optional) => {}
547 (Some(v), _) => {
548 if let Some(problem) = crate::atlassian::adf_attr_schema::check_value(ty, v) {
549 out.push(AdfSchemaViolation::InvalidMarkAttr {
550 mark_type: mark_type.to_string(),
551 attr_name: (*field).to_string(),
552 problem,
553 inline_index: Some(mark_idx),
554 path: path.to_vec(),
555 });
556 }
557 }
558 }
559 }
560}
561
562#[cfg(test)]
563#[allow(clippy::unwrap_used, clippy::expect_used, clippy::needless_collect)]
564mod tests {
565 use super::*;
566 use crate::atlassian::adf::{AdfMark, AdfNode};
567
568 fn text_with_marks(text: &str, marks: Vec<AdfMark>) -> AdfNode {
569 AdfNode {
570 node_type: "text".to_string(),
571 attrs: None,
572 content: None,
573 text: Some(text.to_string()),
574 marks: Some(marks),
575 local_id: None,
576 parameters: None,
577 }
578 }
579
580 fn paragraph_with_marks(marks: Vec<AdfMark>, content: Vec<AdfNode>) -> AdfNode {
581 AdfNode {
582 node_type: "paragraph".to_string(),
583 attrs: None,
584 content: if content.is_empty() {
585 None
586 } else {
587 Some(content)
588 },
589 text: None,
590 marks: Some(marks),
591 local_id: None,
592 parameters: None,
593 }
594 }
595
596 fn mark(mark_type: &str, attrs: Option<serde_json::Value>) -> AdfMark {
597 AdfMark {
598 mark_type: mark_type.to_string(),
599 attrs,
600 }
601 }
602
603 fn run_inline(parent: &str, child: AdfNode) -> Vec<AdfSchemaViolation> {
604 let mut out = Vec::new();
605 validate_marks(parent, &child, &[0_usize], &mut out);
606 out
607 }
608
609 fn run_block(node: AdfNode) -> Vec<AdfSchemaViolation> {
610 let mut out = Vec::new();
611 validate_marks("doc", &node, &[0_usize], &mut out);
612 out
613 }
614
615 #[test]
618 fn paragraph_allows_code_mark_on_text() {
619 let node = text_with_marks("hi", vec![mark("code", None)]);
620 assert!(run_inline("paragraph", node).is_empty());
621 }
622
623 #[test]
624 fn heading_rejects_code_mark_on_text() {
625 let node = text_with_marks("hi", vec![mark("code", None)]);
626 let v = run_inline("heading", node);
627 assert_eq!(v.len(), 1, "got: {v:?}");
628 match &v[0] {
629 AdfSchemaViolation::DisallowedMark {
630 mark_type,
631 parent_type,
632 ..
633 } => {
634 assert_eq!(mark_type, "code");
635 assert_eq!(parent_type, "heading");
636 }
637 other => panic!("expected DisallowedMark, got {other:?}"),
638 }
639 }
640
641 #[test]
642 fn code_block_rejects_any_mark_on_text() {
643 let node = text_with_marks("hi", vec![mark("strong", None)]);
644 let v = run_inline("codeBlock", node);
645 assert_eq!(v.len(), 1);
646 }
647
648 #[test]
649 fn unknown_parent_skips_mark_validation() {
650 let node = text_with_marks("hi", vec![mark("madeUp", None)]);
651 assert!(run_inline("madeUpParent", node).is_empty());
652 }
653
654 #[test]
655 fn unsupported_mark_accepted_anywhere() {
656 let node = text_with_marks(
657 "hi",
658 vec![
659 mark("unsupportedMark", None),
660 mark("unsupportedNodeAttribute", None),
661 ],
662 );
663 assert!(run_inline("heading", node).is_empty());
664 }
665
666 #[test]
669 fn paragraph_block_allows_alignment() {
670 let node = paragraph_with_marks(
671 vec![mark(
672 "alignment",
673 Some(serde_json::json!({"align": "center"})),
674 )],
675 vec![AdfNode::text("x")],
676 );
677 assert!(run_block(node).is_empty());
678 }
679
680 #[test]
681 fn paragraph_block_rejects_border() {
682 let node = paragraph_with_marks(
684 vec![mark(
685 "border",
686 Some(serde_json::json!({"color": "#ff0000", "size": 1})),
687 )],
688 vec![AdfNode::text("x")],
689 );
690 let v = run_block(node);
691 let disallowed: Vec<_> = v
692 .iter()
693 .filter(|v| matches!(v, AdfSchemaViolation::DisallowedMark { .. }))
694 .collect();
695 assert_eq!(disallowed.len(), 1, "got: {v:?}");
696 }
697
698 #[test]
699 fn table_cell_allows_border() {
700 let cell = AdfNode {
701 node_type: "tableCell".to_string(),
702 attrs: None,
703 content: None,
704 text: None,
705 marks: Some(vec![mark(
706 "border",
707 Some(serde_json::json!({"color": "#ff0000", "size": 2})),
708 )]),
709 local_id: None,
710 parameters: None,
711 };
712 assert!(run_block(cell).is_empty());
713 }
714
715 #[test]
718 fn link_mark_with_valid_href_validates() {
719 let node = text_with_marks(
720 "hi",
721 vec![mark(
722 "link",
723 Some(serde_json::json!({"href": "https://x.com"})),
724 )],
725 );
726 assert!(run_inline("paragraph", node).is_empty());
727 }
728
729 #[test]
730 fn link_mark_with_invalid_href_flagged() {
731 let node = text_with_marks(
732 "hi",
733 vec![mark("link", Some(serde_json::json!({"href": "not a url"})))],
734 );
735 let v = run_inline("paragraph", node);
736 let invalid: Vec<_> = v
737 .iter()
738 .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
739 .collect();
740 assert_eq!(invalid.len(), 1, "got: {v:?}");
741 }
742
743 #[test]
744 fn link_mark_missing_href_flagged() {
745 let node = text_with_marks("hi", vec![mark("link", Some(serde_json::json!({})))]);
746 let v = run_inline("paragraph", node);
747 let invalid: Vec<_> = v
748 .iter()
749 .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
750 .collect();
751 assert_eq!(invalid.len(), 1);
752 }
753
754 #[test]
755 fn subsup_known_type_validates() {
756 for t in ["sub", "sup"] {
757 let node = text_with_marks(
758 "hi",
759 vec![mark("subsup", Some(serde_json::json!({"type": t})))],
760 );
761 assert!(run_inline("paragraph", node).is_empty());
762 }
763 }
764
765 #[test]
766 fn subsup_unknown_type_flagged() {
767 let node = text_with_marks(
768 "hi",
769 vec![mark("subsup", Some(serde_json::json!({"type": "side"})))],
770 );
771 let v = run_inline("paragraph", node);
772 let invalid: Vec<_> = v
773 .iter()
774 .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
775 .collect();
776 assert_eq!(invalid.len(), 1);
777 }
778
779 #[test]
780 fn indentation_level_in_range() {
781 let node = paragraph_with_marks(
782 vec![mark("indentation", Some(serde_json::json!({"level": 3})))],
783 vec![AdfNode::text("x")],
784 );
785 assert!(run_block(node).is_empty());
786 }
787
788 #[test]
789 fn indentation_level_out_of_range_flagged() {
790 let node = paragraph_with_marks(
791 vec![mark("indentation", Some(serde_json::json!({"level": 10})))],
792 vec![AdfNode::text("x")],
793 );
794 let v = run_block(node);
795 let invalid: Vec<_> = v
796 .iter()
797 .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
798 .collect();
799 assert_eq!(invalid.len(), 1);
800 }
801
802 #[test]
803 fn border_with_size_too_large_flagged() {
804 let cell = AdfNode {
805 node_type: "tableCell".to_string(),
806 attrs: None,
807 content: None,
808 text: None,
809 marks: Some(vec![mark(
810 "border",
811 Some(serde_json::json!({"color": "#ff0000", "size": 5})),
812 )]),
813 local_id: None,
814 parameters: None,
815 };
816 let v = run_block(cell);
817 let invalid: Vec<_> = v
818 .iter()
819 .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
820 .collect();
821 assert_eq!(invalid.len(), 1);
822 }
823
824 #[test]
825 fn empty_marks_array_no_violations() {
826 let node = text_with_marks("hi", vec![]);
827 assert!(run_inline("paragraph", node).is_empty());
828 }
829
830 #[test]
831 fn no_marks_field_no_violations() {
832 let node = AdfNode::text("hi");
833 assert!(run_inline("paragraph", node).is_empty());
834 }
835
836 #[test]
839 fn link_mark_with_array_attrs_flagged_as_disallowed_mark() {
840 let node = text_with_marks(
844 "click",
845 vec![mark("link", Some(serde_json::json!([1, 2, 3])))],
846 );
847 let v = run_inline("paragraph", node);
848 let disallowed: Vec<_> = v
849 .iter()
850 .filter(|v| matches!(v, AdfSchemaViolation::DisallowedMark { .. }))
851 .collect();
852 assert_eq!(disallowed.len(), 1, "got: {v:?}");
853 match disallowed[0] {
854 AdfSchemaViolation::DisallowedMark {
855 mark_type,
856 parent_type,
857 ..
858 } => {
859 assert_eq!(mark_type, "link");
860 assert!(
861 parent_type.contains("malformed attrs"),
862 "expected malformed-attrs sentinel, got: {parent_type}"
863 );
864 }
865 other => panic!("expected DisallowedMark, got {other:?}"),
866 }
867 }
868
869 #[test]
870 fn code_mark_with_array_attrs_no_violation() {
871 let node = text_with_marks("x", vec![mark("code", Some(serde_json::json!([1, 2, 3])))]);
875 assert!(run_inline("paragraph", node).is_empty());
876 }
877
878 fn combos(v: &[AdfSchemaViolation]) -> Vec<(&str, &str)> {
881 v.iter()
882 .filter_map(|x| match x {
883 AdfSchemaViolation::ForbiddenMarkCombination {
884 mark_type,
885 conflicts_with,
886 ..
887 } => Some((mark_type.as_str(), conflicts_with.as_str())),
888 _ => None,
889 })
890 .collect()
891 }
892
893 #[test]
894 fn strong_plus_code_is_forbidden() {
895 let node = text_with_marks("hi", vec![mark("strong", None), mark("code", None)]);
897 let v = run_inline("paragraph", node);
898 assert_eq!(combos(&v), vec![("strong", "code")], "got: {v:?}");
899 }
900
901 #[test]
902 fn code_plus_background_color_is_forbidden() {
903 let node = text_with_marks(
904 "hi",
905 vec![
906 mark("code", None),
907 mark(
908 "backgroundColor",
909 Some(serde_json::json!({"color": "#ff0000"})),
910 ),
911 ],
912 );
913 let v = run_inline("paragraph", node);
914 assert_eq!(combos(&v), vec![("code", "backgroundColor")], "got: {v:?}");
915 }
916
917 #[test]
918 fn code_plus_link_is_allowed() {
919 let node = text_with_marks(
922 "hi",
923 vec![
924 mark("code", None),
925 mark("link", Some(serde_json::json!({"href": "https://x.com"}))),
926 ],
927 );
928 let v = run_inline("paragraph", node);
929 assert!(combos(&v).is_empty(), "got: {v:?}");
930 }
931
932 #[test]
933 fn code_plus_annotation_is_allowed() {
934 let node = text_with_marks(
935 "hi",
936 vec![
937 mark("code", None),
938 mark(
939 "annotation",
940 Some(serde_json::json!({"id": "a1", "annotationType": "inlineComment"})),
941 ),
942 ],
943 );
944 let v = run_inline("paragraph", node);
945 assert!(combos(&v).is_empty(), "got: {v:?}");
946 }
947
948 #[test]
949 fn styling_marks_combine_freely() {
950 let node = text_with_marks(
952 "hi",
953 vec![
954 mark("strong", None),
955 mark("em", None),
956 mark("strike", None),
957 mark("underline", None),
958 ],
959 );
960 let v = run_inline("paragraph", node);
961 assert!(combos(&v).is_empty(), "got: {v:?}");
962 }
963
964 #[test]
965 fn link_plus_text_color_is_allowed() {
966 let node = text_with_marks(
969 "hi",
970 vec![
971 mark("link", Some(serde_json::json!({"href": "https://x.com"}))),
972 mark("textColor", Some(serde_json::json!({"color": "#0000ff"}))),
973 ],
974 );
975 let v = run_inline("paragraph", node);
976 assert!(combos(&v).is_empty(), "got: {v:?}");
977 }
978
979 #[test]
980 fn heading_bold_code_flags_both_disallowed_and_combination() {
981 let node = text_with_marks("hi", vec![mark("strong", None), mark("code", None)]);
986 let v = run_inline("heading", node);
987 assert!(
988 v.iter()
989 .any(|x| matches!(x, AdfSchemaViolation::DisallowedMark { .. })),
990 "expected a DisallowedMark for code-on-heading too, got: {v:?}"
991 );
992 assert_eq!(combos(&v), vec![("strong", "code")], "got: {v:?}");
993 }
994
995 #[test]
996 fn only_first_conflicting_pair_is_reported() {
997 let node = text_with_marks(
999 "hi",
1000 vec![mark("code", None), mark("strong", None), mark("em", None)],
1001 );
1002 let v = run_inline("paragraph", node);
1003 assert_eq!(combos(&v), vec![("code", "strong")], "got: {v:?}");
1004 }
1005
1006 #[test]
1007 fn combination_check_records_parent_and_inline_index() {
1008 let node = text_with_marks("hi", vec![mark("code", None), mark("strong", None)]);
1009 let mut out = Vec::new();
1010 validate_marks("paragraph", &node, &[3_usize], &mut out);
1011 assert_eq!(out.len(), 1, "got: {out:?}");
1012 assert!(
1013 matches!(
1014 &out[0],
1015 AdfSchemaViolation::ForbiddenMarkCombination { parent_type, inline_index, path, .. }
1016 if parent_type == "paragraph" && *inline_index == Some(3) && path.as_slice() == [3]
1017 ),
1018 "got: {out:?}"
1019 );
1020 }
1021
1022 #[test]
1023 fn marks_may_coexist_treats_unknown_marks_as_non_conflicting() {
1024 assert!(marks_may_coexist("code", "madeUpMark"));
1027 assert!(marks_may_coexist("madeUpMark", "strong"));
1028 }
1029
1030 #[test]
1033 fn inline_node_under_unknown_parent_skips_mark_check() {
1034 let node = text_with_marks(
1040 "x",
1041 vec![mark("link", Some(serde_json::json!({"href": "not a url"})))],
1042 );
1043 let v = run_inline("madeUpParent", node);
1044 assert!(v
1046 .iter()
1047 .all(|v| !matches!(v, AdfSchemaViolation::DisallowedMark { .. })));
1048 assert!(v
1050 .iter()
1051 .any(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. })));
1052 }
1053}