use std::ops::Deref;
use serde::Serialize;
use crate::atlassian::adf::AdfDocument;
use crate::atlassian::adf_schema::{validate_document, AdfSchemaViolation};
#[derive(Debug, Clone, PartialEq)]
pub struct AdfValidationError {
pub violations: Vec<AdfSchemaViolation>,
}
impl std::fmt::Display for AdfValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut out = String::new();
for (i, v) in self.violations.iter().enumerate() {
if i > 0 {
out.push_str("\n\n");
}
let path = v
.path()
.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join("/");
match v {
AdfSchemaViolation::DisallowedChild {
child_type,
parent_type,
..
} => {
out.push_str(&format!(
"invalid ADF nesting — `{child_type}` cannot be a child of `{parent_type}` at /{path}.\n",
));
let hint = hint_for(parent_type, child_type).map_or_else(
|| {
format!(
"hint: restructure the document so `{child_type}` is not a direct child of `{parent_type}`.",
)
},
|h| format!("hint: {h}"),
);
out.push_str(&hint);
}
AdfSchemaViolation::Arity { .. } => {
out.push_str(&format!("invalid ADF nesting — {v}.\n"));
out.push_str(
"hint: adjust the number of children to match the schema's quantifier.",
);
}
AdfSchemaViolation::MissingAttr { .. } | AdfSchemaViolation::InvalidAttr { .. } => {
out.push_str(&format!("invalid ADF attribute — {v}.\n"));
out.push_str("hint: fix the offending attribute on the node before retrying.");
}
AdfSchemaViolation::DisallowedMark { .. }
| AdfSchemaViolation::InvalidMarkAttr { .. } => {
out.push_str(&format!("invalid ADF mark — {v}.\n"));
out.push_str("hint: remove or correct the offending mark before retrying.");
}
}
}
f.write_str(&out)
}
}
impl std::error::Error for AdfValidationError {}
fn hint_for(parent: &str, child: &str) -> Option<&'static str> {
HINTS
.iter()
.find(|(p, c, _)| *p == parent && *c == child)
.map(|(_, _, h)| *h)
}
const HINTS: &[(&str, &str, &str)] = &[
(
"panel",
"expand",
"invert the nesting (put the panel inside the expand) or use siblings.",
),
(
"panel",
"nestedExpand",
"invert the nesting (put the panel inside the expand) or use siblings.",
),
(
"panel",
"panel",
"panels cannot nest; use siblings or convert one to a blockquote.",
),
(
"expand",
"expand",
"expands cannot nest directly; consider a single expand with sectioned headings.",
),
(
"expand",
"nestedExpand",
"use a plain `expand` at the inner level only inside table cells or layout columns.",
),
(
"nestedExpand",
"expand",
"nestedExpand cannot contain another expand; flatten the structure.",
),
(
"nestedExpand",
"nestedExpand",
"nestedExpand cannot nest; use siblings.",
),
(
"nestedExpand",
"panel",
"move the panel outside the nestedExpand or replace it with a blockquote.",
),
(
"tableCell",
"expand",
"use a `nestedExpand` inside table cells; `expand` is only valid at the top level or inside layout columns.",
),
(
"tableHeader",
"expand",
"use a `nestedExpand` inside table headers; `expand` is only valid at the top level or inside layout columns.",
),
(
"tableCell",
"panel",
"panels are not allowed inside table cells; move the panel outside the table.",
),
(
"tableHeader",
"panel",
"panels are not allowed inside table headers; move the panel outside the table.",
),
(
"layoutSection",
"layoutSection",
"layout sections cannot nest; use sibling sections.",
),
(
"layoutColumn",
"layoutSection",
"a layout column cannot contain another layout section; flatten the structure.",
),
(
"blockquote",
"blockquote",
"blockquotes cannot nest; use a single blockquote with paragraph siblings.",
),
(
"blockquote",
"panel",
"move the panel outside the blockquote.",
),
(
"blockquote",
"expand",
"move the expand outside the blockquote.",
),
(
"listItem",
"panel",
"panels cannot appear inside list items; place the panel outside the list.",
),
(
"listItem",
"expand",
"expands cannot appear inside list items; place the expand outside the list.",
),
];
pub fn validate(doc: &AdfDocument) -> Result<(), AdfValidationError> {
let violations = validate_document(doc);
if violations.is_empty() {
Ok(())
} else {
Err(AdfValidationError { violations })
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidatedAdfDocument(AdfDocument);
impl ValidatedAdfDocument {
pub fn try_new(doc: AdfDocument) -> Result<Self, AdfValidationError> {
let violations = validate_document(&doc);
if violations.is_empty() {
Ok(Self(doc))
} else {
Err(AdfValidationError { violations })
}
}
#[must_use]
pub fn empty() -> Self {
Self(AdfDocument::new())
}
#[cfg(test)]
#[must_use]
pub fn trust(doc: AdfDocument) -> Self {
Self(doc)
}
}
impl Deref for ValidatedAdfDocument {
type Target = AdfDocument;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Serialize for ValidatedAdfDocument {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::atlassian::adf::AdfNode;
fn doc(nodes: Vec<AdfNode>) -> AdfDocument {
AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: nodes,
}
}
#[test]
fn try_new_accepts_clean_document() {
let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("ok")])]);
let v = ValidatedAdfDocument::try_new(d).unwrap();
assert_eq!(v.content.len(), 1);
}
#[test]
fn try_new_rejects_panel_with_expand() {
let d = doc(vec![AdfNode::panel(
"info",
vec![AdfNode::expand(None, vec![])],
)]);
let err = ValidatedAdfDocument::try_new(d).unwrap_err();
assert!(err.violations.iter().any(|v| matches!(
v,
AdfSchemaViolation::DisallowedChild { child_type, parent_type, .. }
if child_type == "expand" && parent_type == "panel"
)));
}
#[test]
fn try_new_rejects_table_cell_with_expand() {
let d = doc(vec![AdfNode::table(vec![AdfNode::table_row(vec![
AdfNode::table_cell(vec![AdfNode::expand(None, vec![])]),
])])]);
let err = ValidatedAdfDocument::try_new(d).unwrap_err();
assert!(err.violations.iter().any(|v| matches!(
v,
AdfSchemaViolation::DisallowedChild { child_type, parent_type, .. }
if child_type == "expand" && parent_type == "tableCell"
)));
}
#[test]
fn try_new_allows_expand_inside_layout_column() {
let inner = || AdfNode::paragraph(vec![AdfNode::text("x")]);
let column = || AdfNode::layout_column(50, vec![AdfNode::expand(None, vec![inner()])]);
let d = doc(vec![AdfNode::layout_section(vec![column(), column()])]);
assert!(ValidatedAdfDocument::try_new(d).is_ok());
}
#[test]
fn empty_is_trivially_valid() {
let v = ValidatedAdfDocument::empty();
assert!(v.content.is_empty());
}
#[test]
fn serializes_as_inner_adf() {
let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("hello")])]);
let v = ValidatedAdfDocument::try_new(d.clone()).unwrap();
let v_json = serde_json::to_string(&v).unwrap();
let d_json = serde_json::to_string(&d).unwrap();
assert_eq!(v_json, d_json);
}
#[test]
fn deref_exposes_inner_fields() {
let d = doc(vec![AdfNode::paragraph(vec![])]);
let v = ValidatedAdfDocument::try_new(d).unwrap();
assert_eq!(v.version, 1);
assert_eq!(v.doc_type, "doc");
}
#[test]
fn error_display_includes_path_and_hint_for_known_pair() {
let d = doc(vec![AdfNode::panel(
"info",
vec![AdfNode::expand(None, vec![])],
)]);
let err = ValidatedAdfDocument::try_new(d).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("invalid ADF nesting"));
assert!(msg.contains("`expand` cannot be a child of `panel`"));
assert!(msg.contains("at /0/0"));
assert!(msg.contains("hint: invert the nesting"));
}
#[test]
fn error_display_falls_back_to_generic_hint_for_unknown_pair() {
let d = doc(vec![AdfNode::paragraph(vec![AdfNode::table(vec![])])]);
let err = ValidatedAdfDocument::try_new(d).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("invalid ADF nesting"));
assert!(msg.contains("`table` cannot be a child of `paragraph`"));
assert!(msg.contains("hint: restructure the document"));
}
#[test]
fn error_display_separates_multiple_violations() {
let d = doc(vec![
AdfNode::panel("info", vec![AdfNode::expand(None, vec![])]),
AdfNode::blockquote(vec![AdfNode::panel("note", vec![])]),
]);
let err = ValidatedAdfDocument::try_new(d).unwrap_err();
assert!(err.violations.len() >= 2);
let msg = err.to_string();
assert!(msg.contains("\n\n"));
}
#[test]
fn error_display_for_missing_attr_violation() {
let err = AdfValidationError {
violations: vec![AdfSchemaViolation::MissingAttr {
node_type: "panel".to_string(),
attr_name: "panelType".to_string(),
path: vec![0],
}],
};
let msg = err.to_string();
assert!(msg.contains("invalid ADF attribute"), "got: {msg}");
assert!(msg.contains("'panelType'"), "got: {msg}");
assert!(msg.contains("hint:"), "got: {msg}");
}
#[test]
fn error_display_for_invalid_attr_violation() {
use crate::atlassian::adf_attr_schema::AttrProblem;
let err = AdfValidationError {
violations: vec![AdfSchemaViolation::InvalidAttr {
node_type: "heading".to_string(),
attr_name: "level".to_string(),
problem: AttrProblem::OutOfRange {
lo: 1,
hi: 6,
actual: 7,
},
path: vec![0],
}],
};
let msg = err.to_string();
assert!(msg.contains("invalid ADF attribute"), "got: {msg}");
assert!(msg.contains("'heading.level'"), "got: {msg}");
}
#[test]
fn error_display_for_disallowed_mark_violation() {
let err = AdfValidationError {
violations: vec![AdfSchemaViolation::DisallowedMark {
mark_type: "code".to_string(),
parent_type: "heading".to_string(),
inline_index: Some(0),
path: vec![0],
}],
};
let msg = err.to_string();
assert!(msg.contains("invalid ADF mark"), "got: {msg}");
assert!(msg.contains("'code' mark"), "got: {msg}");
assert!(msg.contains("hint: remove or correct"), "got: {msg}");
}
#[test]
fn error_display_for_invalid_mark_attr_violation() {
use crate::atlassian::adf_attr_schema::AttrProblem;
let err = AdfValidationError {
violations: vec![AdfSchemaViolation::InvalidMarkAttr {
mark_type: "link".to_string(),
attr_name: "href".to_string(),
problem: AttrProblem::BadFormat {
reason: "not a valid URL",
},
inline_index: Some(0),
path: vec![0],
}],
};
let msg = err.to_string();
assert!(msg.contains("invalid ADF mark"), "got: {msg}");
assert!(msg.contains("'link' mark"), "got: {msg}");
}
}