use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::LazyLock;
use crate::atlassian::adf::{AdfDocument, AdfNode};
pub mod drift;
pub mod generated;
#[must_use]
pub(crate) fn local_schema_map() -> BTreeMap<&'static str, BTreeSet<&'static str>> {
let mut m = BTreeMap::new();
for (parent, terms) in CONTENT_ENTRIES {
let children: BTreeSet<&'static str> =
terms.iter().flat_map(|t| t.atoms.iter().copied()).collect();
m.insert(*parent, children);
}
m
}
pub const SCHEMA_VERSION: &str = "52.9.5-2026-05-10";
pub const UPSTREAM_TARBALL_SHA256: &str =
"90b9b26f5cdf6f0850cebe5cf2df7662601b249322d6bcbeead712ca018e0b56";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Quantifier {
ZeroOrOne,
ZeroOrMore,
OneOrMore,
Exactly(usize),
Range(usize, usize),
}
impl Quantifier {
#[must_use]
pub fn satisfied_by(&self, n: usize) -> bool {
match *self {
Self::ZeroOrOne => n <= 1,
Self::ZeroOrMore => true,
Self::OneOrMore => n >= 1,
Self::Exactly(k) => n == k,
Self::Range(lo, hi) => n >= lo && n <= hi,
}
}
fn phrasing(&self) -> String {
match *self {
Self::ZeroOrOne => "at most one".to_string(),
Self::ZeroOrMore => "any number of".to_string(),
Self::OneOrMore => "at least one".to_string(),
Self::Exactly(1) => "exactly one".to_string(),
Self::Exactly(n) => format!("exactly {n}"),
Self::Range(lo, hi) => format!("between {lo} and {hi}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentTerm {
pub atoms: &'static [&'static str],
pub quant: Quantifier,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AdfSchemaViolation {
DisallowedChild {
child_type: String,
parent_type: String,
path: Vec<usize>,
},
Arity {
parent_type: String,
atoms: Vec<&'static str>,
expected: Quantifier,
actual: usize,
path: Vec<usize>,
},
MissingAttr {
node_type: String,
attr_name: String,
path: Vec<usize>,
},
InvalidAttr {
node_type: String,
attr_name: String,
problem: crate::atlassian::adf_attr_schema::AttrProblem,
path: Vec<usize>,
},
DisallowedMark {
mark_type: String,
parent_type: String,
inline_index: Option<usize>,
path: Vec<usize>,
},
InvalidMarkAttr {
mark_type: String,
attr_name: String,
problem: crate::atlassian::adf_attr_schema::AttrProblem,
inline_index: Option<usize>,
path: Vec<usize>,
},
}
impl AdfSchemaViolation {
#[must_use]
pub fn path(&self) -> &[usize] {
match self {
Self::DisallowedChild { path, .. }
| Self::Arity { path, .. }
| Self::MissingAttr { path, .. }
| Self::InvalidAttr { path, .. }
| Self::DisallowedMark { path, .. }
| Self::InvalidMarkAttr { path, .. } => path,
}
}
}
impl std::fmt::Display for AdfSchemaViolation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let path_str = self
.path()
.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join("/");
match self {
Self::DisallowedChild {
child_type,
parent_type,
..
} => write!(
f,
"ADF schema violation at /{path_str}: '{child_type}' is not permitted inside '{parent_type}'",
),
Self::Arity {
parent_type,
atoms,
expected,
actual,
..
} => write!(
f,
"ADF schema violation at /{path_str}: '{parent_type}' must contain {phrasing} {atoms_str} (found {actual})",
phrasing = expected.phrasing(),
atoms_str = format_atoms(atoms),
),
Self::MissingAttr {
node_type,
attr_name,
..
} => write!(
f,
"ADF schema violation at /{path_str}: '{node_type}' is missing required attribute '{attr_name}'",
),
Self::InvalidAttr {
node_type,
attr_name,
problem,
..
} => write!(
f,
"ADF schema violation at /{path_str}: '{node_type}.{attr_name}' is invalid — {problem}",
),
Self::DisallowedMark {
mark_type,
parent_type,
..
} => write!(
f,
"ADF schema violation at /{path_str}: '{mark_type}' mark is not permitted on '{parent_type}'",
),
Self::InvalidMarkAttr {
mark_type,
attr_name,
problem,
..
} => write!(
f,
"ADF schema violation at /{path_str}: '{mark_type}' mark's '{attr_name}' is invalid — {problem}",
),
}
}
}
fn format_atoms(atoms: &[&str]) -> String {
if atoms.len() == 1 {
format!("'{}'", atoms[0])
} else {
let inner = atoms
.iter()
.map(|a| format!("'{a}'"))
.collect::<Vec<_>>()
.join(", ");
format!("{{{inner}}}")
}
}
const FULL_INLINE_ATOMS: &[&str] = &[
"date",
"emoji",
"hardBreak",
"inlineCard",
"inlineExtension",
"mediaInline",
"mention",
"placeholder",
"status",
"text",
];
const CAPTION_INLINE_ATOMS: &[&str] = &[
"date",
"emoji",
"hardBreak",
"inlineCard",
"mention",
"placeholder",
"status",
"text",
];
const LISTITEM_BLOCK_ATOMS: &[&str] = &[
"bulletList",
"codeBlock",
"extension",
"mediaSingle",
"orderedList",
"paragraph",
"taskList",
];
const PANEL_BLOCK_ATOMS: &[&str] = &[
"blockCard",
"bulletList",
"codeBlock",
"decisionList",
"extension",
"heading",
"mediaGroup",
"mediaSingle",
"orderedList",
"paragraph",
"rule",
"taskList",
];
const NESTED_EXPAND_BLOCK_ATOMS: &[&str] = &[
"blockquote",
"bulletList",
"codeBlock",
"decisionList",
"extension",
"heading",
"mediaGroup",
"mediaSingle",
"orderedList",
"panel",
"paragraph",
"rule",
"taskList",
];
const EXPAND_BLOCK_ATOMS: &[&str] = &[
"blockCard",
"blockquote",
"bulletList",
"codeBlock",
"decisionList",
"embedCard",
"extension",
"heading",
"mediaGroup",
"mediaSingle",
"nestedExpand",
"orderedList",
"panel",
"paragraph",
"rule",
"table",
"taskList",
];
const BODIED_EXTENSION_BLOCK_ATOMS: &[&str] = &[
"blockCard",
"blockquote",
"bulletList",
"codeBlock",
"decisionList",
"embedCard",
"extension",
"heading",
"mediaGroup",
"mediaSingle",
"orderedList",
"panel",
"paragraph",
"rule",
"table",
"taskList",
];
const BODIED_SYNC_BLOCK_ATOMS: &[&str] = &[
"blockCard",
"blockquote",
"bulletList",
"codeBlock",
"decisionList",
"embedCard",
"expand",
"heading",
"layoutSection",
"mediaGroup",
"mediaSingle",
"orderedList",
"panel",
"paragraph",
"rule",
"table",
"taskList",
];
const LAYOUT_COLUMN_BLOCK_ATOMS: &[&str] = &[
"blockCard",
"blockquote",
"bodiedExtension",
"bulletList",
"codeBlock",
"decisionList",
"embedCard",
"expand",
"extension",
"heading",
"mediaGroup",
"mediaSingle",
"orderedList",
"panel",
"paragraph",
"rule",
"table",
"taskList",
];
const TABLE_CELL_BLOCK_ATOMS: &[&str] = &[
"blockCard",
"blockquote",
"bulletList",
"codeBlock",
"decisionList",
"embedCard",
"extension",
"heading",
"mediaGroup",
"mediaSingle",
"nestedExpand",
"orderedList",
"panel",
"paragraph",
"rule",
"taskList",
];
const DOC_BLOCK_ATOMS: &[&str] = &[
"blockCard",
"blockquote",
"bodiedExtension",
"bodiedSyncBlock",
"bulletList",
"codeBlock",
"decisionList",
"embedCard",
"expand",
"extension",
"heading",
"layoutSection",
"mediaGroup",
"mediaSingle",
"orderedList",
"panel",
"paragraph",
"rule",
"syncBlock",
"table",
"taskList",
];
pub(crate) type ModelEntry = (&'static str, &'static [ContentTerm]);
pub(crate) const CONTENT_ENTRIES: &[ModelEntry] = &[
(
"blockTaskItem",
&[ContentTerm {
atoms: &["extension", "paragraph"],
quant: Quantifier::OneOrMore,
}],
),
(
"blockquote",
&[ContentTerm {
atoms: &[
"bulletList",
"codeBlock",
"extension",
"mediaGroup",
"mediaSingle",
"orderedList",
"paragraph",
],
quant: Quantifier::OneOrMore,
}],
),
(
"bodiedExtension",
&[ContentTerm {
atoms: BODIED_EXTENSION_BLOCK_ATOMS,
quant: Quantifier::OneOrMore,
}],
),
(
"bodiedSyncBlock",
&[ContentTerm {
atoms: BODIED_SYNC_BLOCK_ATOMS,
quant: Quantifier::OneOrMore,
}],
),
(
"bulletList",
&[ContentTerm {
atoms: &["listItem"],
quant: Quantifier::OneOrMore,
}],
),
(
"caption",
&[ContentTerm {
atoms: CAPTION_INLINE_ATOMS,
quant: Quantifier::ZeroOrMore,
}],
),
(
"codeBlock",
&[ContentTerm {
atoms: &["text"],
quant: Quantifier::ZeroOrMore,
}],
),
(
"decisionItem",
&[ContentTerm {
atoms: FULL_INLINE_ATOMS,
quant: Quantifier::ZeroOrMore,
}],
),
(
"decisionList",
&[ContentTerm {
atoms: &["decisionItem"],
quant: Quantifier::OneOrMore,
}],
),
(
"doc",
&[ContentTerm {
atoms: DOC_BLOCK_ATOMS,
quant: Quantifier::ZeroOrMore,
}],
),
(
"expand",
&[ContentTerm {
atoms: EXPAND_BLOCK_ATOMS,
quant: Quantifier::OneOrMore,
}],
),
(
"heading",
&[ContentTerm {
atoms: FULL_INLINE_ATOMS,
quant: Quantifier::ZeroOrMore,
}],
),
(
"layoutColumn",
&[ContentTerm {
atoms: LAYOUT_COLUMN_BLOCK_ATOMS,
quant: Quantifier::OneOrMore,
}],
),
(
"layoutSection",
&[ContentTerm {
atoms: &["layoutColumn"],
quant: Quantifier::Range(2, 3),
}],
),
(
"listItem",
&[ContentTerm {
atoms: LISTITEM_BLOCK_ATOMS,
quant: Quantifier::OneOrMore,
}],
),
(
"mediaGroup",
&[ContentTerm {
atoms: &["media"],
quant: Quantifier::OneOrMore,
}],
),
(
"mediaSingle",
&[
ContentTerm {
atoms: &["media"],
quant: Quantifier::Exactly(1),
},
ContentTerm {
atoms: &["caption"],
quant: Quantifier::ZeroOrOne,
},
],
),
(
"nestedExpand",
&[ContentTerm {
atoms: NESTED_EXPAND_BLOCK_ATOMS,
quant: Quantifier::OneOrMore,
}],
),
(
"orderedList",
&[ContentTerm {
atoms: &["listItem"],
quant: Quantifier::OneOrMore,
}],
),
(
"panel",
&[ContentTerm {
atoms: PANEL_BLOCK_ATOMS,
quant: Quantifier::OneOrMore,
}],
),
(
"paragraph",
&[ContentTerm {
atoms: FULL_INLINE_ATOMS,
quant: Quantifier::ZeroOrMore,
}],
),
(
"table",
&[ContentTerm {
atoms: &["tableRow"],
quant: Quantifier::OneOrMore,
}],
),
(
"tableCell",
&[ContentTerm {
atoms: TABLE_CELL_BLOCK_ATOMS,
quant: Quantifier::ZeroOrMore,
}],
),
(
"tableHeader",
&[ContentTerm {
atoms: TABLE_CELL_BLOCK_ATOMS,
quant: Quantifier::ZeroOrMore,
}],
),
(
"tableRow",
&[ContentTerm {
atoms: &["tableCell", "tableHeader"],
quant: Quantifier::OneOrMore,
}],
),
(
"taskItem",
&[ContentTerm {
atoms: FULL_INLINE_ATOMS,
quant: Quantifier::ZeroOrMore,
}],
),
(
"taskList",
&[ContentTerm {
atoms: &["blockTaskItem", "taskItem", "taskList"],
quant: Quantifier::OneOrMore,
}],
),
];
const UNSUPPORTED_NODES: &[&str] = &["unsupportedBlock", "unsupportedInline"];
fn is_unsupported(node_type: &str) -> bool {
UNSUPPORTED_NODES.contains(&node_type)
}
static CONTENT_MODELS: LazyLock<HashMap<&'static str, &'static [ContentTerm]>> =
LazyLock::new(|| CONTENT_ENTRIES.iter().copied().collect());
static ALLOWED_CHILDREN: LazyLock<HashMap<&'static str, Vec<&'static str>>> = LazyLock::new(|| {
CONTENT_ENTRIES
.iter()
.map(|(parent, terms)| {
let mut atoms: Vec<&'static str> =
terms.iter().flat_map(|t| t.atoms.iter().copied()).collect();
atoms.sort_unstable();
atoms.dedup();
(*parent, atoms)
})
.collect()
});
#[must_use]
pub fn allowed_children(parent: &str) -> Option<&'static [&'static str]> {
ALLOWED_CHILDREN.get(parent).map(Vec::as_slice)
}
#[must_use]
pub fn content_model(parent: &str) -> Option<&'static [ContentTerm]> {
CONTENT_MODELS.get(parent).copied()
}
#[must_use]
pub fn permits_child(parent: &str, child: &str) -> bool {
if is_unsupported(child) {
return true;
}
match allowed_children(parent) {
Some(children) => children.contains(&child),
None => true,
}
}
#[must_use]
pub fn validate_document(doc: &AdfDocument) -> Vec<AdfSchemaViolation> {
let mut violations = Vec::new();
let mut path = Vec::new();
if let Some(model) = content_model(&doc.doc_type) {
walk_children(
&doc.content,
&doc.doc_type,
model,
&mut path,
&mut violations,
);
}
violations
}
fn walk_children(
children: &[AdfNode],
parent_type: &str,
model: &[ContentTerm],
path: &mut Vec<usize>,
out: &mut Vec<AdfSchemaViolation>,
) {
let mut term_counts: Vec<usize> = vec![0; model.len()];
let mut current_term: usize = 0;
for (idx, child) in children.iter().enumerate() {
path.push(idx);
let child_type = child.node_type.as_str();
crate::atlassian::adf_attr_schema::validate_attrs(
child_type,
child.attrs.as_ref(),
path,
out,
);
crate::atlassian::adf_mark_schema::validate_marks(parent_type, child, path, out);
if is_unsupported(child_type) {
if current_term < model.len() {
term_counts[current_term] += 1;
}
} else {
let mut matched: Option<usize> = None;
let mut try_idx = current_term;
while try_idx < model.len() {
if model[try_idx].atoms.contains(&child_type) {
matched = Some(try_idx);
break;
}
try_idx += 1;
}
match matched {
Some(t) => {
term_counts[t] += 1;
current_term = t;
}
None => {
out.push(AdfSchemaViolation::DisallowedChild {
child_type: child_type.to_string(),
parent_type: parent_type.to_string(),
path: path.clone(),
});
}
}
}
if let Some(grand_model) = content_model(child_type) {
let grand = child.content.as_deref().unwrap_or(&[]);
walk_children(grand, child_type, grand_model, path, out);
}
path.pop();
}
for (i, term) in model.iter().enumerate() {
let count = term_counts[i];
if !term.quant.satisfied_by(count) {
out.push(AdfSchemaViolation::Arity {
parent_type: parent_type.to_string(),
atoms: term.atoms.to_vec(),
expected: term.quant.clone(),
actual: count,
path: path.clone(),
});
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::atlassian::adf::{AdfDocument, AdfNode};
fn node(node_type: &str, content: Vec<AdfNode>) -> AdfNode {
AdfNode {
node_type: node_type.to_string(),
attrs: None,
content: if content.is_empty() {
None
} else {
Some(content)
},
text: None,
marks: None,
local_id: None,
parameters: None,
}
}
fn leaf(node_type: &str) -> AdfNode {
node(node_type, vec![])
}
fn with_attrs(mut n: AdfNode, attrs: serde_json::Value) -> AdfNode {
n.attrs = Some(attrs);
n
}
fn panel(content: Vec<AdfNode>) -> AdfNode {
with_attrs(
node("panel", content),
serde_json::json!({"panelType": "info"}),
)
}
fn media() -> AdfNode {
with_attrs(
leaf("media"),
serde_json::json!({"type": "file", "id": "x"}),
)
}
fn layout_column(content: Vec<AdfNode>) -> AdfNode {
with_attrs(
node("layoutColumn", content),
serde_json::json!({"width": 33.3}),
)
}
fn doc(content: Vec<AdfNode>) -> AdfDocument {
AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content,
}
}
fn unwrap_disallowed(v: &AdfSchemaViolation) -> (&str, &str, &[usize]) {
match v {
AdfSchemaViolation::DisallowedChild {
child_type,
parent_type,
path,
} => (child_type.as_str(), parent_type.as_str(), path.as_slice()),
other => panic!("expected DisallowedChild, got {other:?}"),
}
}
fn unwrap_arity(
v: &AdfSchemaViolation,
) -> (&str, &[&'static str], &Quantifier, usize, &[usize]) {
match v {
AdfSchemaViolation::Arity {
parent_type,
atoms,
expected,
actual,
path,
} => (
parent_type.as_str(),
atoms.as_slice(),
expected,
*actual,
path.as_slice(),
),
other => panic!("expected Arity, got {other:?}"),
}
}
#[test]
fn schema_has_entry_for_every_advertised_container() {
let known_leaves = [
"blockCard",
"date",
"embedCard",
"emoji",
"extension",
"hardBreak",
"inlineCard",
"inlineExtension",
"media",
"mediaInline",
"mention",
"placeholder",
"rule",
"status",
"syncBlock",
"text",
"unsupportedBlock",
"unsupportedInline",
];
for (_parent, terms) in CONTENT_ENTRIES {
for term in *terms {
for child in term.atoms {
let known = CONTENT_MODELS.contains_key(child) || known_leaves.contains(child);
assert!(
known,
"child '{child}' has no schema entry and is not in the leaf list"
);
}
}
}
}
#[test]
fn child_lists_are_sorted_for_diffability() {
for (parent, terms) in CONTENT_ENTRIES {
for term in *terms {
let mut sorted = term.atoms.to_vec();
sorted.sort_unstable();
assert_eq!(
term.atoms.to_vec(),
sorted,
"atom list for '{parent}' is not sorted"
);
}
}
}
#[test]
fn panel_allows_examples_from_issue_717() {
for child in [
"paragraph",
"heading",
"bulletList",
"orderedList",
"blockCard",
"mediaGroup",
"mediaSingle",
"codeBlock",
"taskList",
"rule",
"decisionList",
"unsupportedBlock",
"extension",
] {
assert!(
permits_child("panel", child),
"panel should permit '{child}'"
);
}
}
#[test]
fn panel_rejects_expand_and_nested_expand() {
assert!(!permits_child("panel", "expand"));
assert!(!permits_child("panel", "nestedExpand"));
}
#[test]
fn expand_allows_nested_block_types_and_nested_expand_but_not_self() {
assert!(permits_child("expand", "panel"));
assert!(permits_child("expand", "table"));
assert!(permits_child("expand", "nestedExpand"));
assert!(!permits_child("expand", "expand"));
}
#[test]
fn table_cell_allows_nested_expand_but_not_expand() {
assert!(permits_child("tableCell", "nestedExpand"));
assert!(!permits_child("tableCell", "expand"));
}
#[test]
fn blockquote_allowed_children_match_upstream_json_schema() {
let expected = [
"bulletList",
"codeBlock",
"extension",
"mediaGroup",
"mediaSingle",
"orderedList",
"paragraph",
];
let got: Vec<&str> = allowed_children("blockquote")
.expect("blockquote has an entry")
.to_vec();
assert_eq!(got, expected);
}
#[test]
fn unknown_parent_is_permissive() {
assert!(permits_child("madeUpNode", "anything"));
assert!(permits_child("madeUpNode", "alsoFake"));
}
#[test]
fn unknown_child_inside_known_parent_is_a_violation() {
assert!(!permits_child("paragraph", "madeUpInline"));
}
#[test]
fn nested_expand_distinguished_from_expand() {
assert!(permits_child("nestedExpand", "panel"));
assert!(permits_child("nestedExpand", "blockquote"));
assert!(!permits_child("nestedExpand", "table"));
assert!(!permits_child("nestedExpand", "blockCard"));
assert!(!permits_child("nestedExpand", "embedCard"));
assert!(!permits_child("nestedExpand", "nestedExpand"));
assert!(!permits_child("nestedExpand", "expand"));
}
#[test]
fn validate_succeeds_on_known_good_doc() {
let document = doc(vec![
AdfNode::paragraph(vec![AdfNode::text("hello")]),
AdfNode::heading(2, vec![AdfNode::text("world")]),
]);
assert_eq!(validate_document(&document), vec![]);
}
#[test]
fn validate_finds_expand_inside_panel() {
let bad_panel = panel(vec![with_attrs(
node("expand", vec![AdfNode::paragraph(vec![])]),
serde_json::json!({"title": "x"}),
)]);
let document = doc(vec![bad_panel]);
let violations = validate_document(&document);
let disallowed: Vec<_> = violations
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::DisallowedChild { .. }))
.collect();
let arity: Vec<_> = violations
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
.collect();
assert_eq!(disallowed.len(), 1, "got: {violations:?}");
let (child, parent, path) = unwrap_disallowed(disallowed[0]);
assert_eq!(child, "expand");
assert_eq!(parent, "panel");
assert_eq!(path, [0, 0]);
assert_eq!(arity.len(), 1, "got: {violations:?}");
let (parent, _, _, actual, path) = unwrap_arity(arity[0]);
assert_eq!(parent, "panel");
assert_eq!(actual, 0);
assert_eq!(path, [0]);
}
#[test]
fn validate_finds_expand_inside_table_cell() {
let bad_cell = node(
"tableCell",
vec![with_attrs(
node("expand", vec![AdfNode::paragraph(vec![])]),
serde_json::json!({"title": "x"}),
)],
);
let row = node("tableRow", vec![bad_cell]);
let table = node("table", vec![row]);
let document = doc(vec![table]);
let violations = validate_document(&document);
let disallowed: Vec<_> = violations
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::DisallowedChild { .. }))
.collect();
assert_eq!(disallowed.len(), 1, "got: {violations:?}");
let (child, parent, path) = unwrap_disallowed(disallowed[0]);
assert_eq!(child, "expand");
assert_eq!(parent, "tableCell");
assert_eq!(path, [0, 0, 0, 0]);
}
#[test]
fn validate_walks_into_nested_violations_in_document_order() {
let document = doc(vec![
AdfNode::paragraph(vec![leaf("rule")]),
panel(vec![with_attrs(
node("expand", vec![AdfNode::paragraph(vec![])]),
serde_json::json!({"title": "x"}),
)]),
]);
let violations = validate_document(&document);
let first = violations.first().expect("at least one");
let (child, parent, _) = unwrap_disallowed(first);
assert_eq!(child, "rule");
assert_eq!(parent, "paragraph");
}
#[test]
fn validate_is_permissive_under_unknown_parents() {
let document = doc(vec![node("futureBlock", vec![node("expand", vec![])])]);
let violations = validate_document(&document);
assert_eq!(violations.len(), 1);
let (child, parent, _) = unwrap_disallowed(&violations[0]);
assert_eq!(child, "futureBlock");
assert_eq!(parent, "doc");
}
#[test]
fn unsupported_block_is_universally_accepted_via_walker_escape_hatch() {
for parent in [
"doc",
"panel",
"expand",
"tableCell",
"blockquote",
"listItem",
] {
assert!(
permits_child(parent, "unsupportedBlock"),
"{parent} should permit unsupportedBlock via the escape hatch"
);
assert!(
!allowed_children(parent).is_some_and(|c| c.contains(&"unsupportedBlock")),
"{parent}'s allowed-children list must not list unsupportedBlock — \
acceptance comes from the walker escape hatch only"
);
}
}
#[test]
fn unsupported_inline_is_universally_accepted_via_walker_escape_hatch() {
for parent in [
"paragraph",
"heading",
"taskItem",
"decisionItem",
"caption",
] {
assert!(
permits_child(parent, "unsupportedInline"),
"{parent} should permit unsupportedInline via the escape hatch"
);
assert!(
!allowed_children(parent).is_some_and(|c| c.contains(&"unsupportedInline")),
"{parent}'s allowed-children list must not list unsupportedInline"
);
}
}
#[test]
fn validate_returns_empty_when_doc_type_is_unknown() {
let document = AdfDocument {
version: 1,
doc_type: "futureRoot".to_string(),
content: vec![node("expand", vec![])],
};
assert_eq!(validate_document(&document), vec![]);
}
#[test]
fn walker_does_not_flag_unsupported_block_inside_panel() {
let document = doc(vec![panel(vec![leaf("unsupportedBlock")])]);
assert_eq!(validate_document(&document), vec![]);
}
#[test]
fn empty_bullet_list_flagged_as_arity_violation() {
let document = doc(vec![node("bulletList", vec![])]);
let violations = validate_document(&document);
assert_eq!(violations.len(), 1, "got: {violations:?}");
let (parent, atoms, expected, actual, path) = unwrap_arity(&violations[0]);
assert_eq!(parent, "bulletList");
assert_eq!(atoms, &["listItem"]);
assert_eq!(expected, &Quantifier::OneOrMore);
assert_eq!(actual, 0);
assert_eq!(path, [0]);
}
#[test]
fn media_single_with_two_media_flagged_as_arity_violation() {
let media_single = node("mediaSingle", vec![media(), media()]);
let document = doc(vec![media_single]);
let violations = validate_document(&document);
let arity: Vec<_> = violations
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
.collect();
assert_eq!(arity.len(), 1, "got: {violations:?}");
let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
assert_eq!(parent, "mediaSingle");
assert_eq!(atoms, &["media"]);
assert_eq!(expected, &Quantifier::Exactly(1));
assert_eq!(actual, 2);
}
#[test]
fn media_single_with_only_caption_flagged_missing_media() {
let document = doc(vec![node("mediaSingle", vec![leaf("caption")])]);
let violations = validate_document(&document);
let arity: Vec<_> = violations
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
.collect();
assert_eq!(arity.len(), 1, "got: {violations:?}");
let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
assert_eq!(parent, "mediaSingle");
assert_eq!(atoms, &["media"]);
assert_eq!(expected, &Quantifier::Exactly(1));
assert_eq!(actual, 0);
}
#[test]
fn media_single_with_media_then_caption_validates() {
let document = doc(vec![node(
"mediaSingle",
vec![media(), node("caption", vec![AdfNode::text("c")])],
)]);
assert_eq!(validate_document(&document), vec![]);
}
#[test]
fn media_single_with_just_one_media_validates() {
let document = doc(vec![node("mediaSingle", vec![media()])]);
assert_eq!(validate_document(&document), vec![]);
}
#[test]
fn empty_table_row_flagged_arity() {
let document = doc(vec![node("table", vec![node("tableRow", vec![])])]);
let violations = validate_document(&document);
let arity: Vec<_> = violations
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
.collect();
assert_eq!(arity.len(), 1, "got: {violations:?}");
let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
assert_eq!(parent, "tableRow");
assert_eq!(atoms, &["tableCell", "tableHeader"]);
assert_eq!(expected, &Quantifier::OneOrMore);
assert_eq!(actual, 0);
}
#[test]
fn empty_media_group_flagged_arity() {
let document = doc(vec![node("mediaGroup", vec![])]);
let violations = validate_document(&document);
assert_eq!(violations.len(), 1);
let (parent, atoms, expected, actual, _) = unwrap_arity(&violations[0]);
assert_eq!(parent, "mediaGroup");
assert_eq!(atoms, &["media"]);
assert_eq!(expected, &Quantifier::OneOrMore);
assert_eq!(actual, 0);
}
#[test]
fn layout_section_with_one_column_flagged_arity_range() {
let document = doc(vec![node(
"layoutSection",
vec![node(
"layoutColumn",
vec![AdfNode::paragraph(vec![AdfNode::text("a")])],
)],
)]);
let violations = validate_document(&document);
let arity: Vec<_> = violations
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
.collect();
assert_eq!(arity.len(), 1, "got: {violations:?}");
let (parent, atoms, expected, actual, _) = unwrap_arity(arity[0]);
assert_eq!(parent, "layoutSection");
assert_eq!(atoms, &["layoutColumn"]);
assert_eq!(expected, &Quantifier::Range(2, 3));
assert_eq!(actual, 1);
}
#[test]
fn layout_section_with_three_columns_validates() {
let column = || layout_column(vec![AdfNode::paragraph(vec![AdfNode::text("x")])]);
let document = doc(vec![node(
"layoutSection",
vec![column(), column(), column()],
)]);
assert_eq!(validate_document(&document), vec![]);
}
#[test]
fn layout_section_with_four_columns_flagged_too_many() {
let column = || layout_column(vec![AdfNode::paragraph(vec![AdfNode::text("x")])]);
let document = doc(vec![node(
"layoutSection",
vec![column(), column(), column(), column()],
)]);
let violations = validate_document(&document);
let arity: Vec<_> = violations
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::Arity { .. }))
.collect();
assert_eq!(arity.len(), 1, "got: {violations:?}");
let (_, _, expected, actual, _) = unwrap_arity(arity[0]);
assert_eq!(expected, &Quantifier::Range(2, 3));
assert_eq!(actual, 4);
}
#[test]
fn empty_paragraph_validates_under_lenient_inline_star() {
let document = doc(vec![AdfNode::paragraph(vec![])]);
assert_eq!(validate_document(&document), vec![]);
}
#[test]
fn empty_doc_validates_under_lenient_block_star() {
let document = doc(vec![]);
assert_eq!(validate_document(&document), vec![]);
}
#[test]
fn empty_table_cell_validates_under_lenient_block_star() {
let document = doc(vec![node(
"table",
vec![node("tableRow", vec![node("tableCell", vec![])])],
)]);
assert_eq!(validate_document(&document), vec![]);
}
#[test]
fn empty_panel_flagged_arity() {
let document = doc(vec![panel(vec![])]);
let violations = validate_document(&document);
assert_eq!(violations.len(), 1, "got: {violations:?}");
let (parent, _, expected, actual, _) = unwrap_arity(&violations[0]);
assert_eq!(parent, "panel");
assert_eq!(expected, &Quantifier::OneOrMore);
assert_eq!(actual, 0);
}
#[test]
fn unsupported_block_satisfies_parent_arity() {
let document = doc(vec![panel(vec![leaf("unsupportedBlock")])]);
assert_eq!(validate_document(&document), vec![]);
}
#[test]
fn unsupported_inline_satisfies_inline_parent_arity() {
let task_item = with_attrs(
node("taskItem", vec![leaf("unsupportedInline")]),
serde_json::json!({"localId": "ti1", "state": "TODO"}),
);
let task_list = with_attrs(
node("taskList", vec![task_item]),
serde_json::json!({"localId": "tl1"}),
);
let document = doc(vec![task_list]);
assert_eq!(validate_document(&document), vec![]);
}
#[test]
fn display_format_for_disallowed_child_is_back_compat() {
let v = AdfSchemaViolation::DisallowedChild {
child_type: "expand".into(),
parent_type: "panel".into(),
path: vec![0, 1, 0],
};
assert_eq!(
v.to_string(),
"ADF schema violation at /0/1/0: 'expand' is not permitted inside 'panel'"
);
}
#[test]
fn display_format_for_arity_one_or_more() {
let v = AdfSchemaViolation::Arity {
parent_type: "bulletList".into(),
atoms: vec!["listItem"],
expected: Quantifier::OneOrMore,
actual: 0,
path: vec![1],
};
assert_eq!(
v.to_string(),
"ADF schema violation at /1: 'bulletList' must contain at least one 'listItem' (found 0)"
);
}
#[test]
fn display_format_for_arity_exactly_one() {
let v = AdfSchemaViolation::Arity {
parent_type: "mediaSingle".into(),
atoms: vec!["media"],
expected: Quantifier::Exactly(1),
actual: 2,
path: vec![0],
};
assert_eq!(
v.to_string(),
"ADF schema violation at /0: 'mediaSingle' must contain exactly one 'media' (found 2)"
);
}
#[test]
fn display_format_for_arity_range() {
let v = AdfSchemaViolation::Arity {
parent_type: "layoutSection".into(),
atoms: vec!["layoutColumn"],
expected: Quantifier::Range(2, 3),
actual: 1,
path: vec![0],
};
assert_eq!(
v.to_string(),
"ADF schema violation at /0: 'layoutSection' must contain between 2 and 3 'layoutColumn' (found 1)"
);
}
#[test]
fn display_format_for_arity_alternation() {
let v = AdfSchemaViolation::Arity {
parent_type: "tableRow".into(),
atoms: vec!["tableCell", "tableHeader"],
expected: Quantifier::OneOrMore,
actual: 0,
path: vec![0, 0],
};
assert_eq!(
v.to_string(),
"ADF schema violation at /0/0: 'tableRow' must contain at least one {'tableCell', 'tableHeader'} (found 0)"
);
}
#[test]
fn display_format_for_missing_attr() {
let v = AdfSchemaViolation::MissingAttr {
node_type: "panel".into(),
attr_name: "panelType".into(),
path: vec![0],
};
assert_eq!(
v.to_string(),
"ADF schema violation at /0: 'panel' is missing required attribute 'panelType'"
);
}
#[test]
fn display_format_for_invalid_attr() {
let v = AdfSchemaViolation::InvalidAttr {
node_type: "heading".into(),
attr_name: "level".into(),
problem: crate::atlassian::adf_attr_schema::AttrProblem::OutOfRange {
lo: 1,
hi: 6,
actual: 7,
},
path: vec![0],
};
let s = v.to_string();
assert!(s.contains("'heading.level'"), "got: {s}");
assert!(s.contains("invalid"), "got: {s}");
assert!(s.contains("[1, 6]"), "got: {s}");
}
#[test]
fn display_format_for_disallowed_mark() {
let v = AdfSchemaViolation::DisallowedMark {
mark_type: "code".into(),
parent_type: "heading".into(),
inline_index: Some(0),
path: vec![0, 1],
};
assert_eq!(
v.to_string(),
"ADF schema violation at /0/1: 'code' mark is not permitted on 'heading'"
);
}
#[test]
fn display_format_for_invalid_mark_attr() {
let v = AdfSchemaViolation::InvalidMarkAttr {
mark_type: "link".into(),
attr_name: "href".into(),
problem: crate::atlassian::adf_attr_schema::AttrProblem::BadFormat {
reason: "not a valid URL",
},
inline_index: Some(0),
path: vec![0, 1],
};
let s = v.to_string();
assert!(s.contains("'link' mark"), "got: {s}");
assert!(s.contains("'href'"), "got: {s}");
assert!(s.contains("not a valid URL"), "got: {s}");
}
#[test]
fn quantifier_satisfied_by() {
assert!(Quantifier::ZeroOrOne.satisfied_by(0));
assert!(Quantifier::ZeroOrOne.satisfied_by(1));
assert!(!Quantifier::ZeroOrOne.satisfied_by(2));
assert!(Quantifier::ZeroOrMore.satisfied_by(0));
assert!(Quantifier::ZeroOrMore.satisfied_by(99));
assert!(!Quantifier::OneOrMore.satisfied_by(0));
assert!(Quantifier::OneOrMore.satisfied_by(1));
assert!(!Quantifier::Exactly(2).satisfied_by(1));
assert!(Quantifier::Exactly(2).satisfied_by(2));
assert!(!Quantifier::Exactly(2).satisfied_by(3));
assert!(!Quantifier::Range(2, 3).satisfied_by(1));
assert!(Quantifier::Range(2, 3).satisfied_by(2));
assert!(Quantifier::Range(2, 3).satisfied_by(3));
assert!(!Quantifier::Range(2, 3).satisfied_by(4));
}
#[test]
fn display_format_for_arity_zero_or_one() {
let v = AdfSchemaViolation::Arity {
parent_type: "mediaSingle".into(),
atoms: vec!["caption"],
expected: Quantifier::ZeroOrOne,
actual: 2,
path: vec![0],
};
assert_eq!(
v.to_string(),
"ADF schema violation at /0: 'mediaSingle' must contain at most one 'caption' (found 2)"
);
}
#[test]
fn display_format_for_arity_zero_or_more() {
let v = AdfSchemaViolation::Arity {
parent_type: "paragraph".into(),
atoms: vec!["text"],
expected: Quantifier::ZeroOrMore,
actual: 0,
path: vec![0],
};
assert_eq!(
v.to_string(),
"ADF schema violation at /0: 'paragraph' must contain any number of 'text' (found 0)"
);
}
#[test]
fn display_format_for_arity_exactly_n_greater_than_one() {
let v = AdfSchemaViolation::Arity {
parent_type: "futureNode".into(),
atoms: vec!["child"],
expected: Quantifier::Exactly(3),
actual: 2,
path: vec![0],
};
assert_eq!(
v.to_string(),
"ADF schema violation at /0: 'futureNode' must contain exactly 3 'child' (found 2)"
);
}
#[test]
fn path_accessor_returns_path_for_each_variant() {
let v1 = AdfSchemaViolation::DisallowedChild {
child_type: "x".into(),
parent_type: "y".into(),
path: vec![1],
};
assert_eq!(v1.path(), &[1]);
let v2 = AdfSchemaViolation::Arity {
parent_type: "y".into(),
atoms: vec!["x"],
expected: Quantifier::OneOrMore,
actual: 0,
path: vec![2],
};
assert_eq!(v2.path(), &[2]);
let v3 = AdfSchemaViolation::MissingAttr {
node_type: "y".into(),
attr_name: "a".into(),
path: vec![3],
};
assert_eq!(v3.path(), &[3]);
let v4 = AdfSchemaViolation::InvalidAttr {
node_type: "y".into(),
attr_name: "a".into(),
problem: crate::atlassian::adf_attr_schema::AttrProblem::WrongType {
expected: "string",
},
path: vec![4],
};
assert_eq!(v4.path(), &[4]);
let v5 = AdfSchemaViolation::DisallowedMark {
mark_type: "code".into(),
parent_type: "heading".into(),
inline_index: Some(0),
path: vec![5],
};
assert_eq!(v5.path(), &[5]);
let v6 = AdfSchemaViolation::InvalidMarkAttr {
mark_type: "link".into(),
attr_name: "href".into(),
problem: crate::atlassian::adf_attr_schema::AttrProblem::BadFormat {
reason: "not a valid URL",
},
inline_index: Some(0),
path: vec![6],
};
assert_eq!(v6.path(), &[6]);
}
type LenientEntry = (
&'static str,
&'static [&'static str],
&'static [&'static str],
&'static str,
);
#[derive(Debug, Default)]
struct SchemaAtomDiff {
local_only_parents: Vec<&'static str>,
upstream_only_parents: Vec<&'static str>,
per_parent_unexpected: Vec<String>,
}
impl SchemaAtomDiff {
fn is_clean(&self) -> bool {
self.local_only_parents.is_empty()
&& self.upstream_only_parents.is_empty()
&& self.per_parent_unexpected.is_empty()
}
}
fn diff_atom_sets(
local: &std::collections::BTreeMap<&'static str, std::collections::BTreeSet<&'static str>>,
upstream: &std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<&'static str>,
>,
leniency: &[LenientEntry],
) -> SchemaAtomDiff {
let local_parents: std::collections::BTreeSet<&'static str> =
local.keys().copied().collect();
let upstream_parents: std::collections::BTreeSet<&'static str> =
upstream.keys().copied().collect();
let mut diff = SchemaAtomDiff {
local_only_parents: local_parents
.difference(&upstream_parents)
.copied()
.collect(),
upstream_only_parents: upstream_parents
.difference(&local_parents)
.copied()
.collect(),
per_parent_unexpected: Vec::new(),
};
for parent in local_parents.intersection(&upstream_parents) {
let l = &local[parent];
let u = &upstream[parent];
let allowed_upstream_extra: std::collections::BTreeSet<&str> = leniency
.iter()
.filter(|(p, _, _, _)| p == parent)
.flat_map(|(_, ue, _, _)| ue.iter().copied())
.collect();
let allowed_local_extra: std::collections::BTreeSet<&str> = leniency
.iter()
.filter(|(p, _, _, _)| p == parent)
.flat_map(|(_, _, le, _)| le.iter().copied())
.collect();
let upstream_extra: Vec<&str> = u
.iter()
.filter(|c| !l.contains(**c) && !allowed_upstream_extra.contains(**c))
.copied()
.collect();
let local_extra: Vec<&str> = l
.iter()
.filter(|c| !u.contains(**c) && !allowed_local_extra.contains(**c))
.copied()
.collect();
if !upstream_extra.is_empty() || !local_extra.is_empty() {
diff.per_parent_unexpected.push(format!(
"{parent}: upstream_only={upstream_extra:?}, local_only={local_extra:?}"
));
}
}
diff
}
const LENIENCY_ALLOWLIST: &[LenientEntry] = &[];
fn upstream_atom_map(
) -> std::collections::BTreeMap<&'static str, std::collections::BTreeSet<&'static str>> {
generated::UPSTREAM_ENTRIES
.iter()
.map(|(p, children)| (*p, children.iter().copied().collect()))
.collect()
}
#[test]
fn generated_upstream_atoms_match_local_snapshot() {
let local = local_schema_map();
let upstream = upstream_atom_map();
let diff = diff_atom_sets(&local, &upstream, LENIENCY_ALLOWLIST);
assert!(
diff.is_clean(),
"atom-set drift between CONTENT_ENTRIES and generated::UPSTREAM_ENTRIES:\n\
local_only_parents={:?}\n\
upstream_only_parents={:?}\n\
per_parent_unexpected={:?}",
diff.local_only_parents,
diff.upstream_only_parents,
diff.per_parent_unexpected,
);
}
#[test]
fn diff_atom_sets_reports_clean_when_maps_agree() {
let mut m: std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<&'static str>,
> = std::collections::BTreeMap::new();
m.insert("panel", ["paragraph", "heading"].into_iter().collect());
let diff = diff_atom_sets(&m, &m.clone(), &[]);
assert!(diff.is_clean());
assert!(diff.local_only_parents.is_empty());
assert!(diff.upstream_only_parents.is_empty());
assert!(diff.per_parent_unexpected.is_empty());
}
#[test]
fn diff_atom_sets_reports_local_only_parents() {
let mut local: std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<&'static str>,
> = std::collections::BTreeMap::new();
local.insert("legacyNode", std::iter::once("paragraph").collect());
let upstream: std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<&'static str>,
> = std::collections::BTreeMap::new();
let diff = diff_atom_sets(&local, &upstream, &[]);
assert!(!diff.is_clean());
assert_eq!(diff.local_only_parents, vec!["legacyNode"]);
assert!(diff.upstream_only_parents.is_empty());
}
#[test]
fn diff_atom_sets_reports_upstream_only_parents() {
let local: std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<&'static str>,
> = std::collections::BTreeMap::new();
let mut upstream: std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<&'static str>,
> = std::collections::BTreeMap::new();
upstream.insert("newNode", std::iter::once("paragraph").collect());
let diff = diff_atom_sets(&local, &upstream, &[]);
assert!(!diff.is_clean());
assert_eq!(diff.upstream_only_parents, vec!["newNode"]);
assert!(diff.local_only_parents.is_empty());
}
#[test]
fn diff_atom_sets_reports_unexpected_per_parent_diffs() {
let mut local: std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<&'static str>,
> = std::collections::BTreeMap::new();
local.insert(
"panel",
["paragraph", "heading"]
.into_iter()
.collect::<std::collections::BTreeSet<_>>(),
);
let mut upstream = local.clone();
upstream.insert("panel", ["paragraph", "blockCard"].into_iter().collect());
let diff = diff_atom_sets(&local, &upstream, &[]);
assert!(!diff.is_clean());
let msg = diff.per_parent_unexpected.join("\n");
assert!(msg.contains("panel"));
assert!(
msg.contains("blockCard"),
"upstream_only should mention blockCard: {msg}"
);
assert!(
msg.contains("heading"),
"local_only should mention heading: {msg}"
);
}
#[test]
fn diff_atom_sets_honours_leniency_allowlist() {
let mut local: std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<&'static str>,
> = std::collections::BTreeMap::new();
local.insert("panel", ["paragraph", "heading"].into_iter().collect());
let mut upstream: std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<&'static str>,
> = std::collections::BTreeMap::new();
upstream.insert("panel", ["paragraph", "blockCard"].into_iter().collect());
let lenient: &[LenientEntry] = &[(
"panel",
&["blockCard"], &["heading"], "synthetic test deviation",
)];
let diff = diff_atom_sets(&local, &upstream, lenient);
assert!(diff.is_clean(), "allowlist should mask the diff: {diff:?}");
}
#[test]
fn generated_provenance_matches_local_constants() {
assert_eq!(
generated::UPSTREAM_TARBALL_SHA256,
UPSTREAM_TARBALL_SHA256,
"the vendored JSON's provenance SHA must match the runtime constant; \
both are bumped together when the snapshot is refreshed",
);
let date_len = "-YYYY-MM-DD".len();
let local_npm_prefix = SCHEMA_VERSION
.get(..SCHEMA_VERSION.len().saturating_sub(date_len))
.unwrap_or(SCHEMA_VERSION);
assert_eq!(
generated::UPSTREAM_VERSION,
local_npm_prefix,
"generated UPSTREAM_VERSION must match the npm-version prefix of SCHEMA_VERSION",
);
}
}