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 }
89 }
90 f.write_str(&out)
91 }
92}
93
94impl std::error::Error for AdfValidationError {}
95
96fn hint_for(parent: &str, child: &str) -> Option<&'static str> {
103 HINTS
104 .iter()
105 .find(|(p, c, _)| *p == parent && *c == child)
106 .map(|(_, _, h)| *h)
107}
108
109const HINTS: &[(&str, &str, &str)] = &[
110 (
111 "panel",
112 "expand",
113 "invert the nesting (put the panel inside the expand) or use siblings.",
114 ),
115 (
116 "panel",
117 "nestedExpand",
118 "invert the nesting (put the panel inside the expand) or use siblings.",
119 ),
120 (
121 "panel",
122 "panel",
123 "panels cannot nest; use siblings or convert one to a blockquote.",
124 ),
125 (
126 "expand",
127 "expand",
128 "expands cannot nest directly; consider a single expand with sectioned headings.",
129 ),
130 (
131 "expand",
132 "nestedExpand",
133 "use a plain `expand` at the inner level only inside table cells or layout columns.",
134 ),
135 (
136 "nestedExpand",
137 "expand",
138 "nestedExpand cannot contain another expand; flatten the structure.",
139 ),
140 (
141 "nestedExpand",
142 "nestedExpand",
143 "nestedExpand cannot nest; use siblings.",
144 ),
145 (
146 "nestedExpand",
147 "panel",
148 "move the panel outside the nestedExpand or replace it with a blockquote.",
149 ),
150 (
151 "tableCell",
152 "expand",
153 "use a `nestedExpand` inside table cells; `expand` is only valid at the top level or inside layout columns.",
154 ),
155 (
156 "tableHeader",
157 "expand",
158 "use a `nestedExpand` inside table headers; `expand` is only valid at the top level or inside layout columns.",
159 ),
160 (
161 "tableCell",
162 "panel",
163 "panels are not allowed inside table cells; move the panel outside the table.",
164 ),
165 (
166 "tableHeader",
167 "panel",
168 "panels are not allowed inside table headers; move the panel outside the table.",
169 ),
170 (
171 "layoutSection",
172 "layoutSection",
173 "layout sections cannot nest; use sibling sections.",
174 ),
175 (
176 "layoutColumn",
177 "layoutSection",
178 "a layout column cannot contain another layout section; flatten the structure.",
179 ),
180 (
181 "blockquote",
182 "blockquote",
183 "blockquotes cannot nest; use a single blockquote with paragraph siblings.",
184 ),
185 (
186 "blockquote",
187 "panel",
188 "move the panel outside the blockquote.",
189 ),
190 (
191 "blockquote",
192 "expand",
193 "move the expand outside the blockquote.",
194 ),
195 (
196 "listItem",
197 "panel",
198 "panels cannot appear inside list items; place the panel outside the list.",
199 ),
200 (
201 "listItem",
202 "expand",
203 "expands cannot appear inside list items; place the expand outside the list.",
204 ),
205];
206
207pub fn validate(doc: &AdfDocument) -> Result<(), AdfValidationError> {
218 let violations = validate_document(doc);
219 if violations.is_empty() {
220 Ok(())
221 } else {
222 Err(AdfValidationError { violations })
223 }
224}
225
226#[derive(Debug, Clone, PartialEq)]
238pub struct ValidatedAdfDocument(AdfDocument);
239
240impl ValidatedAdfDocument {
241 pub fn try_new(doc: AdfDocument) -> Result<Self, AdfValidationError> {
249 let violations = validate_document(&doc);
250 if violations.is_empty() {
251 Ok(Self(doc))
252 } else {
253 Err(AdfValidationError { violations })
254 }
255 }
256
257 #[must_use]
261 pub fn empty() -> Self {
262 Self(AdfDocument::new())
263 }
264
265 #[cfg(test)]
277 #[must_use]
278 pub fn trust(doc: AdfDocument) -> Self {
279 Self(doc)
280 }
281}
282
283impl Deref for ValidatedAdfDocument {
284 type Target = AdfDocument;
285
286 fn deref(&self) -> &Self::Target {
287 &self.0
288 }
289}
290
291impl Serialize for ValidatedAdfDocument {
292 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
293 self.0.serialize(serializer)
294 }
295}
296
297#[cfg(test)]
298#[allow(clippy::unwrap_used, clippy::expect_used)]
299mod tests {
300 use super::*;
301 use crate::atlassian::adf::AdfNode;
302
303 fn doc(nodes: Vec<AdfNode>) -> AdfDocument {
304 AdfDocument {
305 version: 1,
306 doc_type: "doc".to_string(),
307 content: nodes,
308 }
309 }
310
311 #[test]
312 fn try_new_accepts_clean_document() {
313 let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("ok")])]);
314 let v = ValidatedAdfDocument::try_new(d).unwrap();
315 assert_eq!(v.content.len(), 1);
316 }
317
318 #[test]
319 fn try_new_rejects_panel_with_expand() {
320 let d = doc(vec![AdfNode::panel(
325 "info",
326 vec![AdfNode::expand(None, vec![])],
327 )]);
328 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
329 assert!(err.violations.iter().any(|v| matches!(
330 v,
331 AdfSchemaViolation::DisallowedChild { child_type, parent_type, .. }
332 if child_type == "expand" && parent_type == "panel"
333 )));
334 }
335
336 #[test]
337 fn try_new_rejects_table_cell_with_expand() {
338 let d = doc(vec![AdfNode::table(vec![AdfNode::table_row(vec![
339 AdfNode::table_cell(vec![AdfNode::expand(None, vec![])]),
340 ])])]);
341 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
342 assert!(err.violations.iter().any(|v| matches!(
343 v,
344 AdfSchemaViolation::DisallowedChild { child_type, parent_type, .. }
345 if child_type == "expand" && parent_type == "tableCell"
346 )));
347 }
348
349 #[test]
350 fn try_new_allows_expand_inside_layout_column() {
351 let inner = || AdfNode::paragraph(vec![AdfNode::text("x")]);
354 let column = || AdfNode::layout_column(50, vec![AdfNode::expand(None, vec![inner()])]);
355 let d = doc(vec![AdfNode::layout_section(vec![column(), column()])]);
356 assert!(ValidatedAdfDocument::try_new(d).is_ok());
357 }
358
359 #[test]
360 fn empty_is_trivially_valid() {
361 let v = ValidatedAdfDocument::empty();
362 assert!(v.content.is_empty());
363 }
364
365 #[test]
366 fn serializes_as_inner_adf() {
367 let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("hello")])]);
368 let v = ValidatedAdfDocument::try_new(d.clone()).unwrap();
369 let v_json = serde_json::to_string(&v).unwrap();
370 let d_json = serde_json::to_string(&d).unwrap();
371 assert_eq!(v_json, d_json);
372 }
373
374 #[test]
375 fn deref_exposes_inner_fields() {
376 let d = doc(vec![AdfNode::paragraph(vec![])]);
377 let v = ValidatedAdfDocument::try_new(d).unwrap();
378 assert_eq!(v.version, 1);
379 assert_eq!(v.doc_type, "doc");
380 }
381
382 #[test]
383 fn error_display_includes_path_and_hint_for_known_pair() {
384 let d = doc(vec![AdfNode::panel(
385 "info",
386 vec![AdfNode::expand(None, vec![])],
387 )]);
388 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
389 let msg = err.to_string();
390 assert!(msg.contains("invalid ADF nesting"));
391 assert!(msg.contains("`expand` cannot be a child of `panel`"));
392 assert!(msg.contains("at /0/0"));
395 assert!(msg.contains("hint: invert the nesting"));
396 }
397
398 #[test]
399 fn error_display_falls_back_to_generic_hint_for_unknown_pair() {
400 let d = doc(vec![AdfNode::paragraph(vec![AdfNode::table(vec![])])]);
404 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
405 let msg = err.to_string();
406 assert!(msg.contains("invalid ADF nesting"));
407 assert!(msg.contains("`table` cannot be a child of `paragraph`"));
408 assert!(msg.contains("hint: restructure the document"));
409 }
410
411 #[test]
412 fn error_display_separates_multiple_violations() {
413 let d = doc(vec![
414 AdfNode::panel("info", vec![AdfNode::expand(None, vec![])]),
415 AdfNode::blockquote(vec![AdfNode::panel("note", vec![])]),
416 ]);
417 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
418 assert!(err.violations.len() >= 2);
419 let msg = err.to_string();
420 assert!(msg.contains("\n\n"));
423 }
424
425 #[test]
433 fn error_display_for_missing_attr_violation() {
434 let err = AdfValidationError {
435 violations: vec![AdfSchemaViolation::MissingAttr {
436 node_type: "panel".to_string(),
437 attr_name: "panelType".to_string(),
438 path: vec![0],
439 }],
440 };
441 let msg = err.to_string();
442 assert!(msg.contains("invalid ADF attribute"), "got: {msg}");
443 assert!(msg.contains("'panelType'"), "got: {msg}");
444 assert!(msg.contains("hint:"), "got: {msg}");
445 }
446
447 #[test]
448 fn error_display_for_invalid_attr_violation() {
449 use crate::atlassian::adf_attr_schema::AttrProblem;
450 let err = AdfValidationError {
451 violations: vec![AdfSchemaViolation::InvalidAttr {
452 node_type: "heading".to_string(),
453 attr_name: "level".to_string(),
454 problem: AttrProblem::OutOfRange {
455 lo: 1,
456 hi: 6,
457 actual: 7,
458 },
459 path: vec![0],
460 }],
461 };
462 let msg = err.to_string();
463 assert!(msg.contains("invalid ADF attribute"), "got: {msg}");
464 assert!(msg.contains("'heading.level'"), "got: {msg}");
465 }
466
467 #[test]
468 fn error_display_for_disallowed_mark_violation() {
469 let err = AdfValidationError {
470 violations: vec![AdfSchemaViolation::DisallowedMark {
471 mark_type: "code".to_string(),
472 parent_type: "heading".to_string(),
473 inline_index: Some(0),
474 path: vec![0],
475 }],
476 };
477 let msg = err.to_string();
478 assert!(msg.contains("invalid ADF mark"), "got: {msg}");
479 assert!(msg.contains("'code' mark"), "got: {msg}");
480 assert!(msg.contains("hint: remove or correct"), "got: {msg}");
481 }
482
483 #[test]
484 fn error_display_for_invalid_mark_attr_violation() {
485 use crate::atlassian::adf_attr_schema::AttrProblem;
486 let err = AdfValidationError {
487 violations: vec![AdfSchemaViolation::InvalidMarkAttr {
488 mark_type: "link".to_string(),
489 attr_name: "href".to_string(),
490 problem: AttrProblem::BadFormat {
491 reason: "not a valid URL",
492 },
493 inline_index: Some(0),
494 path: vec![0],
495 }],
496 };
497 let msg = err.to_string();
498 assert!(msg.contains("invalid ADF mark"), "got: {msg}");
499 assert!(msg.contains("'link' mark"), "got: {msg}");
500 }
501}