Skip to main content

omni_dev/atlassian/
adf_validated.rs

1//! Type-system enforcement of "validated once before send" for ADF documents.
2//!
3//! This module ties together:
4//! - the upstream-faithful schema validator from
5//!   [`crate::atlassian::adf_schema`] (introduced by ADR-0023), and
6//! - a [`ValidatedAdfDocument`] newtype whose only fallible constructor runs
7//!   that validator.
8//!
9//! Every API send signature in [`crate::atlassian::client`] and
10//! [`crate::atlassian::confluence_api`] accepts `&ValidatedAdfDocument`
11//! rather than `&AdfDocument`, which makes "I forgot to validate" a compile
12//! error rather than the opaque HTTP 500 from Confluence that motivates
13//! issue #714.
14//!
15//! See ADR-0024 for the wiring rationale and the per-`(parent, child)` hint
16//! table surfaced through [`AdfValidationError`]'s `Display` impl.
17
18use std::ops::Deref;
19
20use serde::Serialize;
21
22use crate::atlassian::adf::AdfDocument;
23use crate::atlassian::adf_schema::{validate_document, AdfSchemaViolation};
24
25/// One or more nesting violations discovered when validating an
26/// [`AdfDocument`] against the upstream content model.
27//
28// `Eq` is intentionally not derived: `AdfSchemaViolation::InvalidAttr`
29// carries an `AttrProblem` whose `OutOfRangeF` variant holds `f64`, which
30// does not implement `Eq`. `PartialEq` is sufficient for all uses.
31#[derive(Debug, Clone, PartialEq)]
32pub struct AdfValidationError {
33    /// All violations found, in document order.
34    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        // Build the full message in a String first, then emit it with a
40        // single `write!`. That collapses several formatter-`?` branches into
41        // one, which keeps coverage tools from flagging each `writeln!` /
42        // `write!` site as a partially-covered branch.
43        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
102/// Returns the actionable hint for a known forbidden parent → child pair, or
103/// `None` when the pair is forbidden by the schema but we have no hand-written
104/// remediation guidance for it. The hint table covers the high-traffic
105/// combinations called out in issue #714 plus other known-painful cases; the
106/// generic fallback in [`AdfValidationError`]'s `Display` impl covers
107/// everything else.
108fn 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
213/// Returns `Ok(())` if `doc` has no nesting violations, else an
214/// [`AdfValidationError`] listing every violation found.
215///
216/// Borrows `doc`; use [`ValidatedAdfDocument::try_new`] when the goal is to
217/// produce a validated wrapper rather than just check.
218///
219/// # Errors
220///
221/// Returns [`AdfValidationError`] when the document violates one or more
222/// allowed-children rules in the upstream content model.
223pub 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/// An [`AdfDocument`] that has passed nesting validation against the
233/// upstream content model.
234///
235/// Constructing one is the only way to satisfy the type signatures of the
236/// API send functions in [`crate::atlassian::client`] and
237/// [`crate::atlassian::confluence_api`]. This makes "I forgot to validate"
238/// a compile error rather than a runtime opaque-500 error.
239///
240/// `Deref<Target = AdfDocument>` and a delegated `Serialize` impl let
241/// callers continue to use the validated document anywhere a `&AdfDocument`
242/// or serialized JSON value is needed.
243#[derive(Debug, Clone, PartialEq)]
244pub struct ValidatedAdfDocument(AdfDocument);
245
246impl ValidatedAdfDocument {
247    /// Validates `doc` against the upstream ADF content model and wraps it
248    /// on success.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`AdfValidationError`] if `doc` contains any disallowed
253    /// nesting per the schema in [`crate::atlassian::adf_schema`].
254    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    /// Returns a trivially-valid empty document without invoking the
264    /// validator. Useful for tests and for code paths that need an
265    /// "unset" placeholder.
266    #[must_use]
267    pub fn empty() -> Self {
268        Self(AdfDocument::new())
269    }
270
271    /// Test-only constructor that wraps `doc` *without* running the
272    /// validator.
273    ///
274    /// Reserved for tests that need to drive a send function with an
275    /// intentionally-invalid document — for example, the HTTP-500 diagnosis
276    /// path tests in [`crate::atlassian::confluence_api`] which assert the
277    /// post-response diagnoser fires when a violation slips past the local
278    /// validator.
279    ///
280    /// **Never use in production code.** Production callers must go through
281    /// [`Self::try_new`] so validation is guaranteed.
282    #[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        // Issue #714 reproducer. Since arity checking landed in #733, an
327        // empty `expand` (and the panel that lacks any valid children)
328        // also generate Arity violations — assertion is on the
329        // disallowed-child case, the one the user cares about.
330        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        // layoutSection requires 2..=3 columns (Range quantifier) and the
358        // expand needs ≥1 child, so the document is composed accordingly.
359        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        // adf_schema's path is index-only from the document root; the
399        // panel sits at /0 and its expand child at /0/0.
400        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        // `paragraph → table` is forbidden by the schema but is not in our
407        // hand-written hint table; the generic fallback should still give
408        // the user something actionable.
409        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        // Two violations imply a blank-line separator (two consecutive
427        // newlines) between them.
428        assert!(msg.contains("\n\n"));
429    }
430
431    // ── Display arms for non-nesting variant kinds ────────────────────
432    //
433    // Each variant kind in `AdfSchemaViolation` produces a different
434    // `AdfValidationError` Display section (nesting / arity / attr / mark).
435    // Cover the attr and mark sections directly by constructing the
436    // error rather than going through the validator.
437
438    #[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        // Issue #1047 reproducer at the validator boundary: a paragraph whose
512        // text carries both `strong` and `code` is rejected before send.
513        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}