1use std::collections::{BTreeMap, BTreeSet, HashMap};
42use std::sync::LazyLock;
43
44use crate::atlassian::adf::{AdfDocument, AdfNode};
45
46pub mod drift;
47pub mod generated;
48
49#[must_use]
57pub(crate) fn local_schema_map() -> BTreeMap<&'static str, BTreeSet<&'static str>> {
58 let mut m = BTreeMap::new();
59 for (parent, terms) in CONTENT_ENTRIES {
60 let children: BTreeSet<&'static str> =
61 terms.iter().flat_map(|t| t.atoms.iter().copied()).collect();
62 m.insert(*parent, children);
63 }
64 m
65}
66
67pub const SCHEMA_VERSION: &str = "56.0.9-2026-06-30";
72
73pub const UPSTREAM_TARBALL_SHA256: &str =
86 "a8345cc658048efd681c927814498d2cb83f1c75094ae2528f788b4aaa6a5c1f";
87
88#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum Quantifier {
100 ZeroOrOne,
102 ZeroOrMore,
104 OneOrMore,
106 Exactly(usize),
108 Range(usize, usize),
110}
111
112impl Quantifier {
113 #[must_use]
115 pub fn satisfied_by(&self, n: usize) -> bool {
116 match *self {
117 Self::ZeroOrOne => n <= 1,
118 Self::ZeroOrMore => true,
119 Self::OneOrMore => n >= 1,
120 Self::Exactly(k) => n == k,
121 Self::Range(lo, hi) => n >= lo && n <= hi,
122 }
123 }
124
125 fn phrasing(&self) -> String {
127 match *self {
128 Self::ZeroOrOne => "at most one".to_string(),
129 Self::ZeroOrMore => "any number of".to_string(),
130 Self::OneOrMore => "at least one".to_string(),
131 Self::Exactly(1) => "exactly one".to_string(),
132 Self::Exactly(n) => format!("exactly {n}"),
133 Self::Range(lo, hi) => format!("between {lo} and {hi}"),
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct ContentTerm {
142 pub atoms: &'static [&'static str],
145 pub quant: Quantifier,
147}
148
149#[derive(Debug, Clone, PartialEq)]
161pub enum AdfSchemaViolation {
162 DisallowedChild {
164 child_type: String,
166 parent_type: String,
168 path: Vec<usize>,
173 },
174
175 Arity {
186 parent_type: String,
188 atoms: Vec<&'static str>,
191 expected: Quantifier,
193 actual: usize,
195 path: Vec<usize>,
197 },
198
199 MissingAttr {
203 node_type: String,
205 attr_name: String,
207 path: Vec<usize>,
209 },
210
211 InvalidAttr {
217 node_type: String,
219 attr_name: String,
221 problem: crate::atlassian::adf_attr_schema::AttrProblem,
223 path: Vec<usize>,
225 },
226
227 DisallowedMark {
232 mark_type: String,
234 parent_type: String,
239 inline_index: Option<usize>,
242 path: Vec<usize>,
245 },
246
247 InvalidMarkAttr {
254 mark_type: String,
256 attr_name: String,
258 problem: crate::atlassian::adf_attr_schema::AttrProblem,
260 inline_index: Option<usize>,
263 path: Vec<usize>,
266 },
267
268 ForbiddenMarkCombination {
278 mark_type: String,
280 conflicts_with: String,
282 parent_type: String,
284 inline_index: Option<usize>,
286 path: Vec<usize>,
288 },
289}
290
291impl AdfSchemaViolation {
292 #[must_use]
298 pub fn path(&self) -> &[usize] {
299 match self {
300 Self::DisallowedChild { path, .. }
301 | Self::Arity { path, .. }
302 | Self::MissingAttr { path, .. }
303 | Self::InvalidAttr { path, .. }
304 | Self::DisallowedMark { path, .. }
305 | Self::InvalidMarkAttr { path, .. }
306 | Self::ForbiddenMarkCombination { path, .. } => path,
307 }
308 }
309}
310
311impl std::fmt::Display for AdfSchemaViolation {
312 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313 let path_str = self
314 .path()
315 .iter()
316 .map(usize::to_string)
317 .collect::<Vec<_>>()
318 .join("/");
319 match self {
320 Self::DisallowedChild {
321 child_type,
322 parent_type,
323 ..
324 } => write!(
325 f,
326 "ADF schema violation at /{path_str}: '{child_type}' is not permitted inside '{parent_type}'",
327 ),
328 Self::Arity {
329 parent_type,
330 atoms,
331 expected,
332 actual,
333 ..
334 } => write!(
335 f,
336 "ADF schema violation at /{path_str}: '{parent_type}' must contain {phrasing} {atoms_str} (found {actual})",
337 phrasing = expected.phrasing(),
338 atoms_str = format_atoms(atoms),
339 ),
340 Self::MissingAttr {
341 node_type,
342 attr_name,
343 ..
344 } => write!(
345 f,
346 "ADF schema violation at /{path_str}: '{node_type}' is missing required attribute '{attr_name}'",
347 ),
348 Self::InvalidAttr {
349 node_type,
350 attr_name,
351 problem,
352 ..
353 } => write!(
354 f,
355 "ADF schema violation at /{path_str}: '{node_type}.{attr_name}' is invalid — {problem}",
356 ),
357 Self::DisallowedMark {
358 mark_type,
359 parent_type,
360 ..
361 } => write!(
362 f,
363 "ADF schema violation at /{path_str}: '{mark_type}' mark is not permitted on '{parent_type}'",
364 ),
365 Self::InvalidMarkAttr {
366 mark_type,
367 attr_name,
368 problem,
369 ..
370 } => write!(
371 f,
372 "ADF schema violation at /{path_str}: '{mark_type}' mark's '{attr_name}' is invalid — {problem}",
373 ),
374 Self::ForbiddenMarkCombination {
375 mark_type,
376 conflicts_with,
377 ..
378 } => write!(
379 f,
380 "ADF schema violation at /{path_str}: '{mark_type}' mark cannot be combined with '{conflicts_with}' mark on the same text",
381 ),
382 }
383 }
384}
385
386fn format_atoms(atoms: &[&str]) -> String {
387 if atoms.len() == 1 {
388 format!("'{}'", atoms[0])
389 } else {
390 let inner = atoms
391 .iter()
392 .map(|a| format!("'{a}'"))
393 .collect::<Vec<_>>()
394 .join(", ");
395 format!("{{{inner}}}")
396 }
397}
398
399const FULL_INLINE_ATOMS: &[&str] = &[
409 "date",
410 "emoji",
411 "hardBreak",
412 "inlineCard",
413 "inlineExtension",
414 "mediaInline",
415 "mention",
416 "placeholder",
417 "status",
418 "text",
419];
420
421const CAPTION_INLINE_ATOMS: &[&str] = &[
422 "date",
423 "emoji",
424 "hardBreak",
425 "inlineCard",
426 "mention",
427 "placeholder",
428 "status",
429 "text",
430];
431
432const LISTITEM_BLOCK_ATOMS: &[&str] = &[
433 "bulletList",
434 "codeBlock",
435 "extension",
436 "mediaSingle",
437 "orderedList",
438 "paragraph",
439 "taskList",
440];
441
442const PANEL_BLOCK_ATOMS: &[&str] = &[
443 "blockCard",
444 "bulletList",
445 "codeBlock",
446 "decisionList",
447 "extension",
448 "heading",
449 "mediaGroup",
450 "mediaSingle",
451 "orderedList",
452 "paragraph",
453 "rule",
454 "taskList",
455];
456
457const NESTED_EXPAND_BLOCK_ATOMS: &[&str] = &[
458 "blockquote",
459 "bulletList",
460 "codeBlock",
461 "decisionList",
462 "extension",
463 "heading",
464 "mediaGroup",
465 "mediaSingle",
466 "orderedList",
467 "panel",
468 "paragraph",
469 "rule",
470 "taskList",
471];
472
473const EXPAND_BLOCK_ATOMS: &[&str] = &[
474 "blockCard",
475 "blockquote",
476 "bulletList",
477 "codeBlock",
478 "decisionList",
479 "embedCard",
480 "extension",
481 "heading",
482 "mediaGroup",
483 "mediaSingle",
484 "nestedExpand",
485 "orderedList",
486 "panel",
487 "paragraph",
488 "rule",
489 "table",
490 "taskList",
491];
492
493const BODIED_EXTENSION_BLOCK_ATOMS: &[&str] = &[
494 "blockCard",
495 "blockquote",
496 "bulletList",
497 "codeBlock",
498 "decisionList",
499 "embedCard",
500 "extension",
501 "heading",
502 "mediaGroup",
503 "mediaSingle",
504 "orderedList",
505 "panel",
506 "paragraph",
507 "rule",
508 "table",
509 "taskList",
510];
511
512const BODIED_SYNC_BLOCK_ATOMS: &[&str] = &[
513 "blockCard",
514 "blockquote",
515 "bulletList",
516 "codeBlock",
517 "decisionList",
518 "embedCard",
519 "expand",
520 "heading",
521 "layoutSection",
522 "mediaGroup",
523 "mediaSingle",
524 "orderedList",
525 "panel",
526 "paragraph",
527 "rule",
528 "table",
529 "taskList",
530];
531
532const LAYOUT_COLUMN_BLOCK_ATOMS: &[&str] = &[
533 "blockCard",
534 "blockquote",
535 "bodiedExtension",
536 "bulletList",
537 "codeBlock",
538 "decisionList",
539 "embedCard",
540 "expand",
541 "extension",
542 "heading",
543 "mediaGroup",
544 "mediaSingle",
545 "orderedList",
546 "panel",
547 "paragraph",
548 "rule",
549 "table",
550 "taskList",
551];
552
553const TABLE_CELL_BLOCK_ATOMS: &[&str] = &[
554 "blockCard",
555 "blockquote",
556 "bulletList",
557 "codeBlock",
558 "decisionList",
559 "embedCard",
560 "extension",
561 "heading",
562 "mediaGroup",
563 "mediaSingle",
564 "nestedExpand",
565 "orderedList",
566 "panel",
567 "paragraph",
568 "rule",
569 "taskList",
570];
571
572const DOC_BLOCK_ATOMS: &[&str] = &[
573 "blockCard",
574 "blockquote",
575 "bodiedExtension",
576 "bodiedSyncBlock",
577 "bulletList",
578 "codeBlock",
579 "decisionList",
580 "embedCard",
581 "expand",
582 "extension",
583 "heading",
584 "layoutSection",
585 "mediaGroup",
586 "mediaSingle",
587 "orderedList",
588 "panel",
589 "paragraph",
590 "rule",
591 "syncBlock",
592 "table",
593 "taskList",
594];
595
596pub(crate) type ModelEntry = (&'static str, &'static [ContentTerm]);
627
628pub(crate) const CONTENT_ENTRIES: &[ModelEntry] = &[
629 (
632 "blockTaskItem",
633 &[ContentTerm {
634 atoms: &["extension", "paragraph"],
635 quant: Quantifier::OneOrMore,
636 }],
637 ),
638 (
642 "blockquote",
643 &[ContentTerm {
644 atoms: &[
645 "bulletList",
646 "codeBlock",
647 "extension",
648 "mediaGroup",
649 "mediaSingle",
650 "orderedList",
651 "paragraph",
652 ],
653 quant: Quantifier::OneOrMore,
654 }],
655 ),
656 (
658 "bodiedExtension",
659 &[ContentTerm {
660 atoms: BODIED_EXTENSION_BLOCK_ATOMS,
661 quant: Quantifier::OneOrMore,
662 }],
663 ),
664 (
666 "bodiedSyncBlock",
667 &[ContentTerm {
668 atoms: BODIED_SYNC_BLOCK_ATOMS,
669 quant: Quantifier::OneOrMore,
670 }],
671 ),
672 (
675 "bulletList",
676 &[ContentTerm {
677 atoms: &["listItem"],
678 quant: Quantifier::OneOrMore,
679 }],
680 ),
681 (
685 "caption",
686 &[ContentTerm {
687 atoms: CAPTION_INLINE_ATOMS,
688 quant: Quantifier::ZeroOrMore,
689 }],
690 ),
691 (
695 "codeBlock",
696 &[ContentTerm {
697 atoms: &["text"],
698 quant: Quantifier::ZeroOrMore,
699 }],
700 ),
701 (
704 "decisionItem",
705 &[ContentTerm {
706 atoms: FULL_INLINE_ATOMS,
707 quant: Quantifier::ZeroOrMore,
708 }],
709 ),
710 (
713 "decisionList",
714 &[ContentTerm {
715 atoms: &["decisionItem"],
716 quant: Quantifier::OneOrMore,
717 }],
718 ),
719 (
723 "doc",
724 &[ContentTerm {
725 atoms: DOC_BLOCK_ATOMS,
726 quant: Quantifier::ZeroOrMore,
727 }],
728 ),
729 (
732 "expand",
733 &[ContentTerm {
734 atoms: EXPAND_BLOCK_ATOMS,
735 quant: Quantifier::OneOrMore,
736 }],
737 ),
738 (
741 "heading",
742 &[ContentTerm {
743 atoms: FULL_INLINE_ATOMS,
744 quant: Quantifier::ZeroOrMore,
745 }],
746 ),
747 (
751 "layoutColumn",
752 &[ContentTerm {
753 atoms: LAYOUT_COLUMN_BLOCK_ATOMS,
754 quant: Quantifier::OneOrMore,
755 }],
756 ),
757 (
760 "layoutSection",
761 &[ContentTerm {
762 atoms: &["layoutColumn"],
763 quant: Quantifier::Range(2, 3),
764 }],
765 ),
766 (
773 "listItem",
774 &[ContentTerm {
775 atoms: LISTITEM_BLOCK_ATOMS,
776 quant: Quantifier::OneOrMore,
777 }],
778 ),
779 (
782 "mediaGroup",
783 &[ContentTerm {
784 atoms: &["media"],
785 quant: Quantifier::OneOrMore,
786 }],
787 ),
788 (
792 "mediaSingle",
793 &[
794 ContentTerm {
795 atoms: &["media"],
796 quant: Quantifier::Exactly(1),
797 },
798 ContentTerm {
799 atoms: &["caption"],
800 quant: Quantifier::ZeroOrOne,
801 },
802 ],
803 ),
804 (
808 "nestedExpand",
809 &[ContentTerm {
810 atoms: NESTED_EXPAND_BLOCK_ATOMS,
811 quant: Quantifier::OneOrMore,
812 }],
813 ),
814 (
817 "orderedList",
818 &[ContentTerm {
819 atoms: &["listItem"],
820 quant: Quantifier::OneOrMore,
821 }],
822 ),
823 (
826 "panel",
827 &[ContentTerm {
828 atoms: PANEL_BLOCK_ATOMS,
829 quant: Quantifier::OneOrMore,
830 }],
831 ),
832 (
835 "paragraph",
836 &[ContentTerm {
837 atoms: FULL_INLINE_ATOMS,
838 quant: Quantifier::ZeroOrMore,
839 }],
840 ),
841 (
844 "table",
845 &[ContentTerm {
846 atoms: &["tableRow"],
847 quant: Quantifier::OneOrMore,
848 }],
849 ),
850 (
854 "tableCell",
855 &[ContentTerm {
856 atoms: TABLE_CELL_BLOCK_ATOMS,
857 quant: Quantifier::ZeroOrMore,
858 }],
859 ),
860 (
863 "tableHeader",
864 &[ContentTerm {
865 atoms: TABLE_CELL_BLOCK_ATOMS,
866 quant: Quantifier::ZeroOrMore,
867 }],
868 ),
869 (
872 "tableRow",
873 &[ContentTerm {
874 atoms: &["tableCell", "tableHeader"],
875 quant: Quantifier::OneOrMore,
876 }],
877 ),
878 (
881 "taskItem",
882 &[ContentTerm {
883 atoms: FULL_INLINE_ATOMS,
884 quant: Quantifier::ZeroOrMore,
885 }],
886 ),
887 (
890 "taskList",
891 &[ContentTerm {
892 atoms: &["blockTaskItem", "taskItem", "taskList"],
893 quant: Quantifier::OneOrMore,
894 }],
895 ),
896];
897
898const UNSUPPORTED_NODES: &[&str] = &["unsupportedBlock", "unsupportedInline"];
905
906fn is_unsupported(node_type: &str) -> bool {
907 UNSUPPORTED_NODES.contains(&node_type)
908}
909
910static CONTENT_MODELS: LazyLock<HashMap<&'static str, &'static [ContentTerm]>> =
911 LazyLock::new(|| CONTENT_ENTRIES.iter().copied().collect());
912
913static ALLOWED_CHILDREN: LazyLock<HashMap<&'static str, Vec<&'static str>>> = LazyLock::new(|| {
917 CONTENT_ENTRIES
918 .iter()
919 .map(|(parent, terms)| {
920 let mut atoms: Vec<&'static str> =
921 terms.iter().flat_map(|t| t.atoms.iter().copied()).collect();
922 atoms.sort_unstable();
923 atoms.dedup();
924 (*parent, atoms)
925 })
926 .collect()
927});
928
929#[must_use]
939pub fn allowed_children(parent: &str) -> Option<&'static [&'static str]> {
940 ALLOWED_CHILDREN.get(parent).map(Vec::as_slice)
941}
942
943#[must_use]
946pub fn content_model(parent: &str) -> Option<&'static [ContentTerm]> {
947 CONTENT_MODELS.get(parent).copied()
948}
949
950#[must_use]
957pub fn permits_child(parent: &str, child: &str) -> bool {
958 if is_unsupported(child) {
959 return true;
960 }
961 match allowed_children(parent) {
962 Some(children) => children.contains(&child),
963 None => true,
964 }
965}
966
967#[must_use]
976pub fn validate_document(doc: &AdfDocument) -> Vec<AdfSchemaViolation> {
977 let mut violations = Vec::new();
978 let mut path = Vec::new();
979 if let Some(model) = content_model(&doc.doc_type) {
980 walk_children(
981 &doc.content,
982 &doc.doc_type,
983 model,
984 &mut path,
985 &mut violations,
986 );
987 }
988 violations
989}
990
991fn walk_children(
999 children: &[AdfNode],
1000 parent_type: &str,
1001 model: &[ContentTerm],
1002 path: &mut Vec<usize>,
1003 out: &mut Vec<AdfSchemaViolation>,
1004) {
1005 let mut term_counts: Vec<usize> = vec![0; model.len()];
1007 let mut current_term: usize = 0;
1012
1013 for (idx, child) in children.iter().enumerate() {
1014 path.push(idx);
1015
1016 let child_type = child.node_type.as_str();
1017
1018 crate::atlassian::adf_attr_schema::validate_attrs(
1023 child_type,
1024 child.attrs.as_ref(),
1025 path,
1026 out,
1027 );
1028
1029 crate::atlassian::adf_mark_schema::validate_marks(parent_type, child, path, out);
1034
1035 if is_unsupported(child_type) {
1036 if current_term < model.len() {
1040 term_counts[current_term] += 1;
1041 }
1042 } else {
1043 let mut matched: Option<usize> = None;
1047 let mut try_idx = current_term;
1048 while try_idx < model.len() {
1049 if model[try_idx].atoms.contains(&child_type) {
1050 matched = Some(try_idx);
1051 break;
1052 }
1053 try_idx += 1;
1054 }
1055
1056 match matched {
1057 Some(t) => {
1058 term_counts[t] += 1;
1059 current_term = t;
1060 }
1061 None => {
1062 out.push(AdfSchemaViolation::DisallowedChild {
1063 child_type: child_type.to_string(),
1064 parent_type: parent_type.to_string(),
1065 path: path.clone(),
1066 });
1067 }
1073 }
1074 }
1075
1076 if let Some(grand_model) = content_model(child_type) {
1081 let grand = child.content.as_deref().unwrap_or(&[]);
1082 walk_children(grand, child_type, grand_model, path, out);
1083 }
1084
1085 path.pop();
1086 }
1087
1088 for (i, term) in model.iter().enumerate() {
1092 let count = term_counts[i];
1093 if !term.quant.satisfied_by(count) {
1094 out.push(AdfSchemaViolation::Arity {
1095 parent_type: parent_type.to_string(),
1096 atoms: term.atoms.to_vec(),
1097 expected: term.quant.clone(),
1098 actual: count,
1099 path: path.clone(),
1100 });
1101 }
1102 }
1103}
1104
1105#[cfg(test)]
1106#[allow(clippy::unwrap_used, clippy::expect_used)]
1107mod tests {
1108 use super::*;
1109 use crate::atlassian::adf::{AdfDocument, AdfNode};
1110
1111 fn node(node_type: &str, content: Vec<AdfNode>) -> AdfNode {
1112 AdfNode {
1113 node_type: node_type.to_string(),
1114 attrs: None,
1115 content: if content.is_empty() {
1116 None
1117 } else {
1118 Some(content)
1119 },
1120 text: None,
1121 marks: None,
1122 local_id: None,
1123 parameters: None,
1124 }
1125 }
1126
1127 fn leaf(node_type: &str) -> AdfNode {
1128 node(node_type, vec![])
1129 }
1130
1131 fn with_attrs(mut n: AdfNode, attrs: serde_json::Value) -> AdfNode {
1132 n.attrs = Some(attrs);
1133 n
1134 }
1135
1136 fn panel(content: Vec<AdfNode>) -> AdfNode {
1139 with_attrs(
1140 node("panel", content),
1141 serde_json::json!({"panelType": "info"}),
1142 )
1143 }
1144
1145 fn media() -> AdfNode {
1147 with_attrs(
1148 leaf("media"),
1149 serde_json::json!({"type": "file", "id": "x"}),
1150 )
1151 }
1152
1153 fn layout_column(content: Vec<AdfNode>) -> AdfNode {
1155 with_attrs(
1156 node("layoutColumn", content),
1157 serde_json::json!({"width": 33.3}),
1158 )
1159 }
1160
1161 fn doc(content: Vec<AdfNode>) -> AdfDocument {
1162 AdfDocument {
1163 version: 1,
1164 doc_type: "doc".to_string(),
1165 content,
1166 }
1167 }
1168
1169 fn unwrap_disallowed(v: &AdfSchemaViolation) -> (&str, &str, &[usize]) {
1170 match v {
1171 AdfSchemaViolation::DisallowedChild {
1172 child_type,
1173 parent_type,
1174 path,
1175 } => (child_type.as_str(), parent_type.as_str(), path.as_slice()),
1176 other => panic!("expected DisallowedChild, got {other:?}"),
1177 }
1178 }
1179
1180 fn unwrap_arity(
1181 v: &AdfSchemaViolation,
1182 ) -> (&str, &[&'static str], &Quantifier, usize, &[usize]) {
1183 match v {
1184 AdfSchemaViolation::Arity {
1185 parent_type,
1186 atoms,
1187 expected,
1188 actual,
1189 path,
1190 } => (
1191 parent_type.as_str(),
1192 atoms.as_slice(),
1193 expected,
1194 *actual,
1195 path.as_slice(),
1196 ),
1197 other => panic!("expected Arity, got {other:?}"),
1198 }
1199 }
1200
1201 #[test]
1202 fn schema_has_entry_for_every_advertised_container() {
1203 let known_leaves = [
1204 "blockCard",
1205 "date",
1206 "embedCard",
1207 "emoji",
1208 "extension",
1209 "hardBreak",
1210 "inlineCard",
1211 "inlineExtension",
1212 "media",
1213 "mediaInline",
1214 "mention",
1215 "placeholder",
1216 "rule",
1217 "status",
1218 "syncBlock",
1219 "text",
1220 "unsupportedBlock",
1221 "unsupportedInline",
1222 ];
1223 for (_parent, terms) in CONTENT_ENTRIES {
1224 for term in *terms {
1225 for child in term.atoms {
1226 let known = CONTENT_MODELS.contains_key(child) || known_leaves.contains(child);
1227 assert!(
1228 known,
1229 "child '{child}' has no schema entry and is not in the leaf list"
1230 );
1231 }
1232 }
1233 }
1234 }
1235
1236 #[test]
1237 fn child_lists_are_sorted_for_diffability() {
1238 for (parent, terms) in CONTENT_ENTRIES {
1239 for term in *terms {
1240 let mut sorted = term.atoms.to_vec();
1241 sorted.sort_unstable();
1242 assert_eq!(
1243 term.atoms.to_vec(),
1244 sorted,
1245 "atom list for '{parent}' is not sorted"
1246 );
1247 }
1248 }
1249 }
1250
1251 #[test]
1254 fn panel_allows_examples_from_issue_717() {
1255 for child in [
1256 "paragraph",
1257 "heading",
1258 "bulletList",
1259 "orderedList",
1260 "blockCard",
1261 "mediaGroup",
1262 "mediaSingle",
1263 "codeBlock",
1264 "taskList",
1265 "rule",
1266 "decisionList",
1267 "unsupportedBlock",
1268 "extension",
1269 ] {
1270 assert!(
1271 permits_child("panel", child),
1272 "panel should permit '{child}'"
1273 );
1274 }
1275 }
1276
1277 #[test]
1278 fn panel_rejects_expand_and_nested_expand() {
1279 assert!(!permits_child("panel", "expand"));
1280 assert!(!permits_child("panel", "nestedExpand"));
1281 }
1282
1283 #[test]
1284 fn expand_allows_nested_block_types_and_nested_expand_but_not_self() {
1285 assert!(permits_child("expand", "panel"));
1286 assert!(permits_child("expand", "table"));
1287 assert!(permits_child("expand", "nestedExpand"));
1288 assert!(!permits_child("expand", "expand"));
1289 }
1290
1291 #[test]
1292 fn table_cell_allows_nested_expand_but_not_expand() {
1293 assert!(permits_child("tableCell", "nestedExpand"));
1294 assert!(!permits_child("tableCell", "expand"));
1295 }
1296
1297 #[test]
1298 fn blockquote_allowed_children_match_upstream_json_schema() {
1299 let expected = [
1300 "bulletList",
1301 "codeBlock",
1302 "extension",
1303 "mediaGroup",
1304 "mediaSingle",
1305 "orderedList",
1306 "paragraph",
1307 ];
1308 let got: Vec<&str> = allowed_children("blockquote")
1309 .expect("blockquote has an entry")
1310 .to_vec();
1311 assert_eq!(got, expected);
1312 }
1313
1314 #[test]
1317 fn unknown_parent_is_permissive() {
1318 assert!(permits_child("madeUpNode", "anything"));
1319 assert!(permits_child("madeUpNode", "alsoFake"));
1320 }
1321
1322 #[test]
1323 fn unknown_child_inside_known_parent_is_a_violation() {
1324 assert!(!permits_child("paragraph", "madeUpInline"));
1325 }
1326
1327 #[test]
1328 fn nested_expand_distinguished_from_expand() {
1329 assert!(permits_child("nestedExpand", "panel"));
1330 assert!(permits_child("nestedExpand", "blockquote"));
1331 assert!(!permits_child("nestedExpand", "table"));
1332 assert!(!permits_child("nestedExpand", "blockCard"));
1333 assert!(!permits_child("nestedExpand", "embedCard"));
1334 assert!(!permits_child("nestedExpand", "nestedExpand"));
1335 assert!(!permits_child("nestedExpand", "expand"));
1336 }
1337
1338 #[test]
1341 fn validate_succeeds_on_known_good_doc() {
1342 let document = doc(vec![
1343 AdfNode::paragraph(vec![AdfNode::text("hello")]),
1344 AdfNode::heading(2, vec![AdfNode::text("world")]),
1345 ]);
1346 assert_eq!(validate_document(&document), vec![]);
1347 }
1348
1349 #[test]
1350 fn validate_finds_expand_inside_panel() {
1351 let bad_panel = panel(vec![with_attrs(
1355 node("expand", vec![AdfNode::paragraph(vec![])]),
1356 serde_json::json!({"title": "x"}),
1357 )]);
1358 let document = doc(vec![bad_panel]);
1359
1360 let violations = validate_document(&document);
1361 let disallowed: Vec<_> = violations
1362 .iter()
1363 .filter(|v| matches!(v, AdfSchemaViolation::DisallowedChild { .. }))
1364 .collect();
1365 let arity: Vec<_> = violations
1366 .iter()
1367 .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1368 .collect();
1369
1370 assert_eq!(disallowed.len(), 1, "got: {violations:?}");
1371 let (child, parent, path) = unwrap_disallowed(disallowed[0]);
1372 assert_eq!(child, "expand");
1373 assert_eq!(parent, "panel");
1374 assert_eq!(path, [0, 0]);
1375
1376 assert_eq!(arity.len(), 1, "got: {violations:?}");
1377 let (parent, _, _, actual, path) = unwrap_arity(arity[0]);
1378 assert_eq!(parent, "panel");
1379 assert_eq!(actual, 0);
1380 assert_eq!(path, [0]);
1381 }
1382
1383 #[test]
1384 fn validate_finds_expand_inside_table_cell() {
1385 let bad_cell = node(
1386 "tableCell",
1387 vec![with_attrs(
1388 node("expand", vec![AdfNode::paragraph(vec![])]),
1389 serde_json::json!({"title": "x"}),
1390 )],
1391 );
1392 let row = node("tableRow", vec![bad_cell]);
1393 let table = node("table", vec![row]);
1394 let document = doc(vec![table]);
1395
1396 let violations = validate_document(&document);
1397 let disallowed: Vec<_> = violations
1398 .iter()
1399 .filter(|v| matches!(v, AdfSchemaViolation::DisallowedChild { .. }))
1400 .collect();
1401 assert_eq!(disallowed.len(), 1, "got: {violations:?}");
1402 let (child, parent, path) = unwrap_disallowed(disallowed[0]);
1403 assert_eq!(child, "expand");
1404 assert_eq!(parent, "tableCell");
1405 assert_eq!(path, [0, 0, 0, 0]);
1406 }
1407
1408 #[test]
1409 fn validate_walks_into_nested_violations_in_document_order() {
1410 let document = doc(vec![
1411 AdfNode::paragraph(vec![leaf("rule")]),
1412 panel(vec![with_attrs(
1413 node("expand", vec![AdfNode::paragraph(vec![])]),
1414 serde_json::json!({"title": "x"}),
1415 )]),
1416 ]);
1417
1418 let violations = validate_document(&document);
1419 let first = violations.first().expect("at least one");
1424 let (child, parent, _) = unwrap_disallowed(first);
1425 assert_eq!(child, "rule");
1426 assert_eq!(parent, "paragraph");
1427 }
1428
1429 #[test]
1430 fn validate_is_permissive_under_unknown_parents() {
1431 let document = doc(vec![node("futureBlock", vec![node("expand", vec![])])]);
1432 let violations = validate_document(&document);
1433 assert_eq!(violations.len(), 1);
1437 let (child, parent, _) = unwrap_disallowed(&violations[0]);
1438 assert_eq!(child, "futureBlock");
1439 assert_eq!(parent, "doc");
1440 }
1441
1442 #[test]
1443 fn unsupported_block_is_universally_accepted_via_walker_escape_hatch() {
1444 for parent in [
1445 "doc",
1446 "panel",
1447 "expand",
1448 "tableCell",
1449 "blockquote",
1450 "listItem",
1451 ] {
1452 assert!(
1453 permits_child(parent, "unsupportedBlock"),
1454 "{parent} should permit unsupportedBlock via the escape hatch"
1455 );
1456 assert!(
1457 !allowed_children(parent).is_some_and(|c| c.contains(&"unsupportedBlock")),
1458 "{parent}'s allowed-children list must not list unsupportedBlock — \
1459 acceptance comes from the walker escape hatch only"
1460 );
1461 }
1462 }
1463
1464 #[test]
1465 fn unsupported_inline_is_universally_accepted_via_walker_escape_hatch() {
1466 for parent in [
1467 "paragraph",
1468 "heading",
1469 "taskItem",
1470 "decisionItem",
1471 "caption",
1472 ] {
1473 assert!(
1474 permits_child(parent, "unsupportedInline"),
1475 "{parent} should permit unsupportedInline via the escape hatch"
1476 );
1477 assert!(
1478 !allowed_children(parent).is_some_and(|c| c.contains(&"unsupportedInline")),
1479 "{parent}'s allowed-children list must not list unsupportedInline"
1480 );
1481 }
1482 }
1483
1484 #[test]
1485 fn validate_returns_empty_when_doc_type_is_unknown() {
1486 let document = AdfDocument {
1487 version: 1,
1488 doc_type: "futureRoot".to_string(),
1489 content: vec![node("expand", vec![])],
1490 };
1491 assert_eq!(validate_document(&document), vec![]);
1492 }
1493
1494 #[test]
1495 fn walker_does_not_flag_unsupported_block_inside_panel() {
1496 let document = doc(vec![panel(vec![leaf("unsupportedBlock")])]);
1500 assert_eq!(validate_document(&document), vec![]);
1501 }
1502
1503 #[test]
1506 fn empty_bullet_list_flagged_as_arity_violation() {
1507 let document = doc(vec![node("bulletList", vec![])]);
1508 let violations = validate_document(&document);
1509 assert_eq!(violations.len(), 1, "got: {violations:?}");
1510 let (parent, atoms, expected, actual, path) = unwrap_arity(&violations[0]);
1511 assert_eq!(parent, "bulletList");
1512 assert_eq!(atoms, &["listItem"]);
1513 assert_eq!(expected, &Quantifier::OneOrMore);
1514 assert_eq!(actual, 0);
1515 assert_eq!(path, [0]);
1516 }
1517
1518 #[test]
1519 fn media_single_with_two_media_flagged_as_arity_violation() {
1520 let media_single = node("mediaSingle", vec![media(), media()]);
1522 let document = doc(vec![media_single]);
1523 let violations = validate_document(&document);
1524
1525 let arity: Vec<_> = violations
1526 .iter()
1527 .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1528 .collect();
1529 assert_eq!(arity.len(), 1, "got: {violations:?}");
1530 let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
1531 assert_eq!(parent, "mediaSingle");
1532 assert_eq!(atoms, &["media"]);
1533 assert_eq!(expected, &Quantifier::Exactly(1));
1534 assert_eq!(actual, 2);
1535 }
1536
1537 #[test]
1538 fn media_single_with_only_caption_flagged_missing_media() {
1539 let document = doc(vec![node("mediaSingle", vec![leaf("caption")])]);
1543 let violations = validate_document(&document);
1544 let arity: Vec<_> = violations
1545 .iter()
1546 .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1547 .collect();
1548 assert_eq!(arity.len(), 1, "got: {violations:?}");
1549 let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
1550 assert_eq!(parent, "mediaSingle");
1551 assert_eq!(atoms, &["media"]);
1552 assert_eq!(expected, &Quantifier::Exactly(1));
1553 assert_eq!(actual, 0);
1554 }
1555
1556 #[test]
1557 fn media_single_with_media_then_caption_validates() {
1558 let document = doc(vec![node(
1559 "mediaSingle",
1560 vec![media(), node("caption", vec![AdfNode::text("c")])],
1561 )]);
1562 assert_eq!(validate_document(&document), vec![]);
1563 }
1564
1565 #[test]
1566 fn media_single_with_just_one_media_validates() {
1567 let document = doc(vec![node("mediaSingle", vec![media()])]);
1568 assert_eq!(validate_document(&document), vec![]);
1569 }
1570
1571 #[test]
1572 fn empty_table_row_flagged_arity() {
1573 let document = doc(vec![node("table", vec![node("tableRow", vec![])])]);
1574 let violations = validate_document(&document);
1575 let arity: Vec<_> = violations
1576 .iter()
1577 .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1578 .collect();
1579 assert_eq!(arity.len(), 1, "got: {violations:?}");
1580 let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
1581 assert_eq!(parent, "tableRow");
1582 assert_eq!(atoms, &["tableCell", "tableHeader"]);
1583 assert_eq!(expected, &Quantifier::OneOrMore);
1584 assert_eq!(actual, 0);
1585 }
1586
1587 #[test]
1588 fn empty_media_group_flagged_arity() {
1589 let document = doc(vec![node("mediaGroup", vec![])]);
1590 let violations = validate_document(&document);
1591 assert_eq!(violations.len(), 1);
1592 let (parent, atoms, expected, actual, _) = unwrap_arity(&violations[0]);
1593 assert_eq!(parent, "mediaGroup");
1594 assert_eq!(atoms, &["media"]);
1595 assert_eq!(expected, &Quantifier::OneOrMore);
1596 assert_eq!(actual, 0);
1597 }
1598
1599 #[test]
1600 fn layout_section_with_one_column_flagged_arity_range() {
1601 let document = doc(vec![node(
1602 "layoutSection",
1603 vec![node(
1604 "layoutColumn",
1605 vec![AdfNode::paragraph(vec![AdfNode::text("a")])],
1606 )],
1607 )]);
1608 let violations = validate_document(&document);
1609 let arity: Vec<_> = violations
1610 .iter()
1611 .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1612 .collect();
1613 assert_eq!(arity.len(), 1, "got: {violations:?}");
1614 let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
1615 assert_eq!(parent, "layoutSection");
1616 assert_eq!(atoms, &["layoutColumn"]);
1617 assert_eq!(expected, &Quantifier::Range(2, 3));
1618 assert_eq!(actual, 1);
1619 }
1620
1621 #[test]
1622 fn layout_section_with_three_columns_validates() {
1623 let column = || layout_column(vec![AdfNode::paragraph(vec![AdfNode::text("x")])]);
1624 let document = doc(vec![node(
1625 "layoutSection",
1626 vec![column(), column(), column()],
1627 )]);
1628 assert_eq!(validate_document(&document), vec![]);
1629 }
1630
1631 #[test]
1632 fn layout_section_with_four_columns_flagged_too_many() {
1633 let column = || layout_column(vec![AdfNode::paragraph(vec![AdfNode::text("x")])]);
1634 let document = doc(vec![node(
1635 "layoutSection",
1636 vec![column(), column(), column(), column()],
1637 )]);
1638 let violations = validate_document(&document);
1639 let arity: Vec<_> = violations
1640 .iter()
1641 .filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
1642 .collect();
1643 assert_eq!(arity.len(), 1, "got: {violations:?}");
1644 let (_, _, expected, actual, _) = unwrap_arity(arity[0]);
1645 assert_eq!(expected, &Quantifier::Range(2, 3));
1646 assert_eq!(actual, 4);
1647 }
1648
1649 #[test]
1650 fn empty_paragraph_validates_under_lenient_inline_star() {
1651 let document = doc(vec![AdfNode::paragraph(vec![])]);
1652 assert_eq!(validate_document(&document), vec![]);
1653 }
1654
1655 #[test]
1656 fn empty_doc_validates_under_lenient_block_star() {
1657 let document = doc(vec![]);
1658 assert_eq!(validate_document(&document), vec![]);
1659 }
1660
1661 #[test]
1662 fn empty_table_cell_validates_under_lenient_block_star() {
1663 let document = doc(vec![node(
1664 "table",
1665 vec![node("tableRow", vec![node("tableCell", vec![])])],
1666 )]);
1667 assert_eq!(validate_document(&document), vec![]);
1668 }
1669
1670 #[test]
1671 fn empty_panel_flagged_arity() {
1672 let document = doc(vec![panel(vec![])]);
1673 let violations = validate_document(&document);
1674 assert_eq!(violations.len(), 1, "got: {violations:?}");
1675 let (parent, _, expected, actual, _) = unwrap_arity(&violations[0]);
1676 assert_eq!(parent, "panel");
1677 assert_eq!(expected, &Quantifier::OneOrMore);
1678 assert_eq!(actual, 0);
1679 }
1680
1681 #[test]
1682 fn unsupported_block_satisfies_parent_arity() {
1683 let document = doc(vec![panel(vec![leaf("unsupportedBlock")])]);
1686 assert_eq!(validate_document(&document), vec![]);
1687 }
1688
1689 #[test]
1690 fn unsupported_inline_satisfies_inline_parent_arity() {
1691 let task_item = with_attrs(
1696 node("taskItem", vec![leaf("unsupportedInline")]),
1697 serde_json::json!({"localId": "ti1", "state": "TODO"}),
1698 );
1699 let task_list = with_attrs(
1700 node("taskList", vec![task_item]),
1701 serde_json::json!({"localId": "tl1"}),
1702 );
1703 let document = doc(vec![task_list]);
1704 assert_eq!(validate_document(&document), vec![]);
1705 }
1706
1707 #[test]
1710 fn display_format_for_disallowed_child_is_back_compat() {
1711 let v = AdfSchemaViolation::DisallowedChild {
1712 child_type: "expand".into(),
1713 parent_type: "panel".into(),
1714 path: vec![0, 1, 0],
1715 };
1716 assert_eq!(
1717 v.to_string(),
1718 "ADF schema violation at /0/1/0: 'expand' is not permitted inside 'panel'"
1719 );
1720 }
1721
1722 #[test]
1723 fn display_format_for_arity_one_or_more() {
1724 let v = AdfSchemaViolation::Arity {
1725 parent_type: "bulletList".into(),
1726 atoms: vec!["listItem"],
1727 expected: Quantifier::OneOrMore,
1728 actual: 0,
1729 path: vec![1],
1730 };
1731 assert_eq!(
1732 v.to_string(),
1733 "ADF schema violation at /1: 'bulletList' must contain at least one 'listItem' (found 0)"
1734 );
1735 }
1736
1737 #[test]
1738 fn display_format_for_arity_exactly_one() {
1739 let v = AdfSchemaViolation::Arity {
1740 parent_type: "mediaSingle".into(),
1741 atoms: vec!["media"],
1742 expected: Quantifier::Exactly(1),
1743 actual: 2,
1744 path: vec![0],
1745 };
1746 assert_eq!(
1747 v.to_string(),
1748 "ADF schema violation at /0: 'mediaSingle' must contain exactly one 'media' (found 2)"
1749 );
1750 }
1751
1752 #[test]
1753 fn display_format_for_arity_range() {
1754 let v = AdfSchemaViolation::Arity {
1755 parent_type: "layoutSection".into(),
1756 atoms: vec!["layoutColumn"],
1757 expected: Quantifier::Range(2, 3),
1758 actual: 1,
1759 path: vec![0],
1760 };
1761 assert_eq!(
1762 v.to_string(),
1763 "ADF schema violation at /0: 'layoutSection' must contain between 2 and 3 'layoutColumn' (found 1)"
1764 );
1765 }
1766
1767 #[test]
1768 fn display_format_for_arity_alternation() {
1769 let v = AdfSchemaViolation::Arity {
1770 parent_type: "tableRow".into(),
1771 atoms: vec!["tableCell", "tableHeader"],
1772 expected: Quantifier::OneOrMore,
1773 actual: 0,
1774 path: vec![0, 0],
1775 };
1776 assert_eq!(
1777 v.to_string(),
1778 "ADF schema violation at /0/0: 'tableRow' must contain at least one {'tableCell', 'tableHeader'} (found 0)"
1779 );
1780 }
1781
1782 #[test]
1783 fn display_format_for_missing_attr() {
1784 let v = AdfSchemaViolation::MissingAttr {
1785 node_type: "panel".into(),
1786 attr_name: "panelType".into(),
1787 path: vec![0],
1788 };
1789 assert_eq!(
1790 v.to_string(),
1791 "ADF schema violation at /0: 'panel' is missing required attribute 'panelType'"
1792 );
1793 }
1794
1795 #[test]
1796 fn display_format_for_invalid_attr() {
1797 let v = AdfSchemaViolation::InvalidAttr {
1798 node_type: "heading".into(),
1799 attr_name: "level".into(),
1800 problem: crate::atlassian::adf_attr_schema::AttrProblem::OutOfRange {
1801 lo: 1,
1802 hi: 6,
1803 actual: 7,
1804 },
1805 path: vec![0],
1806 };
1807 let s = v.to_string();
1808 assert!(s.contains("'heading.level'"), "got: {s}");
1809 assert!(s.contains("invalid"), "got: {s}");
1810 assert!(s.contains("[1, 6]"), "got: {s}");
1811 }
1812
1813 #[test]
1814 fn display_format_for_disallowed_mark() {
1815 let v = AdfSchemaViolation::DisallowedMark {
1816 mark_type: "code".into(),
1817 parent_type: "heading".into(),
1818 inline_index: Some(0),
1819 path: vec![0, 1],
1820 };
1821 assert_eq!(
1822 v.to_string(),
1823 "ADF schema violation at /0/1: 'code' mark is not permitted on 'heading'"
1824 );
1825 }
1826
1827 #[test]
1828 fn display_format_for_invalid_mark_attr() {
1829 let v = AdfSchemaViolation::InvalidMarkAttr {
1830 mark_type: "link".into(),
1831 attr_name: "href".into(),
1832 problem: crate::atlassian::adf_attr_schema::AttrProblem::BadFormat {
1833 reason: "not a valid URL",
1834 },
1835 inline_index: Some(0),
1836 path: vec![0, 1],
1837 };
1838 let s = v.to_string();
1839 assert!(s.contains("'link' mark"), "got: {s}");
1840 assert!(s.contains("'href'"), "got: {s}");
1841 assert!(s.contains("not a valid URL"), "got: {s}");
1842 }
1843
1844 #[test]
1847 fn quantifier_satisfied_by() {
1848 assert!(Quantifier::ZeroOrOne.satisfied_by(0));
1849 assert!(Quantifier::ZeroOrOne.satisfied_by(1));
1850 assert!(!Quantifier::ZeroOrOne.satisfied_by(2));
1851
1852 assert!(Quantifier::ZeroOrMore.satisfied_by(0));
1853 assert!(Quantifier::ZeroOrMore.satisfied_by(99));
1854
1855 assert!(!Quantifier::OneOrMore.satisfied_by(0));
1856 assert!(Quantifier::OneOrMore.satisfied_by(1));
1857
1858 assert!(!Quantifier::Exactly(2).satisfied_by(1));
1859 assert!(Quantifier::Exactly(2).satisfied_by(2));
1860 assert!(!Quantifier::Exactly(2).satisfied_by(3));
1861
1862 assert!(!Quantifier::Range(2, 3).satisfied_by(1));
1863 assert!(Quantifier::Range(2, 3).satisfied_by(2));
1864 assert!(Quantifier::Range(2, 3).satisfied_by(3));
1865 assert!(!Quantifier::Range(2, 3).satisfied_by(4));
1866 }
1867
1868 #[test]
1877 fn display_format_for_arity_zero_or_one() {
1878 let v = AdfSchemaViolation::Arity {
1879 parent_type: "mediaSingle".into(),
1880 atoms: vec!["caption"],
1881 expected: Quantifier::ZeroOrOne,
1882 actual: 2,
1883 path: vec![0],
1884 };
1885 assert_eq!(
1886 v.to_string(),
1887 "ADF schema violation at /0: 'mediaSingle' must contain at most one 'caption' (found 2)"
1888 );
1889 }
1890
1891 #[test]
1892 fn display_format_for_arity_zero_or_more() {
1893 let v = AdfSchemaViolation::Arity {
1897 parent_type: "paragraph".into(),
1898 atoms: vec!["text"],
1899 expected: Quantifier::ZeroOrMore,
1900 actual: 0,
1901 path: vec![0],
1902 };
1903 assert_eq!(
1904 v.to_string(),
1905 "ADF schema violation at /0: 'paragraph' must contain any number of 'text' (found 0)"
1906 );
1907 }
1908
1909 #[test]
1910 fn display_format_for_arity_exactly_n_greater_than_one() {
1911 let v = AdfSchemaViolation::Arity {
1912 parent_type: "futureNode".into(),
1913 atoms: vec!["child"],
1914 expected: Quantifier::Exactly(3),
1915 actual: 2,
1916 path: vec![0],
1917 };
1918 assert_eq!(
1919 v.to_string(),
1920 "ADF schema violation at /0: 'futureNode' must contain exactly 3 'child' (found 2)"
1921 );
1922 }
1923
1924 #[test]
1930 fn path_accessor_returns_path_for_each_variant() {
1931 let v1 = AdfSchemaViolation::DisallowedChild {
1932 child_type: "x".into(),
1933 parent_type: "y".into(),
1934 path: vec![1],
1935 };
1936 assert_eq!(v1.path(), &[1]);
1937
1938 let v2 = AdfSchemaViolation::Arity {
1939 parent_type: "y".into(),
1940 atoms: vec!["x"],
1941 expected: Quantifier::OneOrMore,
1942 actual: 0,
1943 path: vec![2],
1944 };
1945 assert_eq!(v2.path(), &[2]);
1946
1947 let v3 = AdfSchemaViolation::MissingAttr {
1948 node_type: "y".into(),
1949 attr_name: "a".into(),
1950 path: vec![3],
1951 };
1952 assert_eq!(v3.path(), &[3]);
1953
1954 let v4 = AdfSchemaViolation::InvalidAttr {
1955 node_type: "y".into(),
1956 attr_name: "a".into(),
1957 problem: crate::atlassian::adf_attr_schema::AttrProblem::WrongType {
1958 expected: "string",
1959 },
1960 path: vec![4],
1961 };
1962 assert_eq!(v4.path(), &[4]);
1963
1964 let v5 = AdfSchemaViolation::DisallowedMark {
1965 mark_type: "code".into(),
1966 parent_type: "heading".into(),
1967 inline_index: Some(0),
1968 path: vec![5],
1969 };
1970 assert_eq!(v5.path(), &[5]);
1971
1972 let v6 = AdfSchemaViolation::InvalidMarkAttr {
1973 mark_type: "link".into(),
1974 attr_name: "href".into(),
1975 problem: crate::atlassian::adf_attr_schema::AttrProblem::BadFormat {
1976 reason: "not a valid URL",
1977 },
1978 inline_index: Some(0),
1979 path: vec![6],
1980 };
1981 assert_eq!(v6.path(), &[6]);
1982 }
1983
1984 type LenientEntry = (
1987 &'static str,
1988 &'static [&'static str],
1989 &'static [&'static str],
1990 &'static str,
1991 );
1992
1993 #[derive(Debug, Default)]
1995 struct SchemaAtomDiff {
1996 local_only_parents: Vec<&'static str>,
1998 upstream_only_parents: Vec<&'static str>,
2000 per_parent_unexpected: Vec<String>,
2003 }
2004
2005 impl SchemaAtomDiff {
2006 fn is_clean(&self) -> bool {
2007 self.local_only_parents.is_empty()
2008 && self.upstream_only_parents.is_empty()
2009 && self.per_parent_unexpected.is_empty()
2010 }
2011 }
2012
2013 fn diff_atom_sets(
2020 local: &std::collections::BTreeMap<&'static str, std::collections::BTreeSet<&'static str>>,
2021 upstream: &std::collections::BTreeMap<
2022 &'static str,
2023 std::collections::BTreeSet<&'static str>,
2024 >,
2025 leniency: &[LenientEntry],
2026 ) -> SchemaAtomDiff {
2027 let local_parents: std::collections::BTreeSet<&'static str> =
2028 local.keys().copied().collect();
2029 let upstream_parents: std::collections::BTreeSet<&'static str> =
2030 upstream.keys().copied().collect();
2031
2032 let mut diff = SchemaAtomDiff {
2033 local_only_parents: local_parents
2034 .difference(&upstream_parents)
2035 .copied()
2036 .collect(),
2037 upstream_only_parents: upstream_parents
2038 .difference(&local_parents)
2039 .copied()
2040 .collect(),
2041 per_parent_unexpected: Vec::new(),
2042 };
2043
2044 for parent in local_parents.intersection(&upstream_parents) {
2045 let l = &local[parent];
2046 let u = &upstream[parent];
2047
2048 let allowed_upstream_extra: std::collections::BTreeSet<&str> = leniency
2049 .iter()
2050 .filter(|(p, _, _, _)| p == parent)
2051 .flat_map(|(_, ue, _, _)| ue.iter().copied())
2052 .collect();
2053 let allowed_local_extra: std::collections::BTreeSet<&str> = leniency
2054 .iter()
2055 .filter(|(p, _, _, _)| p == parent)
2056 .flat_map(|(_, _, le, _)| le.iter().copied())
2057 .collect();
2058
2059 let upstream_extra: Vec<&str> = u
2060 .iter()
2061 .filter(|c| !l.contains(**c) && !allowed_upstream_extra.contains(**c))
2062 .copied()
2063 .collect();
2064 let local_extra: Vec<&str> = l
2065 .iter()
2066 .filter(|c| !u.contains(**c) && !allowed_local_extra.contains(**c))
2067 .copied()
2068 .collect();
2069
2070 if !upstream_extra.is_empty() || !local_extra.is_empty() {
2071 diff.per_parent_unexpected.push(format!(
2072 "{parent}: upstream_only={upstream_extra:?}, local_only={local_extra:?}"
2073 ));
2074 }
2075 }
2076
2077 diff
2078 }
2079
2080 const LENIENCY_ALLOWLIST: &[LenientEntry] = &[];
2089
2090 fn upstream_atom_map(
2092 ) -> std::collections::BTreeMap<&'static str, std::collections::BTreeSet<&'static str>> {
2093 generated::UPSTREAM_ENTRIES
2094 .iter()
2095 .map(|(p, children)| (*p, children.iter().copied().collect()))
2096 .collect()
2097 }
2098
2099 #[test]
2114 fn generated_upstream_atoms_match_local_snapshot() {
2115 let local = local_schema_map();
2116 let upstream = upstream_atom_map();
2117 let diff = diff_atom_sets(&local, &upstream, LENIENCY_ALLOWLIST);
2118 assert!(
2119 diff.is_clean(),
2120 "atom-set drift between CONTENT_ENTRIES and generated::UPSTREAM_ENTRIES:\n\
2121 local_only_parents={:?}\n\
2122 upstream_only_parents={:?}\n\
2123 per_parent_unexpected={:?}",
2124 diff.local_only_parents,
2125 diff.upstream_only_parents,
2126 diff.per_parent_unexpected,
2127 );
2128 }
2129
2130 #[test]
2131 fn diff_atom_sets_reports_clean_when_maps_agree() {
2132 let mut m: std::collections::BTreeMap<
2133 &'static str,
2134 std::collections::BTreeSet<&'static str>,
2135 > = std::collections::BTreeMap::new();
2136 m.insert("panel", ["paragraph", "heading"].into_iter().collect());
2137 let diff = diff_atom_sets(&m, &m.clone(), &[]);
2138 assert!(diff.is_clean());
2139 assert!(diff.local_only_parents.is_empty());
2140 assert!(diff.upstream_only_parents.is_empty());
2141 assert!(diff.per_parent_unexpected.is_empty());
2142 }
2143
2144 #[test]
2145 fn diff_atom_sets_reports_local_only_parents() {
2146 let mut local: std::collections::BTreeMap<
2147 &'static str,
2148 std::collections::BTreeSet<&'static str>,
2149 > = std::collections::BTreeMap::new();
2150 local.insert("legacyNode", std::iter::once("paragraph").collect());
2151 let upstream: std::collections::BTreeMap<
2152 &'static str,
2153 std::collections::BTreeSet<&'static str>,
2154 > = std::collections::BTreeMap::new();
2155 let diff = diff_atom_sets(&local, &upstream, &[]);
2156 assert!(!diff.is_clean());
2157 assert_eq!(diff.local_only_parents, vec!["legacyNode"]);
2158 assert!(diff.upstream_only_parents.is_empty());
2159 }
2160
2161 #[test]
2162 fn diff_atom_sets_reports_upstream_only_parents() {
2163 let local: std::collections::BTreeMap<
2164 &'static str,
2165 std::collections::BTreeSet<&'static str>,
2166 > = std::collections::BTreeMap::new();
2167 let mut upstream: std::collections::BTreeMap<
2168 &'static str,
2169 std::collections::BTreeSet<&'static str>,
2170 > = std::collections::BTreeMap::new();
2171 upstream.insert("newNode", std::iter::once("paragraph").collect());
2172 let diff = diff_atom_sets(&local, &upstream, &[]);
2173 assert!(!diff.is_clean());
2174 assert_eq!(diff.upstream_only_parents, vec!["newNode"]);
2175 assert!(diff.local_only_parents.is_empty());
2176 }
2177
2178 #[test]
2179 fn diff_atom_sets_reports_unexpected_per_parent_diffs() {
2180 let mut local: std::collections::BTreeMap<
2181 &'static str,
2182 std::collections::BTreeSet<&'static str>,
2183 > = std::collections::BTreeMap::new();
2184 local.insert(
2185 "panel",
2186 ["paragraph", "heading"]
2187 .into_iter()
2188 .collect::<std::collections::BTreeSet<_>>(),
2189 );
2190 let mut upstream = local.clone();
2191 upstream.insert("panel", ["paragraph", "blockCard"].into_iter().collect());
2192 let diff = diff_atom_sets(&local, &upstream, &[]);
2193 assert!(!diff.is_clean());
2194 let msg = diff.per_parent_unexpected.join("\n");
2195 assert!(msg.contains("panel"));
2196 assert!(
2197 msg.contains("blockCard"),
2198 "upstream_only should mention blockCard: {msg}"
2199 );
2200 assert!(
2201 msg.contains("heading"),
2202 "local_only should mention heading: {msg}"
2203 );
2204 }
2205
2206 #[test]
2207 fn diff_atom_sets_honours_leniency_allowlist() {
2208 let mut local: std::collections::BTreeMap<
2209 &'static str,
2210 std::collections::BTreeSet<&'static str>,
2211 > = std::collections::BTreeMap::new();
2212 local.insert("panel", ["paragraph", "heading"].into_iter().collect());
2213 let mut upstream: std::collections::BTreeMap<
2214 &'static str,
2215 std::collections::BTreeSet<&'static str>,
2216 > = std::collections::BTreeMap::new();
2217 upstream.insert("panel", ["paragraph", "blockCard"].into_iter().collect());
2218 let lenient: &[LenientEntry] = &[(
2220 "panel",
2221 &["blockCard"], &["heading"], "synthetic test deviation",
2224 )];
2225 let diff = diff_atom_sets(&local, &upstream, lenient);
2226 assert!(diff.is_clean(), "allowlist should mask the diff: {diff:?}");
2227 }
2228
2229 #[test]
2230 fn generated_provenance_matches_local_constants() {
2231 assert_eq!(
2232 generated::UPSTREAM_TARBALL_SHA256,
2233 UPSTREAM_TARBALL_SHA256,
2234 "the vendored JSON's provenance SHA must match the runtime constant; \
2235 both are bumped together when the snapshot is refreshed",
2236 );
2237 let date_len = "-YYYY-MM-DD".len();
2241 let local_npm_prefix = SCHEMA_VERSION
2242 .get(..SCHEMA_VERSION.len().saturating_sub(date_len))
2243 .unwrap_or(SCHEMA_VERSION);
2244 assert_eq!(
2245 generated::UPSTREAM_VERSION,
2246 local_npm_prefix,
2247 "generated UPSTREAM_VERSION must match the npm-version prefix of SCHEMA_VERSION",
2248 );
2249 }
2250}