use std::collections::HashMap;
use std::sync::LazyLock;
use serde_json::Value;
use crate::atlassian::adf_attr_schema::{AttrPresence, AttrSchema, AttrType};
use crate::atlassian::adf_schema::AdfSchemaViolation;
const STD_INLINE_MARKS: &[&str] = &[
"annotation",
"backgroundColor",
"code",
"em",
"link",
"strike",
"strong",
"subsup",
"textColor",
"underline",
];
const HEADING_INLINE_MARKS: &[&str] = &[
"annotation",
"backgroundColor",
"em",
"link",
"strike",
"strong",
"subsup",
"textColor",
"underline",
];
const CODE_BLOCK_INLINE_MARKS: &[&str] = &[];
const CAPTION_INLINE_MARKS: &[&str] = &[
"backgroundColor",
"em",
"link",
"strike",
"strong",
"subsup",
"textColor",
"underline",
];
const PARAGRAPH_BLOCK_MARKS: &[&str] = &["alignment", "indentation"];
const HEADING_BLOCK_MARKS: &[&str] = &["alignment", "indentation"];
const TABLE_CELL_BLOCK_MARKS: &[&str] = &["backgroundColor", "border"];
const TABLE_HEADER_BLOCK_MARKS: &[&str] = &["backgroundColor", "border"];
const CODE_INLINE_MARK_GROUP: &[&str] = &["annotation", "code", "link"];
const FORMATTED_INLINE_MARK_GROUP: &[&str] = &[
"annotation",
"backgroundColor",
"em",
"link",
"strike",
"strong",
"subsup",
"textColor",
"underline",
];
const INLINE_MARK_GROUPS: &[&[&str]] = &[CODE_INLINE_MARK_GROUP, FORMATTED_INLINE_MARK_GROUP];
fn marks_may_coexist(a: &str, b: &str) -> bool {
let in_a_group = |m: &str| INLINE_MARK_GROUPS.iter().any(|g| g.contains(&m));
if !in_a_group(a) || !in_a_group(b) {
return true;
}
INLINE_MARK_GROUPS
.iter()
.any(|g| g.contains(&a) && g.contains(&b))
}
const INLINE_MARKS_ENTRIES: &[(&str, &[&str])] = &[
("caption", CAPTION_INLINE_MARKS),
("codeBlock", CODE_BLOCK_INLINE_MARKS),
("decisionItem", STD_INLINE_MARKS),
("heading", HEADING_INLINE_MARKS),
("paragraph", STD_INLINE_MARKS),
("taskItem", STD_INLINE_MARKS),
];
const BLOCK_MARKS_ENTRIES: &[(&str, &[&str])] = &[
("heading", HEADING_BLOCK_MARKS),
("paragraph", PARAGRAPH_BLOCK_MARKS),
("tableCell", TABLE_CELL_BLOCK_MARKS),
("tableHeader", TABLE_HEADER_BLOCK_MARKS),
];
static INLINE_MARKS: LazyLock<HashMap<&'static str, &'static [&'static str]>> =
LazyLock::new(|| INLINE_MARKS_ENTRIES.iter().copied().collect());
static BLOCK_MARKS: LazyLock<HashMap<&'static str, &'static [&'static str]>> =
LazyLock::new(|| BLOCK_MARKS_ENTRIES.iter().copied().collect());
const INLINE_NODE_TYPES: &[&str] = &[
"date",
"emoji",
"hardBreak",
"inlineCard",
"inlineExtension",
"mediaInline",
"mention",
"placeholder",
"status",
"text",
];
#[must_use]
pub fn allowed_inline_marks(parent: &str) -> Option<&'static [&'static str]> {
INLINE_MARKS.get(parent).copied()
}
#[must_use]
pub fn allowed_block_marks(node_type: &str) -> Option<&'static [&'static str]> {
BLOCK_MARKS.get(node_type).copied()
}
#[must_use]
pub fn is_inline_node(node_type: &str) -> bool {
INLINE_NODE_TYPES.contains(&node_type)
}
fn is_unsupported_mark(mark_type: &str) -> bool {
mark_type == "unsupportedMark" || mark_type == "unsupportedNodeAttribute"
}
const ENUM_SUBSUP_TYPE: &[&str] = &["sub", "sup"];
const ENUM_ALIGNMENT_ALIGN: &[&str] = &["start", "end", "center", "right", "left"];
const ENUM_BREAKOUT_MODE: &[&str] = &["wide", "full-width"];
type MarkAttrEntry = (&'static str, AttrSchema);
const MARK_ATTR_ENTRIES: &[MarkAttrEntry] = &[
(
"alignment",
AttrSchema {
fields: &[(
"align",
AttrType::Enum(ENUM_ALIGNMENT_ALIGN),
AttrPresence::Required,
)],
},
),
(
"annotation",
AttrSchema {
fields: &[
("id", AttrType::String, AttrPresence::Required),
("annotationType", AttrType::String, AttrPresence::Required),
],
},
),
(
"backgroundColor",
AttrSchema {
fields: &[("color", AttrType::String, AttrPresence::Required)],
},
),
(
"border",
AttrSchema {
fields: &[
("color", AttrType::String, AttrPresence::Required),
("size", AttrType::IntRange(1, 3), AttrPresence::Required),
],
},
),
(
"breakout",
AttrSchema {
fields: &[(
"mode",
AttrType::Enum(ENUM_BREAKOUT_MODE),
AttrPresence::Required,
)],
},
),
("code", AttrSchema { fields: &[] }),
("em", AttrSchema { fields: &[] }),
(
"indentation",
AttrSchema {
fields: &[("level", AttrType::IntRange(1, 6), AttrPresence::Required)],
},
),
(
"link",
AttrSchema {
fields: &[
("href", AttrType::Url, AttrPresence::Required),
("title", AttrType::String, AttrPresence::Optional),
("id", AttrType::String, AttrPresence::Optional),
("collection", AttrType::String, AttrPresence::Optional),
("occurrenceKey", AttrType::String, AttrPresence::Optional),
],
},
),
("strike", AttrSchema { fields: &[] }),
("strong", AttrSchema { fields: &[] }),
(
"subsup",
AttrSchema {
fields: &[(
"type",
AttrType::Enum(ENUM_SUBSUP_TYPE),
AttrPresence::Required,
)],
},
),
(
"textColor",
AttrSchema {
fields: &[("color", AttrType::String, AttrPresence::Required)],
},
),
("underline", AttrSchema { fields: &[] }),
];
static MARK_ATTR_SCHEMAS: LazyLock<HashMap<&'static str, &'static AttrSchema>> =
LazyLock::new(|| {
MARK_ATTR_ENTRIES
.iter()
.map(|(mark_type, schema)| (*mark_type, schema))
.collect()
});
#[must_use]
pub fn mark_attr_schema(mark_type: &str) -> Option<&'static AttrSchema> {
MARK_ATTR_SCHEMAS.get(mark_type).copied()
}
pub fn validate_marks(
parent_type: &str,
node: &crate::atlassian::adf::AdfNode,
path: &[usize],
out: &mut Vec<AdfSchemaViolation>,
) {
let Some(marks) = node.marks.as_ref() else {
return;
};
if marks.is_empty() {
return;
}
let node_type = node.node_type.as_str();
let allowed = if is_inline_node(node_type) {
allowed_inline_marks(parent_type)
} else {
allowed_block_marks(node_type)
};
for (mark_idx, mark) in marks.iter().enumerate() {
let mark_type = mark.mark_type.as_str();
if is_unsupported_mark(mark_type) {
continue;
}
if let Some(allowed) = allowed {
if !allowed.contains(&mark_type) {
out.push(AdfSchemaViolation::DisallowedMark {
mark_type: mark_type.to_string(),
parent_type: if is_inline_node(node_type) {
parent_type.to_string()
} else {
node_type.to_string()
},
inline_index: if is_inline_node(node_type) {
Some(*path.last().unwrap_or(&0))
} else {
None
},
path: path.to_vec(),
});
continue;
}
}
if let Some(schema) = mark_attr_schema(mark_type) {
validate_mark_attrs_against(
schema,
mark_type,
mark.attrs.as_ref(),
mark_idx,
path,
out,
);
}
}
if is_inline_node(node_type) {
check_inline_mark_combination(parent_type, marks, path, out);
}
}
fn check_inline_mark_combination(
parent_type: &str,
marks: &[crate::atlassian::adf::AdfMark],
path: &[usize],
out: &mut Vec<AdfSchemaViolation>,
) {
let mut seen: Vec<&str> = Vec::new();
for mark in marks {
let m = mark.mark_type.as_str();
if is_unsupported_mark(m) || seen.contains(&m) {
continue;
}
seen.push(m);
}
for i in 0..seen.len() {
for j in (i + 1)..seen.len() {
if !marks_may_coexist(seen[i], seen[j]) {
out.push(AdfSchemaViolation::ForbiddenMarkCombination {
mark_type: seen[i].to_string(),
conflicts_with: seen[j].to_string(),
parent_type: parent_type.to_string(),
inline_index: Some(*path.last().unwrap_or(&0)),
path: path.to_vec(),
});
return;
}
}
}
}
fn validate_mark_attrs_against(
schema: &AttrSchema,
mark_type: &str,
attrs: Option<&Value>,
mark_idx: usize,
path: &[usize],
out: &mut Vec<AdfSchemaViolation>,
) {
let mut tmp: Vec<AdfSchemaViolation> = Vec::new();
crate::atlassian::adf_attr_schema::validate_attrs(
"<__adf_mark_inline__>",
attrs,
path,
&mut tmp,
);
debug_assert!(
tmp.is_empty(),
"sentinel must not match a registered schema"
);
let attr_obj = match attrs {
Some(Value::Object(map)) => Some(map),
Some(Value::Null) | None => None,
Some(_other) => {
for (field, _ty, presence) in schema.fields {
if *presence == AttrPresence::Required {
out.push(AdfSchemaViolation::DisallowedMark {
mark_type: mark_type.to_string(),
parent_type: format!("<malformed attrs for mark '{mark_type}'>"),
inline_index: Some(mark_idx),
path: path.to_vec(),
});
let _ = field;
return;
}
}
return;
}
};
for (field, ty, presence) in schema.fields {
let value = attr_obj.and_then(|m| m.get(*field));
let value = match value {
Some(Value::Null) | None => None,
Some(v) => Some(v),
};
match (value, *presence) {
(None, AttrPresence::Required) => {
out.push(AdfSchemaViolation::InvalidMarkAttr {
mark_type: mark_type.to_string(),
attr_name: (*field).to_string(),
problem: crate::atlassian::adf_attr_schema::AttrProblem::WrongType {
expected: "present",
},
inline_index: Some(mark_idx),
path: path.to_vec(),
});
}
(None, AttrPresence::Optional) => {}
(Some(v), _) => {
if let Some(problem) = crate::atlassian::adf_attr_schema::check_value(ty, v) {
out.push(AdfSchemaViolation::InvalidMarkAttr {
mark_type: mark_type.to_string(),
attr_name: (*field).to_string(),
problem,
inline_index: Some(mark_idx),
path: path.to_vec(),
});
}
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::needless_collect)]
mod tests {
use super::*;
use crate::atlassian::adf::{AdfMark, AdfNode};
fn text_with_marks(text: &str, marks: Vec<AdfMark>) -> AdfNode {
AdfNode {
node_type: "text".to_string(),
attrs: None,
content: None,
text: Some(text.to_string()),
marks: Some(marks),
local_id: None,
parameters: None,
}
}
fn paragraph_with_marks(marks: Vec<AdfMark>, content: Vec<AdfNode>) -> AdfNode {
AdfNode {
node_type: "paragraph".to_string(),
attrs: None,
content: if content.is_empty() {
None
} else {
Some(content)
},
text: None,
marks: Some(marks),
local_id: None,
parameters: None,
}
}
fn mark(mark_type: &str, attrs: Option<serde_json::Value>) -> AdfMark {
AdfMark {
mark_type: mark_type.to_string(),
attrs,
}
}
fn run_inline(parent: &str, child: AdfNode) -> Vec<AdfSchemaViolation> {
let mut out = Vec::new();
validate_marks(parent, &child, &[0_usize], &mut out);
out
}
fn run_block(node: AdfNode) -> Vec<AdfSchemaViolation> {
let mut out = Vec::new();
validate_marks("doc", &node, &[0_usize], &mut out);
out
}
#[test]
fn paragraph_allows_code_mark_on_text() {
let node = text_with_marks("hi", vec![mark("code", None)]);
assert!(run_inline("paragraph", node).is_empty());
}
#[test]
fn heading_rejects_code_mark_on_text() {
let node = text_with_marks("hi", vec![mark("code", None)]);
let v = run_inline("heading", node);
assert_eq!(v.len(), 1, "got: {v:?}");
match &v[0] {
AdfSchemaViolation::DisallowedMark {
mark_type,
parent_type,
..
} => {
assert_eq!(mark_type, "code");
assert_eq!(parent_type, "heading");
}
other => panic!("expected DisallowedMark, got {other:?}"),
}
}
#[test]
fn code_block_rejects_any_mark_on_text() {
let node = text_with_marks("hi", vec![mark("strong", None)]);
let v = run_inline("codeBlock", node);
assert_eq!(v.len(), 1);
}
#[test]
fn unknown_parent_skips_mark_validation() {
let node = text_with_marks("hi", vec![mark("madeUp", None)]);
assert!(run_inline("madeUpParent", node).is_empty());
}
#[test]
fn unsupported_mark_accepted_anywhere() {
let node = text_with_marks(
"hi",
vec![
mark("unsupportedMark", None),
mark("unsupportedNodeAttribute", None),
],
);
assert!(run_inline("heading", node).is_empty());
}
#[test]
fn paragraph_block_allows_alignment() {
let node = paragraph_with_marks(
vec![mark(
"alignment",
Some(serde_json::json!({"align": "center"})),
)],
vec![AdfNode::text("x")],
);
assert!(run_block(node).is_empty());
}
#[test]
fn paragraph_block_rejects_border() {
let node = paragraph_with_marks(
vec![mark(
"border",
Some(serde_json::json!({"color": "#ff0000", "size": 1})),
)],
vec![AdfNode::text("x")],
);
let v = run_block(node);
let disallowed: Vec<_> = v
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::DisallowedMark { .. }))
.collect();
assert_eq!(disallowed.len(), 1, "got: {v:?}");
}
#[test]
fn table_cell_allows_border() {
let cell = AdfNode {
node_type: "tableCell".to_string(),
attrs: None,
content: None,
text: None,
marks: Some(vec![mark(
"border",
Some(serde_json::json!({"color": "#ff0000", "size": 2})),
)]),
local_id: None,
parameters: None,
};
assert!(run_block(cell).is_empty());
}
#[test]
fn link_mark_with_valid_href_validates() {
let node = text_with_marks(
"hi",
vec![mark(
"link",
Some(serde_json::json!({"href": "https://x.com"})),
)],
);
assert!(run_inline("paragraph", node).is_empty());
}
#[test]
fn link_mark_with_invalid_href_flagged() {
let node = text_with_marks(
"hi",
vec![mark("link", Some(serde_json::json!({"href": "not a url"})))],
);
let v = run_inline("paragraph", node);
let invalid: Vec<_> = v
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
.collect();
assert_eq!(invalid.len(), 1, "got: {v:?}");
}
#[test]
fn link_mark_missing_href_flagged() {
let node = text_with_marks("hi", vec![mark("link", Some(serde_json::json!({})))]);
let v = run_inline("paragraph", node);
let invalid: Vec<_> = v
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
.collect();
assert_eq!(invalid.len(), 1);
}
#[test]
fn subsup_known_type_validates() {
for t in ["sub", "sup"] {
let node = text_with_marks(
"hi",
vec![mark("subsup", Some(serde_json::json!({"type": t})))],
);
assert!(run_inline("paragraph", node).is_empty());
}
}
#[test]
fn subsup_unknown_type_flagged() {
let node = text_with_marks(
"hi",
vec![mark("subsup", Some(serde_json::json!({"type": "side"})))],
);
let v = run_inline("paragraph", node);
let invalid: Vec<_> = v
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
.collect();
assert_eq!(invalid.len(), 1);
}
#[test]
fn indentation_level_in_range() {
let node = paragraph_with_marks(
vec![mark("indentation", Some(serde_json::json!({"level": 3})))],
vec![AdfNode::text("x")],
);
assert!(run_block(node).is_empty());
}
#[test]
fn indentation_level_out_of_range_flagged() {
let node = paragraph_with_marks(
vec![mark("indentation", Some(serde_json::json!({"level": 10})))],
vec![AdfNode::text("x")],
);
let v = run_block(node);
let invalid: Vec<_> = v
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
.collect();
assert_eq!(invalid.len(), 1);
}
#[test]
fn border_with_size_too_large_flagged() {
let cell = AdfNode {
node_type: "tableCell".to_string(),
attrs: None,
content: None,
text: None,
marks: Some(vec![mark(
"border",
Some(serde_json::json!({"color": "#ff0000", "size": 5})),
)]),
local_id: None,
parameters: None,
};
let v = run_block(cell);
let invalid: Vec<_> = v
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. }))
.collect();
assert_eq!(invalid.len(), 1);
}
#[test]
fn empty_marks_array_no_violations() {
let node = text_with_marks("hi", vec![]);
assert!(run_inline("paragraph", node).is_empty());
}
#[test]
fn no_marks_field_no_violations() {
let node = AdfNode::text("hi");
assert!(run_inline("paragraph", node).is_empty());
}
#[test]
fn link_mark_with_array_attrs_flagged_as_disallowed_mark() {
let node = text_with_marks(
"click",
vec![mark("link", Some(serde_json::json!([1, 2, 3])))],
);
let v = run_inline("paragraph", node);
let disallowed: Vec<_> = v
.iter()
.filter(|v| matches!(v, AdfSchemaViolation::DisallowedMark { .. }))
.collect();
assert_eq!(disallowed.len(), 1, "got: {v:?}");
match disallowed[0] {
AdfSchemaViolation::DisallowedMark {
mark_type,
parent_type,
..
} => {
assert_eq!(mark_type, "link");
assert!(
parent_type.contains("malformed attrs"),
"expected malformed-attrs sentinel, got: {parent_type}"
);
}
other => panic!("expected DisallowedMark, got {other:?}"),
}
}
#[test]
fn code_mark_with_array_attrs_no_violation() {
let node = text_with_marks("x", vec![mark("code", Some(serde_json::json!([1, 2, 3])))]);
assert!(run_inline("paragraph", node).is_empty());
}
fn combos(v: &[AdfSchemaViolation]) -> Vec<(&str, &str)> {
v.iter()
.filter_map(|x| match x {
AdfSchemaViolation::ForbiddenMarkCombination {
mark_type,
conflicts_with,
..
} => Some((mark_type.as_str(), conflicts_with.as_str())),
_ => None,
})
.collect()
}
#[test]
fn strong_plus_code_is_forbidden() {
let node = text_with_marks("hi", vec![mark("strong", None), mark("code", None)]);
let v = run_inline("paragraph", node);
assert_eq!(combos(&v), vec![("strong", "code")], "got: {v:?}");
}
#[test]
fn code_plus_background_color_is_forbidden() {
let node = text_with_marks(
"hi",
vec![
mark("code", None),
mark(
"backgroundColor",
Some(serde_json::json!({"color": "#ff0000"})),
),
],
);
let v = run_inline("paragraph", node);
assert_eq!(combos(&v), vec![("code", "backgroundColor")], "got: {v:?}");
}
#[test]
fn code_plus_link_is_allowed() {
let node = text_with_marks(
"hi",
vec![
mark("code", None),
mark("link", Some(serde_json::json!({"href": "https://x.com"}))),
],
);
let v = run_inline("paragraph", node);
assert!(combos(&v).is_empty(), "got: {v:?}");
}
#[test]
fn code_plus_annotation_is_allowed() {
let node = text_with_marks(
"hi",
vec![
mark("code", None),
mark(
"annotation",
Some(serde_json::json!({"id": "a1", "annotationType": "inlineComment"})),
),
],
);
let v = run_inline("paragraph", node);
assert!(combos(&v).is_empty(), "got: {v:?}");
}
#[test]
fn styling_marks_combine_freely() {
let node = text_with_marks(
"hi",
vec![
mark("strong", None),
mark("em", None),
mark("strike", None),
mark("underline", None),
],
);
let v = run_inline("paragraph", node);
assert!(combos(&v).is_empty(), "got: {v:?}");
}
#[test]
fn link_plus_text_color_is_allowed() {
let node = text_with_marks(
"hi",
vec![
mark("link", Some(serde_json::json!({"href": "https://x.com"}))),
mark("textColor", Some(serde_json::json!({"color": "#0000ff"}))),
],
);
let v = run_inline("paragraph", node);
assert!(combos(&v).is_empty(), "got: {v:?}");
}
#[test]
fn heading_bold_code_flags_both_disallowed_and_combination() {
let node = text_with_marks("hi", vec![mark("strong", None), mark("code", None)]);
let v = run_inline("heading", node);
assert!(
v.iter()
.any(|x| matches!(x, AdfSchemaViolation::DisallowedMark { .. })),
"expected a DisallowedMark for code-on-heading too, got: {v:?}"
);
assert_eq!(combos(&v), vec![("strong", "code")], "got: {v:?}");
}
#[test]
fn only_first_conflicting_pair_is_reported() {
let node = text_with_marks(
"hi",
vec![mark("code", None), mark("strong", None), mark("em", None)],
);
let v = run_inline("paragraph", node);
assert_eq!(combos(&v), vec![("code", "strong")], "got: {v:?}");
}
#[test]
fn combination_check_records_parent_and_inline_index() {
let node = text_with_marks("hi", vec![mark("code", None), mark("strong", None)]);
let mut out = Vec::new();
validate_marks("paragraph", &node, &[3_usize], &mut out);
assert_eq!(out.len(), 1, "got: {out:?}");
assert!(
matches!(
&out[0],
AdfSchemaViolation::ForbiddenMarkCombination { parent_type, inline_index, path, .. }
if parent_type == "paragraph" && *inline_index == Some(3) && path.as_slice() == [3]
),
"got: {out:?}"
);
}
#[test]
fn marks_may_coexist_treats_unknown_marks_as_non_conflicting() {
assert!(marks_may_coexist("code", "madeUpMark"));
assert!(marks_may_coexist("madeUpMark", "strong"));
}
#[test]
fn inline_node_under_unknown_parent_skips_mark_check() {
let node = text_with_marks(
"x",
vec![mark("link", Some(serde_json::json!({"href": "not a url"})))],
);
let v = run_inline("madeUpParent", node);
assert!(v
.iter()
.all(|v| !matches!(v, AdfSchemaViolation::DisallowedMark { .. })));
assert!(v
.iter()
.any(|v| matches!(v, AdfSchemaViolation::InvalidMarkAttr { .. })));
}
}