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            }
89        }
90        f.write_str(&out)
91    }
92}
93
94impl std::error::Error for AdfValidationError {}
95
96/// Returns the actionable hint for a known forbidden parent → child pair, or
97/// `None` when the pair is forbidden by the schema but we have no hand-written
98/// remediation guidance for it. The hint table covers the high-traffic
99/// combinations called out in issue #714 plus other known-painful cases; the
100/// generic fallback in [`AdfValidationError`]'s `Display` impl covers
101/// everything else.
102fn 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
207/// Returns `Ok(())` if `doc` has no nesting violations, else an
208/// [`AdfValidationError`] listing every violation found.
209///
210/// Borrows `doc`; use [`ValidatedAdfDocument::try_new`] when the goal is to
211/// produce a validated wrapper rather than just check.
212///
213/// # Errors
214///
215/// Returns [`AdfValidationError`] when the document violates one or more
216/// allowed-children rules in the upstream content model.
217pub 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/// An [`AdfDocument`] that has passed nesting validation against the
227/// upstream content model.
228///
229/// Constructing one is the only way to satisfy the type signatures of the
230/// API send functions in [`crate::atlassian::client`] and
231/// [`crate::atlassian::confluence_api`]. This makes "I forgot to validate"
232/// a compile error rather than a runtime opaque-500 error.
233///
234/// `Deref<Target = AdfDocument>` and a delegated `Serialize` impl let
235/// callers continue to use the validated document anywhere a `&AdfDocument`
236/// or serialized JSON value is needed.
237#[derive(Debug, Clone, PartialEq)]
238pub struct ValidatedAdfDocument(AdfDocument);
239
240impl ValidatedAdfDocument {
241    /// Validates `doc` against the upstream ADF content model and wraps it
242    /// on success.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`AdfValidationError`] if `doc` contains any disallowed
247    /// nesting per the schema in [`crate::atlassian::adf_schema`].
248    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    /// Returns a trivially-valid empty document without invoking the
258    /// validator. Useful for tests and for code paths that need an
259    /// "unset" placeholder.
260    #[must_use]
261    pub fn empty() -> Self {
262        Self(AdfDocument::new())
263    }
264
265    /// Test-only constructor that wraps `doc` *without* running the
266    /// validator.
267    ///
268    /// Reserved for tests that need to drive a send function with an
269    /// intentionally-invalid document — for example, the HTTP-500 diagnosis
270    /// path tests in [`crate::atlassian::confluence_api`] which assert the
271    /// post-response diagnoser fires when a violation slips past the local
272    /// validator.
273    ///
274    /// **Never use in production code.** Production callers must go through
275    /// [`Self::try_new`] so validation is guaranteed.
276    #[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        // Issue #714 reproducer. Since arity checking landed in #733, an
321        // empty `expand` (and the panel that lacks any valid children)
322        // also generate Arity violations — assertion is on the
323        // disallowed-child case, the one the user cares about.
324        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        // layoutSection requires 2..=3 columns (Range quantifier) and the
352        // expand needs ≥1 child, so the document is composed accordingly.
353        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        // adf_schema's path is index-only from the document root; the
393        // panel sits at /0 and its expand child at /0/0.
394        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        // `paragraph → table` is forbidden by the schema but is not in our
401        // hand-written hint table; the generic fallback should still give
402        // the user something actionable.
403        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        // Two violations imply a blank-line separator (two consecutive
421        // newlines) between them.
422        assert!(msg.contains("\n\n"));
423    }
424
425    // ── Display arms for non-nesting variant kinds ────────────────────
426    //
427    // Each variant kind in `AdfSchemaViolation` produces a different
428    // `AdfValidationError` Display section (nesting / arity / attr / mark).
429    // Cover the attr and mark sections directly by constructing the
430    // error rather than going through the validator.
431
432    #[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}