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 INLINE_MARKS_ENTRIES: &[(&str, &[&str])] = &[
101 ("caption", CAPTION_INLINE_MARKS),
102 ("codeBlock", CODE_BLOCK_INLINE_MARKS),
103 ("decisionItem", STD_INLINE_MARKS),
104 ("heading", HEADING_INLINE_MARKS),
105 ("paragraph", STD_INLINE_MARKS),
106 ("taskItem", STD_INLINE_MARKS),
107];
108
109const BLOCK_MARKS_ENTRIES: &[(&str, &[&str])] = &[
110 ("heading", HEADING_BLOCK_MARKS),
111 ("paragraph", PARAGRAPH_BLOCK_MARKS),
112 ("tableCell", TABLE_CELL_BLOCK_MARKS),
113 ("tableHeader", TABLE_HEADER_BLOCK_MARKS),
114];
115
116static INLINE_MARKS: LazyLock<HashMap<&'static str, &'static [&'static str]>> =
117 LazyLock::new(|| INLINE_MARKS_ENTRIES.iter().copied().collect());
118
119static BLOCK_MARKS: LazyLock<HashMap<&'static str, &'static [&'static str]>> =
120 LazyLock::new(|| BLOCK_MARKS_ENTRIES.iter().copied().collect());
121
122const INLINE_NODE_TYPES: &[&str] = &[
126 "date",
127 "emoji",
128 "hardBreak",
129 "inlineCard",
130 "inlineExtension",
131 "mediaInline",
132 "mention",
133 "placeholder",
134 "status",
135 "text",
136];
137
138#[must_use]
142pub fn allowed_inline_marks(parent: &str) -> Option<&'static [&'static str]> {
143 INLINE_MARKS.get(parent).copied()
144}
145
146#[must_use]
149pub fn allowed_block_marks(node_type: &str) -> Option<&'static [&'static str]> {
150 BLOCK_MARKS.get(node_type).copied()
151}
152
153#[must_use]
157pub fn is_inline_node(node_type: &str) -> bool {
158 INLINE_NODE_TYPES.contains(&node_type)
159}
160
161fn is_unsupported_mark(mark_type: &str) -> bool {
164 mark_type == "unsupportedMark" || mark_type == "unsupportedNodeAttribute"
165}
166
167const ENUM_SUBSUP_TYPE: &[&str] = &["sub", "sup"];
172const ENUM_ALIGNMENT_ALIGN: &[&str] = &["start", "end", "center", "right", "left"];
173const ENUM_BREAKOUT_MODE: &[&str] = &["wide", "full-width"];
174
175type MarkAttrEntry = (&'static str, AttrSchema);
176
177const MARK_ATTR_ENTRIES: &[MarkAttrEntry] = &[
178 (
180 "alignment",
181 AttrSchema {
182 fields: &[(
183 "align",
184 AttrType::Enum(ENUM_ALIGNMENT_ALIGN),
185 AttrPresence::Required,
186 )],
187 },
188 ),
189 (
191 "annotation",
192 AttrSchema {
193 fields: &[
194 ("id", AttrType::String, AttrPresence::Required),
195 ("annotationType", AttrType::String, AttrPresence::Required),
196 ],
197 },
198 ),
199 (
202 "backgroundColor",
203 AttrSchema {
204 fields: &[("color", AttrType::String, AttrPresence::Required)],
205 },
206 ),
207 (
209 "border",
210 AttrSchema {
211 fields: &[
212 ("color", AttrType::String, AttrPresence::Required),
213 ("size", AttrType::IntRange(1, 3), AttrPresence::Required),
214 ],
215 },
216 ),
217 (
219 "breakout",
220 AttrSchema {
221 fields: &[(
222 "mode",
223 AttrType::Enum(ENUM_BREAKOUT_MODE),
224 AttrPresence::Required,
225 )],
226 },
227 ),
228 ("code", AttrSchema { fields: &[] }),
230 ("em", AttrSchema { fields: &[] }),
232 (
234 "indentation",
235 AttrSchema {
236 fields: &[("level", AttrType::IntRange(1, 6), AttrPresence::Required)],
237 },
238 ),
239 (
243 "link",
244 AttrSchema {
245 fields: &[
246 ("href", AttrType::Url, AttrPresence::Required),
247 ("title", AttrType::String, AttrPresence::Optional),
248 ("id", AttrType::String, AttrPresence::Optional),
249 ("collection", AttrType::String, AttrPresence::Optional),
250 ("occurrenceKey", AttrType::String, AttrPresence::Optional),
251 ],
252 },
253 ),
254 ("strike", AttrSchema { fields: &[] }),
256 ("strong", AttrSchema { fields: &[] }),
258 (
260 "subsup",
261 AttrSchema {
262 fields: &[(
263 "type",
264 AttrType::Enum(ENUM_SUBSUP_TYPE),
265 AttrPresence::Required,
266 )],
267 },
268 ),
269 (
272 "textColor",
273 AttrSchema {
274 fields: &[("color", AttrType::String, AttrPresence::Required)],
275 },
276 ),
277 ("underline", AttrSchema { fields: &[] }),
279];
280
281static MARK_ATTR_SCHEMAS: LazyLock<HashMap<&'static str, &'static AttrSchema>> =
282 LazyLock::new(|| {
283 MARK_ATTR_ENTRIES
284 .iter()
285 .map(|(mark_type, schema)| (*mark_type, schema))
286 .collect()
287 });
288
289#[must_use]
296pub fn mark_attr_schema(mark_type: &str) -> Option<&'static AttrSchema> {
297 MARK_ATTR_SCHEMAS.get(mark_type).copied()
298}
299
300pub fn validate_marks(
325 parent_type: &str,
326 node: &crate::atlassian::adf::AdfNode,
327 path: &[usize],
328 out: &mut Vec<AdfSchemaViolation>,
329) {
330 let Some(marks) = node.marks.as_ref() else {
331 return;
332 };
333 if marks.is_empty() {
334 return;
335 }
336
337 let node_type = node.node_type.as_str();
338 let allowed = if is_inline_node(node_type) {
339 allowed_inline_marks(parent_type)
340 } else {
341 allowed_block_marks(node_type)
342 };
343
344 for (mark_idx, mark) in marks.iter().enumerate() {
345 let mark_type = mark.mark_type.as_str();
346
347 if is_unsupported_mark(mark_type) {
348 continue;
349 }
350
351 if let Some(allowed) = allowed {
352 if !allowed.contains(&mark_type) {
353 out.push(AdfSchemaViolation::DisallowedMark {
354 mark_type: mark_type.to_string(),
355 parent_type: if is_inline_node(node_type) {
356 parent_type.to_string()
357 } else {
358 node_type.to_string()
359 },
360 inline_index: if is_inline_node(node_type) {
361 Some(*path.last().unwrap_or(&0))
362 } else {
363 None
364 },
365 path: path.to_vec(),
366 });
367 continue;
371 }
372 }
373
374 if let Some(schema) = mark_attr_schema(mark_type) {
375 validate_mark_attrs_against(
376 schema,
377 mark_type,
378 mark.attrs.as_ref(),
379 mark_idx,
380 path,
381 out,
382 );
383 }
384 }
385}
386
387fn validate_mark_attrs_against(
388 schema: &AttrSchema,
389 mark_type: &str,
390 attrs: Option<&Value>,
391 mark_idx: usize,
392 path: &[usize],
393 out: &mut Vec<AdfSchemaViolation>,
394) {
395 let mut tmp: Vec<AdfSchemaViolation> = Vec::new();
399 crate::atlassian::adf_attr_schema::validate_attrs(
400 "<__adf_mark_inline__>",
405 attrs,
406 path,
407 &mut tmp,
408 );
409 debug_assert!(
410 tmp.is_empty(),
411 "sentinel must not match a registered schema"
412 );
413
414 let attr_obj = match attrs {
416 Some(Value::Object(map)) => Some(map),
417 Some(Value::Null) | None => None,
418 Some(_other) => {
419 for (field, _ty, presence) in schema.fields {
420 if *presence == AttrPresence::Required {
421 out.push(AdfSchemaViolation::DisallowedMark {
422 mark_type: mark_type.to_string(),
423 parent_type: format!("<malformed attrs for mark '{mark_type}'>"),
424 inline_index: Some(mark_idx),
425 path: path.to_vec(),
426 });
427 let _ = field;
428 return;
429 }
430 }
431 return;
432 }
433 };
434
435 for (field, ty, presence) in schema.fields {
436 let value = attr_obj.and_then(|m| m.get(*field));
437 let value = match value {
438 Some(Value::Null) | None => None,
439 Some(v) => Some(v),
440 };
441
442 match (value, *presence) {
443 (None, AttrPresence::Required) => {
444 out.push(AdfSchemaViolation::InvalidMarkAttr {
445 mark_type: mark_type.to_string(),
446 attr_name: (*field).to_string(),
447 problem: crate::atlassian::adf_attr_schema::AttrProblem::WrongType {
448 expected: "present",
449 },
450 inline_index: Some(mark_idx),
451 path: path.to_vec(),
452 });
453 }
454 (None, AttrPresence::Optional) => {}
455 (Some(v), _) => {
456 if let Some(problem) = crate::atlassian::adf_attr_schema::check_value(ty, v) {
457 out.push(AdfSchemaViolation::InvalidMarkAttr {
458 mark_type: mark_type.to_string(),
459 attr_name: (*field).to_string(),
460 problem,
461 inline_index: Some(mark_idx),
462 path: path.to_vec(),
463 });
464 }
465 }
466 }
467 }
468}
469
470#[cfg(test)]
471#[allow(clippy::unwrap_used, clippy::expect_used, clippy::needless_collect)]
472mod tests {
473 use super::*;
474 use crate::atlassian::adf::{AdfMark, AdfNode};
475
476 fn text_with_marks(text: &str, marks: Vec<AdfMark>) -> AdfNode {
477 AdfNode {
478 node_type: "text".to_string(),
479 attrs: None,
480 content: None,
481 text: Some(text.to_string()),
482 marks: Some(marks),
483 local_id: None,
484 parameters: None,
485 }
486 }
487
488 fn paragraph_with_marks(marks: Vec<AdfMark>, content: Vec<AdfNode>) -> AdfNode {
489 AdfNode {
490 node_type: "paragraph".to_string(),
491 attrs: None,
492 content: if content.is_empty() {
493 None
494 } else {
495 Some(content)
496 },
497 text: None,
498 marks: Some(marks),
499 local_id: None,
500 parameters: None,
501 }
502 }
503
504 fn mark(mark_type: &str, attrs: Option<serde_json::Value>) -> AdfMark {
505 AdfMark {
506 mark_type: mark_type.to_string(),
507 attrs,
508 }
509 }
510
511 fn run_inline(parent: &str, child: AdfNode) -> Vec<AdfSchemaViolation> {
512 let mut out = Vec::new();
513 validate_marks(parent, &child, &[0_usize], &mut out);
514 out
515 }
516
517 fn run_block(node: AdfNode) -> Vec<AdfSchemaViolation> {
518 let mut out = Vec::new();
519 validate_marks("doc", &node, &[0_usize], &mut out);
520 out
521 }
522
523 #[test]
526 fn paragraph_allows_code_mark_on_text() {
527 let node = text_with_marks("hi", vec![mark("code", None)]);
528 assert!(run_inline("paragraph", node).is_empty());
529 }
530
531 #[test]
532 fn heading_rejects_code_mark_on_text() {
533 let node = text_with_marks("hi", vec![mark("code", None)]);
534 let v = run_inline("heading", node);
535 assert_eq!(v.len(), 1, "got: {v:?}");
536 match &v[0] {
537 AdfSchemaViolation::DisallowedMark {
538 mark_type,
539 parent_type,
540 ..
541 } => {
542 assert_eq!(mark_type, "code");
543 assert_eq!(parent_type, "heading");
544 }
545 other => panic!("expected DisallowedMark, got {other:?}"),
546 }
547 }
548
549 #[test]
550 fn code_block_rejects_any_mark_on_text() {
551 let node = text_with_marks("hi", vec![mark("strong", None)]);
552 let v = run_inline("codeBlock", node);
553 assert_eq!(v.len(), 1);
554 }
555
556 #[test]
557 fn unknown_parent_skips_mark_validation() {
558 let node = text_with_marks("hi", vec![mark("madeUp", None)]);
559 assert!(run_inline("madeUpParent", node).is_empty());
560 }
561
562 #[test]
563 fn unsupported_mark_accepted_anywhere() {
564 let node = text_with_marks(
565 "hi",
566 vec![
567 mark("unsupportedMark", None),
568 mark("unsupportedNodeAttribute", None),
569 ],
570 );
571 assert!(run_inline("heading", node).is_empty());
572 }
573
574 #[test]
577 fn paragraph_block_allows_alignment() {
578 let node = paragraph_with_marks(
579 vec![mark(
580 "alignment",
581 Some(serde_json::json!({"align": "center"})),
582 )],
583 vec![AdfNode::text("x")],
584 );
585 assert!(run_block(node).is_empty());
586 }
587
588 #[test]
589 fn paragraph_block_rejects_border() {
590 let node = paragraph_with_marks(
592 vec![mark(
593 "border",
594 Some(serde_json::json!({"color": "#ff0000", "size": 1})),
595 )],
596 vec![AdfNode::text("x")],
597 );
598 let v = run_block(node);
599 let disallowed: Vec<_> = v
600 .iter()
601 .filter(|v| matches!(v, AdfSchemaViolation::DisallowedMark { .. }))
602 .collect();
603 assert_eq!(disallowed.len(), 1, "got: {v:?}");
604 }
605
606 #[test]
607 fn table_cell_allows_border() {
608 let cell = AdfNode {
609 node_type: "tableCell".to_string(),
610 attrs: None,
611 content: None,
612 text: None,
613 marks: Some(vec![mark(
614 "border",
615 Some(serde_json::json!({"color": "#ff0000", "size": 2})),
616 )]),
617 local_id: None,
618 parameters: None,
619 };
620 assert!(run_block(cell).is_empty());
621 }
622
623 #[test]
626 fn link_mark_with_valid_href_validates() {
627 let node = text_with_marks(
628 "hi",
629 vec![mark(
630 "link",
631 Some(serde_json::json!({"href": "https://x.com"})),
632 )],
633 );
634 assert!(run_inline("paragraph", node).is_empty());
635 }
636
637 #[test]
638 fn link_mark_with_invalid_href_flagged() {
639 let node = text_with_marks(
640 "hi",
641 vec![mark("link", Some(serde_json::json!({"href": "not a url"})))],
642 );
643 let v = run_inline("paragraph", node);
644 let invalid: Vec<_> = v
645 .iter()
646 .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
647 .collect();
648 assert_eq!(invalid.len(), 1, "got: {v:?}");
649 }
650
651 #[test]
652 fn link_mark_missing_href_flagged() {
653 let node = text_with_marks("hi", vec![mark("link", Some(serde_json::json!({})))]);
654 let v = run_inline("paragraph", node);
655 let invalid: Vec<_> = v
656 .iter()
657 .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
658 .collect();
659 assert_eq!(invalid.len(), 1);
660 }
661
662 #[test]
663 fn subsup_known_type_validates() {
664 for t in ["sub", "sup"] {
665 let node = text_with_marks(
666 "hi",
667 vec![mark("subsup", Some(serde_json::json!({"type": t})))],
668 );
669 assert!(run_inline("paragraph", node).is_empty());
670 }
671 }
672
673 #[test]
674 fn subsup_unknown_type_flagged() {
675 let node = text_with_marks(
676 "hi",
677 vec![mark("subsup", Some(serde_json::json!({"type": "side"})))],
678 );
679 let v = run_inline("paragraph", node);
680 let invalid: Vec<_> = v
681 .iter()
682 .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
683 .collect();
684 assert_eq!(invalid.len(), 1);
685 }
686
687 #[test]
688 fn indentation_level_in_range() {
689 let node = paragraph_with_marks(
690 vec![mark("indentation", Some(serde_json::json!({"level": 3})))],
691 vec![AdfNode::text("x")],
692 );
693 assert!(run_block(node).is_empty());
694 }
695
696 #[test]
697 fn indentation_level_out_of_range_flagged() {
698 let node = paragraph_with_marks(
699 vec![mark("indentation", Some(serde_json::json!({"level": 10})))],
700 vec![AdfNode::text("x")],
701 );
702 let v = run_block(node);
703 let invalid: Vec<_> = v
704 .iter()
705 .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
706 .collect();
707 assert_eq!(invalid.len(), 1);
708 }
709
710 #[test]
711 fn border_with_size_too_large_flagged() {
712 let cell = AdfNode {
713 node_type: "tableCell".to_string(),
714 attrs: None,
715 content: None,
716 text: None,
717 marks: Some(vec![mark(
718 "border",
719 Some(serde_json::json!({"color": "#ff0000", "size": 5})),
720 )]),
721 local_id: None,
722 parameters: None,
723 };
724 let v = run_block(cell);
725 let invalid: Vec<_> = v
726 .iter()
727 .filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
728 .collect();
729 assert_eq!(invalid.len(), 1);
730 }
731
732 #[test]
733 fn empty_marks_array_no_violations() {
734 let node = text_with_marks("hi", vec![]);
735 assert!(run_inline("paragraph", node).is_empty());
736 }
737
738 #[test]
739 fn no_marks_field_no_violations() {
740 let node = AdfNode::text("hi");
741 assert!(run_inline("paragraph", node).is_empty());
742 }
743
744 #[test]
747 fn link_mark_with_array_attrs_flagged_as_disallowed_mark() {
748 let node = text_with_marks(
752 "click",
753 vec![mark("link", Some(serde_json::json!([1, 2, 3])))],
754 );
755 let v = run_inline("paragraph", node);
756 let disallowed: Vec<_> = v
757 .iter()
758 .filter(|v| matches!(v, AdfSchemaViolation::DisallowedMark { .. }))
759 .collect();
760 assert_eq!(disallowed.len(), 1, "got: {v:?}");
761 match disallowed[0] {
762 AdfSchemaViolation::DisallowedMark {
763 mark_type,
764 parent_type,
765 ..
766 } => {
767 assert_eq!(mark_type, "link");
768 assert!(
769 parent_type.contains("malformed attrs"),
770 "expected malformed-attrs sentinel, got: {parent_type}"
771 );
772 }
773 other => panic!("expected DisallowedMark, got {other:?}"),
774 }
775 }
776
777 #[test]
778 fn code_mark_with_array_attrs_no_violation() {
779 let node = text_with_marks("x", vec![mark("code", Some(serde_json::json!([1, 2, 3])))]);
783 assert!(run_inline("paragraph", node).is_empty());
784 }
785
786 #[test]
789 fn inline_node_under_unknown_parent_skips_mark_check() {
790 let node = text_with_marks(
796 "x",
797 vec![mark("link", Some(serde_json::json!({"href": "not a url"})))],
798 );
799 let v = run_inline("madeUpParent", node);
800 assert!(v
802 .iter()
803 .all(|v| !matches!(v, AdfSchemaViolation::DisallowedMark { .. })));
804 assert!(v
806 .iter()
807 .any(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. })));
808 }
809}