1use std::ops::Deref;
19
20use serde::Serialize;
21
22use crate::atlassian::adf::AdfDocument;
23use crate::atlassian::adf_schema::{validate_document, AdfSchemaViolation};
24
25#[derive(Debug, Clone, PartialEq)]
32pub struct AdfValidationError {
33 pub violations: Vec<AdfSchemaViolation>,
35}
36
37impl std::fmt::Display for AdfValidationError {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 let mut out = String::new();
44 for (i, v) in self.violations.iter().enumerate() {
45 if i > 0 {
46 out.push_str("\n\n");
47 }
48 let path = v
49 .path()
50 .iter()
51 .map(usize::to_string)
52 .collect::<Vec<_>>()
53 .join("/");
54 match v {
55 AdfSchemaViolation::DisallowedChild {
56 child_type,
57 parent_type,
58 ..
59 } => {
60 out.push_str(&format!(
61 "invalid ADF nesting — `{child_type}` cannot be a child of `{parent_type}` at /{path}.\n",
62 ));
63 let hint = hint_for(parent_type, child_type).map_or_else(
64 || {
65 format!(
66 "hint: restructure the document so `{child_type}` is not a direct child of `{parent_type}`.",
67 )
68 },
69 |h| format!("hint: {h}"),
70 );
71 out.push_str(&hint);
72 }
73 AdfSchemaViolation::Arity { .. } => {
74 out.push_str(&format!("invalid ADF nesting — {v}.\n"));
75 out.push_str(
76 "hint: adjust the number of children to match the schema's quantifier.",
77 );
78 }
79 AdfSchemaViolation::MissingAttr { .. } | AdfSchemaViolation::InvalidAttr { .. } => {
80 out.push_str(&format!("invalid ADF attribute — {v}.\n"));
81 out.push_str("hint: fix the offending attribute on the node before retrying.");
82 }
83 AdfSchemaViolation::DisallowedMark { .. }
84 | AdfSchemaViolation::InvalidMarkAttr { .. } => {
85 out.push_str(&format!("invalid ADF mark — {v}.\n"));
86 out.push_str("hint: remove or correct the offending mark before retrying.");
87 }
88 AdfSchemaViolation::ForbiddenMarkCombination { .. } => {
89 out.push_str(&format!("invalid ADF mark combination — {v}.\n"));
90 out.push_str(
91 "hint: ADF does not allow these marks on the same text — the `code` (monospace) mark only combines with a link; split the run so each piece carries a single style (e.g. drop the backticks or the surrounding bold/italic).",
92 );
93 }
94 }
95 }
96 f.write_str(&out)
97 }
98}
99
100impl std::error::Error for AdfValidationError {}
101
102fn hint_for(parent: &str, child: &str) -> Option<&'static str> {
109 HINTS
110 .iter()
111 .find(|(p, c, _)| *p == parent && *c == child)
112 .map(|(_, _, h)| *h)
113}
114
115const HINTS: &[(&str, &str, &str)] = &[
116 (
117 "panel",
118 "expand",
119 "invert the nesting (put the panel inside the expand) or use siblings.",
120 ),
121 (
122 "panel",
123 "nestedExpand",
124 "invert the nesting (put the panel inside the expand) or use siblings.",
125 ),
126 (
127 "panel",
128 "panel",
129 "panels cannot nest; use siblings or convert one to a blockquote.",
130 ),
131 (
132 "expand",
133 "expand",
134 "expands cannot nest directly; consider a single expand with sectioned headings.",
135 ),
136 (
137 "expand",
138 "nestedExpand",
139 "use a plain `expand` at the inner level only inside table cells or layout columns.",
140 ),
141 (
142 "nestedExpand",
143 "expand",
144 "nestedExpand cannot contain another expand; flatten the structure.",
145 ),
146 (
147 "nestedExpand",
148 "nestedExpand",
149 "nestedExpand cannot nest; use siblings.",
150 ),
151 (
152 "nestedExpand",
153 "panel",
154 "move the panel outside the nestedExpand or replace it with a blockquote.",
155 ),
156 (
157 "tableCell",
158 "expand",
159 "use a `nestedExpand` inside table cells; `expand` is only valid at the top level or inside layout columns.",
160 ),
161 (
162 "tableHeader",
163 "expand",
164 "use a `nestedExpand` inside table headers; `expand` is only valid at the top level or inside layout columns.",
165 ),
166 (
167 "tableCell",
168 "panel",
169 "panels are not allowed inside table cells; move the panel outside the table.",
170 ),
171 (
172 "tableHeader",
173 "panel",
174 "panels are not allowed inside table headers; move the panel outside the table.",
175 ),
176 (
177 "layoutSection",
178 "layoutSection",
179 "layout sections cannot nest; use sibling sections.",
180 ),
181 (
182 "layoutColumn",
183 "layoutSection",
184 "a layout column cannot contain another layout section; flatten the structure.",
185 ),
186 (
187 "blockquote",
188 "blockquote",
189 "blockquotes cannot nest; use a single blockquote with paragraph siblings.",
190 ),
191 (
192 "blockquote",
193 "panel",
194 "move the panel outside the blockquote.",
195 ),
196 (
197 "blockquote",
198 "expand",
199 "move the expand outside the blockquote.",
200 ),
201 (
202 "listItem",
203 "panel",
204 "panels cannot appear inside list items; place the panel outside the list.",
205 ),
206 (
207 "listItem",
208 "expand",
209 "expands cannot appear inside list items; place the expand outside the list.",
210 ),
211];
212
213pub fn validate(doc: &AdfDocument) -> Result<(), AdfValidationError> {
224 let violations = validate_document(doc);
225 if violations.is_empty() {
226 Ok(())
227 } else {
228 Err(AdfValidationError { violations })
229 }
230}
231
232#[derive(Debug, Clone, PartialEq)]
244pub struct ValidatedAdfDocument(AdfDocument);
245
246impl ValidatedAdfDocument {
247 pub fn try_new(doc: AdfDocument) -> Result<Self, AdfValidationError> {
255 let violations = validate_document(&doc);
256 if violations.is_empty() {
257 Ok(Self(doc))
258 } else {
259 Err(AdfValidationError { violations })
260 }
261 }
262
263 #[must_use]
267 pub fn empty() -> Self {
268 Self(AdfDocument::new())
269 }
270
271 #[cfg(test)]
283 #[must_use]
284 pub fn trust(doc: AdfDocument) -> Self {
285 Self(doc)
286 }
287}
288
289impl Deref for ValidatedAdfDocument {
290 type Target = AdfDocument;
291
292 fn deref(&self) -> &Self::Target {
293 &self.0
294 }
295}
296
297impl Serialize for ValidatedAdfDocument {
298 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
299 self.0.serialize(serializer)
300 }
301}
302
303#[cfg(test)]
304#[allow(clippy::unwrap_used, clippy::expect_used)]
305mod tests {
306 use super::*;
307 use crate::atlassian::adf::AdfNode;
308
309 fn doc(nodes: Vec<AdfNode>) -> AdfDocument {
310 AdfDocument {
311 version: 1,
312 doc_type: "doc".to_string(),
313 content: nodes,
314 }
315 }
316
317 #[test]
318 fn try_new_accepts_clean_document() {
319 let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("ok")])]);
320 let v = ValidatedAdfDocument::try_new(d).unwrap();
321 assert_eq!(v.content.len(), 1);
322 }
323
324 #[test]
325 fn try_new_rejects_panel_with_expand() {
326 let d = doc(vec![AdfNode::panel(
331 "info",
332 vec![AdfNode::expand(None, vec![])],
333 )]);
334 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
335 assert!(err.violations.iter().any(|v| matches!(
336 v,
337 AdfSchemaViolation::DisallowedChild { child_type, parent_type, .. }
338 if child_type == "expand" && parent_type == "panel"
339 )));
340 }
341
342 #[test]
343 fn try_new_rejects_table_cell_with_expand() {
344 let d = doc(vec![AdfNode::table(vec![AdfNode::table_row(vec![
345 AdfNode::table_cell(vec![AdfNode::expand(None, vec![])]),
346 ])])]);
347 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
348 assert!(err.violations.iter().any(|v| matches!(
349 v,
350 AdfSchemaViolation::DisallowedChild { child_type, parent_type, .. }
351 if child_type == "expand" && parent_type == "tableCell"
352 )));
353 }
354
355 #[test]
356 fn try_new_allows_expand_inside_layout_column() {
357 let inner = || AdfNode::paragraph(vec![AdfNode::text("x")]);
360 let column = || AdfNode::layout_column(50, vec![AdfNode::expand(None, vec![inner()])]);
361 let d = doc(vec![AdfNode::layout_section(vec![column(), column()])]);
362 assert!(ValidatedAdfDocument::try_new(d).is_ok());
363 }
364
365 #[test]
366 fn empty_is_trivially_valid() {
367 let v = ValidatedAdfDocument::empty();
368 assert!(v.content.is_empty());
369 }
370
371 #[test]
372 fn serializes_as_inner_adf() {
373 let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("hello")])]);
374 let v = ValidatedAdfDocument::try_new(d.clone()).unwrap();
375 let v_json = serde_json::to_string(&v).unwrap();
376 let d_json = serde_json::to_string(&d).unwrap();
377 assert_eq!(v_json, d_json);
378 }
379
380 #[test]
381 fn deref_exposes_inner_fields() {
382 let d = doc(vec![AdfNode::paragraph(vec![])]);
383 let v = ValidatedAdfDocument::try_new(d).unwrap();
384 assert_eq!(v.version, 1);
385 assert_eq!(v.doc_type, "doc");
386 }
387
388 #[test]
389 fn error_display_includes_path_and_hint_for_known_pair() {
390 let d = doc(vec![AdfNode::panel(
391 "info",
392 vec![AdfNode::expand(None, vec![])],
393 )]);
394 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
395 let msg = err.to_string();
396 assert!(msg.contains("invalid ADF nesting"));
397 assert!(msg.contains("`expand` cannot be a child of `panel`"));
398 assert!(msg.contains("at /0/0"));
401 assert!(msg.contains("hint: invert the nesting"));
402 }
403
404 #[test]
405 fn error_display_falls_back_to_generic_hint_for_unknown_pair() {
406 let d = doc(vec![AdfNode::paragraph(vec![AdfNode::table(vec![])])]);
410 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
411 let msg = err.to_string();
412 assert!(msg.contains("invalid ADF nesting"));
413 assert!(msg.contains("`table` cannot be a child of `paragraph`"));
414 assert!(msg.contains("hint: restructure the document"));
415 }
416
417 #[test]
418 fn error_display_separates_multiple_violations() {
419 let d = doc(vec![
420 AdfNode::panel("info", vec![AdfNode::expand(None, vec![])]),
421 AdfNode::blockquote(vec![AdfNode::panel("note", vec![])]),
422 ]);
423 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
424 assert!(err.violations.len() >= 2);
425 let msg = err.to_string();
426 assert!(msg.contains("\n\n"));
429 }
430
431 #[test]
439 fn error_display_for_missing_attr_violation() {
440 let err = AdfValidationError {
441 violations: vec![AdfSchemaViolation::MissingAttr {
442 node_type: "panel".to_string(),
443 attr_name: "panelType".to_string(),
444 path: vec![0],
445 }],
446 };
447 let msg = err.to_string();
448 assert!(msg.contains("invalid ADF attribute"), "got: {msg}");
449 assert!(msg.contains("'panelType'"), "got: {msg}");
450 assert!(msg.contains("hint:"), "got: {msg}");
451 }
452
453 #[test]
454 fn error_display_for_invalid_attr_violation() {
455 use crate::atlassian::adf_attr_schema::AttrProblem;
456 let err = AdfValidationError {
457 violations: vec![AdfSchemaViolation::InvalidAttr {
458 node_type: "heading".to_string(),
459 attr_name: "level".to_string(),
460 problem: AttrProblem::OutOfRange {
461 lo: 1,
462 hi: 6,
463 actual: 7,
464 },
465 path: vec![0],
466 }],
467 };
468 let msg = err.to_string();
469 assert!(msg.contains("invalid ADF attribute"), "got: {msg}");
470 assert!(msg.contains("'heading.level'"), "got: {msg}");
471 }
472
473 #[test]
474 fn error_display_for_disallowed_mark_violation() {
475 let err = AdfValidationError {
476 violations: vec![AdfSchemaViolation::DisallowedMark {
477 mark_type: "code".to_string(),
478 parent_type: "heading".to_string(),
479 inline_index: Some(0),
480 path: vec![0],
481 }],
482 };
483 let msg = err.to_string();
484 assert!(msg.contains("invalid ADF mark"), "got: {msg}");
485 assert!(msg.contains("'code' mark"), "got: {msg}");
486 assert!(msg.contains("hint: remove or correct"), "got: {msg}");
487 }
488
489 #[test]
490 fn error_display_for_forbidden_mark_combination() {
491 let err = AdfValidationError {
492 violations: vec![AdfSchemaViolation::ForbiddenMarkCombination {
493 mark_type: "strong".to_string(),
494 conflicts_with: "code".to_string(),
495 parent_type: "paragraph".to_string(),
496 inline_index: Some(0),
497 path: vec![0, 0],
498 }],
499 };
500 let msg = err.to_string();
501 assert!(msg.contains("invalid ADF mark combination"), "got: {msg}");
502 assert!(
503 msg.contains("'strong' mark cannot be combined with 'code' mark"),
504 "got: {msg}"
505 );
506 assert!(msg.contains("hint:"), "got: {msg}");
507 }
508
509 #[test]
510 fn try_new_rejects_strong_plus_code_text() {
511 let text = AdfNode {
514 node_type: "text".to_string(),
515 attrs: None,
516 content: None,
517 text: Some("x".to_string()),
518 marks: Some(vec![
519 crate::atlassian::adf::AdfMark::strong(),
520 crate::atlassian::adf::AdfMark::code(),
521 ]),
522 local_id: None,
523 parameters: None,
524 };
525 let d = doc(vec![AdfNode::paragraph(vec![text])]);
526 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
527 assert!(err.violations.iter().any(|v| matches!(
528 v,
529 AdfSchemaViolation::ForbiddenMarkCombination { mark_type, conflicts_with, .. }
530 if mark_type == "strong" && conflicts_with == "code"
531 )));
532 }
533
534 #[test]
535 fn error_display_for_invalid_mark_attr_violation() {
536 use crate::atlassian::adf_attr_schema::AttrProblem;
537 let err = AdfValidationError {
538 violations: vec![AdfSchemaViolation::InvalidMarkAttr {
539 mark_type: "link".to_string(),
540 attr_name: "href".to_string(),
541 problem: AttrProblem::BadFormat {
542 reason: "not a valid URL",
543 },
544 inline_index: Some(0),
545 path: vec![0],
546 }],
547 };
548 let msg = err.to_string();
549 assert!(msg.contains("invalid ADF mark"), "got: {msg}");
550 assert!(msg.contains("'link' mark"), "got: {msg}");
551 }
552}